feat: add interactive MCQ and code-sort exercise widgets to tutorial lessons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 01:52:20 -06:00
parent 12cf247e20
commit 6331391792
10 changed files with 568 additions and 53 deletions

View File

@@ -6,6 +6,7 @@ import {
BlankContest,
BlankProblem,
Contest,
Exercise,
Server,
TestcaseUploadedReturns,
Tutorial,
@@ -261,6 +262,35 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
return http.put("admin/tutorial/visibility", { id, is_public })
}
export async function getAdminExercises(tutorialId: number) {
const res = await http.get("admin/exercise", { params: { tutorial_id: tutorialId } })
return res.data as Exercise[]
}
export async function createExercise(data: {
tutorial_id: number
type: "mcq" | "sort"
data: object
order: number
}) {
const res = await http.post("admin/exercise", data)
return res.data as Exercise
}
export async function updateExercise(data: {
id: number
type: "mcq" | "sort"
data: object
order: number
}) {
const res = await http.put("admin/exercise", data)
return res.data as Exercise
}
export function deleteExercise(id: number) {
return http.delete("admin/exercise", { params: { id } })
}
// 将竞赛题目转为公开题目
export function makeProblemPublic(id: number, display_id: string) {
return http.post("admin/contest_problem/make_public", {

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import { Exercise, ExerciseMcqData, ExerciseSortData } from "utils/types"
import {
getAdminExercises,
createExercise,
updateExercise,
deleteExercise,
} from "admin/api"
const props = defineProps<{ tutorialId: number }>()
const message = useMessage()
const dialog = useDialog()
const exercises = ref<Exercise[]>([])
const showForm = ref(false)
const editingId = ref<number | null>(null)
const formType = ref<"mcq" | "sort">("mcq")
const formOrder = ref(0)
const mcqQuestion = ref("")
const mcqOptions = ref(["", ""])
const mcqAnswer = ref(0)
const sortQuestion = ref("")
const sortLines = ref(["", ""])
async function load() {
exercises.value = await getAdminExercises(props.tutorialId)
}
onMounted(load)
function openCreate() {
editingId.value = null
formType.value = "mcq"
formOrder.value = exercises.value.length
mcqQuestion.value = ""
mcqOptions.value = ["", ""]
mcqAnswer.value = 0
sortQuestion.value = ""
sortLines.value = ["", ""]
showForm.value = true
}
function openEdit(ex: Exercise) {
editingId.value = ex.id
formType.value = ex.type
formOrder.value = ex.order
if (ex.type === "mcq") {
const d = ex.data as ExerciseMcqData
mcqQuestion.value = d.question
mcqOptions.value = [...d.options]
mcqAnswer.value = d.answer
} else {
const d = ex.data as ExerciseSortData
sortQuestion.value = d.question
sortLines.value = [...d.lines]
}
showForm.value = true
}
async function save() {
const data =
formType.value === "mcq"
? { question: mcqQuestion.value, options: mcqOptions.value, answer: mcqAnswer.value }
: { question: sortQuestion.value, lines: sortLines.value }
try {
if (editingId.value) {
await updateExercise({ id: editingId.value, type: formType.value, data, order: formOrder.value })
message.success("练习题已更新")
} else {
await createExercise({ tutorial_id: props.tutorialId, type: formType.value, data, order: formOrder.value })
message.success("练习题已创建")
}
showForm.value = false
await load()
} catch (e: any) {
message.error(e.data ?? "保存失败")
}
}
function confirmDelete(id: number) {
dialog.warning({
title: "删除练习题",
content: "此操作不可撤销",
positiveText: "删除",
onPositiveClick: async () => {
await deleteExercise(id)
message.success("已删除")
await load()
},
})
}
function copyPlaceholder(id: number) {
navigator.clipboard.writeText(`[[exercise:${id}]]`)
message.success(`已复制 [[exercise:${id}]]`)
}
function typeName(type: string) {
return type === "mcq" ? "选择题" : "代码排序"
}
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<n-text> {{ exercises.length }} 道练习题</n-text>
<n-button type="primary" size="small" @click="openCreate">+ 添加练习题</n-button>
</n-flex>
<n-empty v-if="exercises.length === 0" description="暂无练习题" />
<n-list v-else bordered>
<n-list-item v-for="ex in exercises" :key="ex.id">
<n-flex justify="space-between" align="center">
<div>
<n-tag
size="small"
:type="ex.type === 'mcq' ? 'success' : 'info'"
:bordered="false"
>
{{ typeName(ex.type) }}
</n-tag>
<n-text style="margin-left: 10px">
{{ (ex.data as any).question }}
</n-text>
</div>
<n-space :size="8">
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" @click="copyPlaceholder(ex.id)">复制占位符</n-button>
</template>
[[exercise:{{ ex.id }}]] 粘贴到 Markdown 内容中
</n-tooltip>
<n-button size="small" @click="openEdit(ex)">编辑</n-button>
<n-button size="small" type="error" @click="confirmDelete(ex.id)">删除</n-button>
</n-space>
</n-flex>
</n-list-item>
</n-list>
<n-modal
v-model:show="showForm"
:title="editingId ? '编辑练习题' : '新建练习题'"
preset="card"
style="width: 560px"
>
<n-form label-placement="top">
<n-form-item label="题型">
<n-radio-group v-model:value="formType" :disabled="!!editingId">
<n-radio value="mcq">选择题</n-radio>
<n-radio value="sort">代码排序</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="顺序">
<n-input-number v-model:value="formOrder" :min="0" style="width: 100px" />
</n-form-item>
<template v-if="formType === 'mcq'">
<n-form-item label="题目">
<n-input v-model:value="mcqQuestion" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="选项(正确答案前选择单选按钮)">
<n-space vertical style="width: 100%">
<n-flex v-for="(opt, i) in mcqOptions" :key="i" align="center" :size="8">
<n-radio
:value="i"
:checked="mcqAnswer === i"
@update:checked="if ($event) mcqAnswer = i"
/>
<n-input
v-model:value="mcqOptions[i]"
:placeholder="`选项 ${String.fromCharCode(65 + i)}`"
style="flex: 1"
/>
<n-button
size="small"
:disabled="mcqOptions.length <= 2"
@click="mcqOptions.splice(i, 1)"
>
</n-button>
</n-flex>
<n-button size="small" @click="mcqOptions.push('')">+ 添加选项</n-button>
</n-space>
</n-form-item>
</template>
<template v-else>
<n-form-item label="题目">
<n-input v-model:value="sortQuestion" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="代码行(按正确顺序输入)">
<n-space vertical style="width: 100%">
<n-flex v-for="(line, i) in sortLines" :key="i" align="center" :size="8">
<n-input
v-model:value="sortLines[i]"
:placeholder="`第 ${i + 1} 行`"
style="flex: 1; font-family: monospace"
/>
<n-button
size="small"
:disabled="sortLines.length <= 2"
@click="sortLines.splice(i, 1)"
>
</n-button>
</n-flex>
<n-button size="small" @click="sortLines.push('')">+ 添加行</n-button>
</n-space>
</n-form-item>
</template>
</n-form>
<template #footer>
<n-flex justify="end" :size="8">
<n-button @click="showForm = false">取消</n-button>
<n-button type="primary" @click="save">保存</n-button>
</n-flex>
</template>
</n-modal>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import CodeEditor from "shared/components/CodeEditor.vue"
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
import { Tutorial } from "utils/types"
import { createTutorial, getTutorial, updateTutorial } from "../api"
import ExerciseManager from "./components/ExerciseManager.vue"
interface Props {
tutorialID?: string
@@ -112,6 +113,10 @@ onMounted(init)
height="400px"
/>
</n-tab-pane>
<n-tab-pane name="exercises" tab="练习题" :disabled="!tutorial.id">
<ExerciseManager v-if="tutorial.id" :tutorial-id="tutorial.id" />
<n-empty v-else description="请先保存教程后再添加练习题" />
</n-tab-pane>
</n-tabs>
</template>
<style scoped>