add new type of self-learning
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

This commit is contained in:
2026-06-29 19:38:00 -06:00
parent 71fc5e3246
commit 5867bbceed
10 changed files with 1113 additions and 31 deletions

View File

@@ -8,6 +8,7 @@ import type {
BlankProblem,
Contest,
Exercise,
ExerciseType,
Server,
TestcaseUploadedReturns,
Tutorial,
@@ -272,7 +273,7 @@ export async function getAdminExercises(tutorialId: number) {
export async function createExercise(data: {
tutorial_id: number
type: "mcq" | "sort" | "fill"
type: ExerciseType
data: object
order: number
}) {
@@ -282,7 +283,7 @@ export async function createExercise(data: {
export async function updateExercise(data: {
id: number
type: "mcq" | "sort" | "fill"
type: ExerciseType
data: object
order: number
}) {

View File

@@ -1,9 +1,14 @@
<script setup lang="ts">
import {
Exercise,
ExerciseType,
ExerciseMcqData,
ExerciseSortData,
ExerciseFillData,
ExerciseMatchData,
ExercisePredictData,
ExerciseDebugData,
ExerciseGroupData,
} from "utils/types"
import {
getAdminExercises,
@@ -19,7 +24,7 @@ const dialog = useDialog()
const exercises = ref<Exercise[]>([])
const showForm = ref(false)
const editingId = ref<number | null>(null)
const formType = ref<"mcq" | "sort" | "fill">("mcq")
const formType = ref<ExerciseType>("mcq")
const formOrder = ref(0)
const mcqQuestion = ref("")
@@ -32,16 +37,34 @@ const sortCode = ref("")
const fillQuestion = ref("")
const fillCode = ref("")
const matchQuestion = ref("")
const matchLeft = ref("")
const matchRight = ref("")
const predictQuestion = ref("")
const predictCode = ref("")
const predictAnswer = ref("")
const debugQuestion = ref("")
const debugCode = ref("")
const debugAnswer = ref<number[]>([])
const debugExplanation = ref("")
const groupQuestion = ref("")
const groupBuckets = ref("")
const groupItems = ref("")
const debugLines = computed(() =>
debugCode.value === "" ? [] : debugCode.value.split("\n"),
)
async function load() {
exercises.value = await getAdminExercises(props.tutorialId)
}
onMounted(load)
function openCreate() {
editingId.value = null
formType.value = "mcq"
formOrder.value = exercises.value.length
function resetForms() {
mcqQuestion.value = ""
mcqOptions.value = ["", ""]
mcqAnswer.value = []
@@ -49,6 +72,26 @@ function openCreate() {
sortCode.value = ""
fillQuestion.value = ""
fillCode.value = ""
matchQuestion.value = ""
matchLeft.value = ""
matchRight.value = ""
predictQuestion.value = ""
predictCode.value = ""
predictAnswer.value = ""
debugQuestion.value = ""
debugCode.value = ""
debugAnswer.value = []
debugExplanation.value = ""
groupQuestion.value = ""
groupBuckets.value = ""
groupItems.value = ""
}
function openCreate() {
editingId.value = null
formType.value = "mcq"
formOrder.value = exercises.value.length
resetForms()
showForm.value = true
}
@@ -56,6 +99,7 @@ function openEdit(ex: Exercise) {
editingId.value = ex.id
formType.value = ex.type
formOrder.value = ex.order
resetForms()
if (ex.type === "mcq") {
const d = ex.data as ExerciseMcqData
mcqQuestion.value = d.question
@@ -65,10 +109,34 @@ function openEdit(ex: Exercise) {
const d = ex.data as ExerciseSortData
sortQuestion.value = d.question
sortCode.value = d.lines.join("\n")
} else {
} else if (ex.type === "fill") {
const d = ex.data as ExerciseFillData
fillQuestion.value = d.question
fillCode.value = d.code
} else if (ex.type === "match") {
const d = ex.data as ExerciseMatchData
matchQuestion.value = d.question
matchLeft.value = d.left.join("\n")
// 按答案顺序还原右列,重存时识别答案保持为顺序对应
matchRight.value = d.answer.map((a) => d.right[a]).join("\n")
} else if (ex.type === "predict") {
const d = ex.data as ExercisePredictData
predictQuestion.value = d.question
predictCode.value = d.code
predictAnswer.value = d.answer.join("\n===\n")
} else if (ex.type === "debug") {
const d = ex.data as ExerciseDebugData
debugQuestion.value = d.question
debugCode.value = d.lines.join("\n")
debugAnswer.value = [...d.answer]
debugExplanation.value = d.explanation ?? ""
} else if (ex.type === "group") {
const d = ex.data as ExerciseGroupData
groupQuestion.value = d.question
groupBuckets.value = d.buckets.join("\n")
groupItems.value = d.items
.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`)
.join("\n")
}
showForm.value = true
}
@@ -79,26 +147,131 @@ function toggleAnswer(i: number) {
else mcqAnswer.value.splice(idx, 1)
}
async function save() {
if (formType.value === "mcq" && mcqAnswer.value.length === 0) {
message.error("请至少勾选一个正确答案")
return
function toggleDebug(i: number) {
const idx = debugAnswer.value.indexOf(i)
if (idx === -1) debugAnswer.value.push(i)
else debugAnswer.value.splice(idx, 1)
}
let data: Record<string, unknown>
function splitLines(text: string): string[] {
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l !== "")
}
function buildData(): Record<string, unknown> | null {
if (formType.value === "mcq") {
data = {
if (mcqAnswer.value.length === 0) {
message.error("请至少勾选一个正确答案")
return null
}
return {
question: mcqQuestion.value || "下面选项中正确是哪个?",
options: mcqOptions.value,
answer: mcqAnswer.value,
}
} else if (formType.value === "sort") {
data = {
}
if (formType.value === "sort") {
return {
question: sortQuestion.value || "将下列代码行排列为正确顺序",
lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""),
}
} else {
data = { question: fillQuestion.value, code: fillCode.value }
}
if (formType.value === "fill") {
return { question: fillQuestion.value, code: fillCode.value }
}
if (formType.value === "match") {
const left = splitLines(matchLeft.value)
const right = splitLines(matchRight.value)
if (left.length < 2 || left.length !== right.length) {
message.error("左右两列需各至少 2 项且行数相等(按行一一对应)")
return null
}
return {
question: matchQuestion.value || "把左右两列正确连线",
left,
right,
answer: left.map((_, i) => i),
}
}
if (formType.value === "predict") {
if (predictCode.value.trim() === "") {
message.error("请填写代码")
return null
}
const answer = predictAnswer.value
.split(/\n===\n/)
.map((a) => a.replace(/\s+$/, ""))
.filter((a) => a.trim() !== "")
if (answer.length === 0) {
message.error("请填写至少一个正确输出")
return null
}
return {
question: predictQuestion.value || "这段代码会输出什么?",
code: predictCode.value,
answer,
}
}
if (formType.value === "debug") {
const lines = debugCode.value.split("\n")
const answer = debugAnswer.value
.filter((i) => i < lines.length)
.sort((a, b) => a - b)
if (lines.length === 0 || answer.length === 0) {
message.error("请填写代码并勾选至少一行错误")
return null
}
const data: Record<string, unknown> = {
question: debugQuestion.value || "下面代码哪几行有错?",
lines,
answer,
}
if (debugExplanation.value.trim() !== "") {
data.explanation = debugExplanation.value.trim()
}
return data
}
// group
const buckets = splitLines(groupBuckets.value)
if (buckets.length < 2) {
message.error("请至少填写 2 个分组")
return null
}
const items: string[] = []
const answer: number[] = []
for (const line of groupItems.value.split("\n")) {
if (line.trim() === "") continue
const parts = line.split("=>")
if (parts.length !== 2) {
message.error(`项目格式应为「项目 => 分组名」:${line}`)
return null
}
const item = parts[0].trim()
const bucket = buckets.indexOf(parts[1].trim())
if (item === "" || bucket === -1) {
message.error(`项目或分组名无效:${line}`)
return null
}
items.push(item)
answer.push(bucket)
}
if (items.length === 0) {
message.error("请至少填写一个项目")
return null
}
return {
question: groupQuestion.value || "把下列项目归类到正确的分组",
buckets,
items,
answer,
}
}
async function save() {
const data = buildData()
if (data === null) return
try {
if (editingId.value) {
@@ -143,16 +316,35 @@ function copyPlaceholder(id: number) {
message.success(`已复制 [[exercise:${id}]]`)
}
function typeName(type: string) {
if (type === "mcq") return "选择题"
if (type === "sort") return "代码排序"
return "代码填空"
const TYPE_NAMES: Record<ExerciseType, string> = {
mcq: "选择题",
sort: "代码排序",
fill: "代码填空",
match: "连线匹配",
predict: "输出预测",
debug: "代码找错",
group: "归类分组",
}
function typeTagType(type: string): "success" | "info" | "warning" {
if (type === "mcq") return "success"
if (type === "sort") return "info"
return "warning"
const TYPE_TAGS: Record<
ExerciseType,
"success" | "info" | "warning" | "error" | "primary" | "default"
> = {
mcq: "success",
sort: "info",
fill: "warning",
match: "primary",
predict: "error",
debug: "info",
group: "warning",
}
function typeName(type: ExerciseType) {
return TYPE_NAMES[type] ?? type
}
function typeTagType(type: ExerciseType) {
return TYPE_TAGS[type] ?? "default"
}
</script>
@@ -208,6 +400,10 @@ function typeTagType(type: string): "success" | "info" | "warning" {
<n-radio value="mcq">选择题</n-radio>
<n-radio value="sort">代码排序</n-radio>
<n-radio value="fill">代码填空</n-radio>
<n-radio value="match">连线匹配</n-radio>
<n-radio value="predict">输出预测</n-radio>
<n-radio value="debug">代码找错</n-radio>
<n-radio value="group">归类分组</n-radio>
</n-radio-group>
</n-form-item>
@@ -251,7 +447,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
@click="
() => {
mcqOptions.splice(i, 1)
mcqAnswer.value = mcqAnswer.value
mcqAnswer = mcqAnswer
.filter((a) => a !== i)
.map((a) => (a > i ? a - 1 : a))
}
@@ -287,7 +483,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
</n-form-item>
</template>
<template v-else>
<template v-else-if="formType === 'fill'">
<n-form-item label="题目说明">
<n-input
v-model:value="fillQuestion"
@@ -306,6 +502,143 @@ function typeTagType(type: string): "success" | "info" | "warning" {
/>
</n-form-item>
</template>
<template v-else-if="formType === 'match'">
<n-form-item label="题目说明">
<n-input
v-model:value="matchQuestion"
type="textarea"
:rows="2"
placeholder="例:把函数和它的功能连起来"
/>
</n-form-item>
<n-form-item label="左列(每行一项)">
<n-input
v-model:value="matchLeft"
type="textarea"
:rows="6"
placeholder="print&#10;len&#10;type"
/>
</n-form-item>
<n-form-item label="右列(与左列按行一一对应,保存后右列自动乱序)">
<n-input
v-model:value="matchRight"
type="textarea"
:rows="6"
placeholder="输出内容&#10;返回长度&#10;返回类型"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'predict'">
<n-form-item label="题目说明">
<n-input
v-model:value="predictQuestion"
type="textarea"
:rows="2"
placeholder="例:这段代码会输出什么?"
/>
</n-form-item>
<n-form-item label="代码">
<n-input
v-model:value="predictCode"
type="textarea"
:rows="8"
placeholder="print(1 + 2)"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
<n-form-item
label="正确输出(多个可接受答案之间用单独一行 === 分隔)"
>
<n-input
v-model:value="predictAnswer"
type="textarea"
:rows="4"
placeholder="3"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'debug'">
<n-form-item label="题目说明">
<n-input
v-model:value="debugQuestion"
type="textarea"
:rows="2"
placeholder="例:下面代码哪几行有错?"
/>
</n-form-item>
<n-form-item label="代码(每行一项)">
<n-input
v-model:value="debugCode"
type="textarea"
:rows="8"
placeholder="在此粘贴含错误的代码"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
<n-form-item label="勾选错误行">
<n-space vertical style="width: 100%">
<n-empty
v-if="debugLines.length === 0"
description="先填写代码"
size="small"
/>
<n-flex
v-for="(line, i) in debugLines"
:key="i"
align="center"
:size="8"
>
<n-checkbox
:checked="debugAnswer.includes(i)"
@update:checked="toggleDebug(i)"
/>
<n-text style="font-family: Monaco; white-space: pre">
{{ i + 1 }}. {{ line }}
</n-text>
</n-flex>
</n-space>
</n-form-item>
<n-form-item label="错误说明(可选,提交后展示)">
<n-input
v-model:value="debugExplanation"
type="textarea"
:rows="2"
placeholder="例:第 2 行少了冒号"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'group'">
<n-form-item label="题目说明">
<n-input
v-model:value="groupQuestion"
type="textarea"
:rows="2"
placeholder="例:把下面的值归类到正确的类型"
/>
</n-form-item>
<n-form-item label="分组(每行一个分组名)">
<n-input
v-model:value="groupBuckets"
type="textarea"
:rows="4"
placeholder="int&#10;float&#10;str"
/>
</n-form-item>
<n-form-item label="项目(每行「项目 => 分组名」)">
<n-input
v-model:value="groupItems"
type="textarea"
:rows="6"
placeholder="3 => int&#10;3.14 => float&#10;hello => str"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
</template>
</n-form>
<template #footer>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseDebugData)
const lineHtml = computed(() => highlightLines(data.value.lines, props.lang))
const selected = ref<Set<number>>(new Set())
const submitted = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
const allCorrect = computed(() => {
const ans = new Set(data.value.answer)
if (selected.value.size !== ans.size) return false
for (const i of selected.value) if (!ans.has(i)) return false
return true
})
const locked = computed(() => submitted.value && allCorrect.value)
function toggle(i: number) {
if (locked.value) return
submitted.value = false
const s = new Set(selected.value)
if (s.has(i)) s.delete(i)
else s.add(i)
selected.value = s
}
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"
}
function lineStyle(i: number): Record<string, string> {
const status = lineStatus(i)
const color =
status === "correct"
? "#18a058"
: status === "wrong"
? "#d03050"
: status === "selected"
? "#2080f0"
: "var(--n-border-color)"
const plain = color === "var(--n-border-color)"
return {
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: "Monaco",
userSelect: "none",
}
}
function submit() {
submitted.value = true
}
function reset() {
selected.value = new Set()
submitted.value = false
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码找错</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
点击你认为有错误的代码行可多选
</p>
<n-space vertical :size="6">
<div
v-for="(line, 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 }}
</span>
<span v-html="lineHtml[idx]" style="white-space: pre" />
</div>
</n-space>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'"
style="margin-top: 12px"
>
<template v-if="submitted && data.explanation" #default>
{{ data.explanation }}
</template>
</n-alert>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="locked" @click="submit">提交</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { Exercise, ExerciseGroupData } from "utils/types"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseGroupData)
const order = ref<number[]>([]) // item 的稳定展示顺序(初始乱序)
const placement = ref<number[]>([]) // placement[itemIdx] = 桶下标,-1 表示在池中
const dragIdx = ref<number | null>(null)
const submitted = ref(false)
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
function init() {
order.value = shuffle(data.value.items.map((_, i) => i))
placement.value = Array(data.value.items.length).fill(-1)
dragIdx.value = null
submitted.value = false
}
onMounted(init)
watch(() => props.exercise.id, init)
const allPlaced = computed(() => placement.value.every((p) => p !== -1))
const allCorrect = computed(() =>
placement.value.every((p, i) => p === data.value.answer[i]),
)
const locked = computed(() => submitted.value && allCorrect.value)
function onDragStart(i: number) {
if (locked.value) return
dragIdx.value = i
}
function dropTo(bucket: number) {
if (locked.value || dragIdx.value === null) return
placement.value[dragIdx.value] = bucket
dragIdx.value = null
submitted.value = false
}
const poolItems = computed(() =>
order.value.filter((i) => placement.value[i] === -1),
)
function itemsIn(bucket: number): number[] {
return order.value.filter((i) => placement.value[i] === bucket)
}
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(--n-border-color)"
const plain = color === "var(--n-border-color)"
return {
padding: "6px 12px",
borderRadius: "6px",
border: `1.5px solid ${color}`,
background: plain ? "var(--n-color)" : color + "14",
cursor: locked.value ? "default" : "grab",
userSelect: "none",
fontSize: "15px",
}
}
function submit() {
submitted.value = true
}
function reset() {
init()
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 归类分组</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
把下面的项目拖到对应的分组里可在分组间拖动调整
</p>
<div
:style="{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
minHeight: '48px',
padding: '10px',
border: '1.5px dashed var(--n-border-color)',
borderRadius: '8px',
marginBottom: '14px',
}"
@dragover.prevent
@drop="dropTo(-1)"
>
<span
v-if="poolItems.length === 0"
style="color: var(--n-text-color-3); font-size: 13px"
>
已全部归类
</span>
<div
v-for="i in poolItems"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
>
{{ data.items[i] }}
</div>
</div>
<div
:style="{
display: 'grid',
gridTemplateColumns: `repeat(${data.buckets.length}, 1fr)`,
gap: '12px',
}"
>
<div
v-for="(bucket, b) in data.buckets"
:key="b"
:style="{
minHeight: '88px',
padding: '10px',
border: '1.5px solid var(--n-border-color)',
borderRadius: '8px',
}"
@dragover.prevent
@drop="dropTo(b)"
>
<p
style="
font-weight: 600;
margin: 0 0 8px;
text-align: center;
font-size: 14px;
"
>
{{ bucket }}
</p>
<n-space :size="8">
<div
v-for="i in itemsIn(b)"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
>
{{ data.items[i] }}
</div>
</n-space>
</div>
</div>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { Exercise, ExerciseMatchData } from "utils/types"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseMatchData)
const PALETTE = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#8a2be2",
"#0891b2",
"#db2777",
"#65a30d",
]
const rightOrder = ref<number[]>([]) // 显示顺序里的 right 原始下标
const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原始下标
const selectedLeft = ref<number | null>(null)
const submitted = ref(false)
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
function init() {
const n = data.value.right.length
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
pairs.value = Array(data.value.left.length).fill(null)
selectedLeft.value = null
submitted.value = false
}
onMounted(init)
watch(() => props.exercise.id, init)
const allPaired = computed(() => pairs.value.every((p) => p !== null))
const allCorrect = computed(() =>
pairs.value.every((p, i) => p === data.value.answer[i]),
)
const locked = computed(() => submitted.value && allCorrect.value)
function leftOf(rightIdx: number): number {
return pairs.value.findIndex((p) => p === rightIdx)
}
function onLeftClick(i: number) {
if (locked.value) return
submitted.value = false
if (pairs.value[i] !== null) {
pairs.value[i] = null
selectedLeft.value = i
return
}
selectedLeft.value = selectedLeft.value === i ? null : i
}
function onRightClick(rightIdx: number) {
if (locked.value) return
submitted.value = false
if (selectedLeft.value === null) {
const l = leftOf(rightIdx)
if (l !== -1) pairs.value[l] = null
return
}
const prev = leftOf(rightIdx)
if (prev !== -1) pairs.value[prev] = null
pairs.value[selectedLeft.value] = rightIdx
selectedLeft.value = null
}
function submit() {
submitted.value = true
}
function reset() {
init()
}
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 (selectedLeft.value === i) return "#2080f0"
if (pairs.value[i] !== null) return PALETTE[i % PALETTE.length]
return "var(--n-border-color)"
}
function rightColor(rightIdx: number): string {
const l = leftOf(rightIdx)
if (submitted.value) {
if (l === -1) return "var(--n-border-color)"
return pairs.value[l] === data.value.answer[l] ? "#18a058" : "#d03050"
}
if (l === -1) return "var(--n-border-color)"
return PALETTE[l % PALETTE.length]
}
function itemStyle(color: string, selected: boolean): Record<string, string> {
const plain = color === "var(--n-border-color)"
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",
}
}
function dotStyle(color: string): Record<string, string> {
return {
width: "10px",
height: "10px",
borderRadius: "50%",
background: color,
flexShrink: "0",
}
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="primary" :bordered="false">练一练 · 连线匹配</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); 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])"
/>
<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])"
/>
<span>{{ data.right[rightIdx] }}</span>
</div>
</n-space>
</div>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { Exercise, ExercisePredictData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExercisePredictData)
const codeHtml = computed(() => highlight(data.value.code, props.lang))
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+$/, "")
}
const allCorrect = computed(() =>
data.value.answer.some((a) => normalize(a) === normalize(userInput.value)),
)
function submit() {
submitted.value = true
}
function reset() {
userInput.value = ""
submitted.value = false
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="error" :bordered="false">练一练 · 输出预测</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<pre
:style="{
fontFamily: 'Monaco',
fontSize: '16px',
lineHeight: '1.6',
background: 'var(--n-color)',
border: '1px solid var(--n-border-color)',
borderRadius: '6px',
padding: '12px',
overflowX: 'auto',
margin: 0,
}"
><code v-html="codeHtml" /></pre>
<p style="font-weight: 500; margin: 14px 0 8px">这段代码会输出什么</p>
<n-input
v-model:value="userInput"
type="textarea"
:rows="3"
:disabled="submitted && allCorrect"
placeholder="在这里输入程序会打印的内容"
style="font-family: Monaco"
/>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button
type="error"
:disabled="submitted && allCorrect"
@click="submit"
>
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -4,6 +4,12 @@ import { Exercise } from "utils/types"
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"))
defineProps<{ exercise: Exercise; lang?: string }>()
</script>
@@ -20,4 +26,16 @@ defineProps<{ exercise: Exercise; lang?: string }>()
:exercise="exercise"
:lang="lang"
/>
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" />
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" />
</template>

View File

@@ -0,0 +1,50 @@
/* 练一练代码高亮配色(明 / 暗),由涉及代码高亮的题型组件统一引入 */
.hljs-keyword,
.hljs-operator,
.hljs-selector-tag {
color: #d73a49;
}
.hljs-string,
.hljs-regexp,
.hljs-template-literal {
color: #032f62;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-number,
.hljs-literal {
color: #005cc5;
}
.hljs-built_in,
.hljs-title.function_,
.hljs-class .hljs-title {
color: #6f42c1;
}
.dark .hljs-keyword,
.dark .hljs-operator,
.dark .hljs-selector-tag {
color: #c678dd;
}
.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-template-literal {
color: #98c379;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #7f848e;
font-style: italic;
}
.dark .hljs-number,
.dark .hljs-literal {
color: #e5c07b;
}
.dark .hljs-built_in,
.dark .hljs-title.function_,
.dark .hljs-class .hljs-title {
color: #61afef;
}

View File

@@ -0,0 +1,41 @@
import hljs from "highlight.js/lib/core"
import python from "highlight.js/lib/languages/python"
import c from "highlight.js/lib/languages/c"
hljs.registerLanguage("python", python)
hljs.registerLanguage("c", c)
export function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
function normalizeLang(lang?: string): "python" | "c" | null {
return lang === "python" ? "python" : lang === "c" ? "c" : null
}
// 把整段代码高亮为 HTML不支持的语言或异常时回退为转义文本
export function highlight(code: string, lang?: string): string {
const language = normalizeLang(lang)
if (language) {
try {
return hljs.highlight(code, { language }).value
} catch {
// fall through
}
}
return escapeHtml(code)
}
// 按行高亮:整体高亮后再按行切分,保证跨行 token 着色正确,返回逐行 HTML 数组
export function highlightLines(lines: string[], lang?: string): string[] {
const language = normalizeLang(lang)
if (language) {
try {
const html = hljs.highlight(lines.join("\n"), { language }).value
return html.split("\n")
} catch {
// fall through
}
}
return lines.map((line) => escapeHtml(line))
}

View File

@@ -627,10 +627,53 @@ export interface ExerciseFillData {
code: string
}
export interface ExerciseMatchData {
question: string
left: string[]
right: string[]
answer: number[]
}
export interface ExercisePredictData {
question: string
code: string
answer: string[]
}
export interface ExerciseDebugData {
question: string
lines: string[]
answer: number[]
explanation?: string
}
export interface ExerciseGroupData {
question: string
buckets: string[]
items: string[]
answer: number[]
}
export type ExerciseType =
| "mcq"
| "sort"
| "fill"
| "match"
| "predict"
| "debug"
| "group"
export interface Exercise {
id: number
type: "mcq" | "sort" | "fill"
data: ExerciseMcqData | ExerciseSortData | ExerciseFillData
type: ExerciseType
data:
| ExerciseMcqData
| ExerciseSortData
| ExerciseFillData
| ExerciseMatchData
| ExercisePredictData
| ExerciseDebugData
| ExerciseGroupData
order: number
}