305 lines
8.7 KiB
Vue
305 lines
8.7 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
Exercise,
|
||
ExerciseMcqData,
|
||
ExerciseSortData,
|
||
ExerciseFillData,
|
||
} 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" | "fill">("mcq")
|
||
const formOrder = ref(0)
|
||
|
||
const mcqQuestion = ref("")
|
||
const mcqOptions = ref(["", ""])
|
||
const mcqAnswer = ref(0)
|
||
|
||
const sortQuestion = ref("")
|
||
const sortCode = ref("")
|
||
|
||
const fillQuestion = ref("")
|
||
const fillCode = 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 = ""
|
||
sortCode.value = ""
|
||
fillQuestion.value = ""
|
||
fillCode.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 if (ex.type === "sort") {
|
||
const d = ex.data as ExerciseSortData
|
||
sortQuestion.value = d.question
|
||
sortCode.value = d.lines.join("\n")
|
||
} else {
|
||
const d = ex.data as ExerciseFillData
|
||
fillQuestion.value = d.question
|
||
fillCode.value = d.code
|
||
}
|
||
showForm.value = true
|
||
}
|
||
|
||
async function save() {
|
||
let data: Record<string, unknown>
|
||
if (formType.value === "mcq") {
|
||
data = {
|
||
question: mcqQuestion.value,
|
||
options: mcqOptions.value,
|
||
answer: mcqAnswer.value,
|
||
}
|
||
} else if (formType.value === "sort") {
|
||
data = {
|
||
question: sortQuestion.value || "将下列代码行排列为正确顺序",
|
||
lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""),
|
||
}
|
||
} else {
|
||
data = { question: fillQuestion.value, code: fillCode.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) {
|
||
if (type === "mcq") return "选择题"
|
||
if (type === "sort") return "代码排序"
|
||
return "代码填空"
|
||
}
|
||
|
||
function typeTagType(type: string): "success" | "info" | "warning" {
|
||
if (type === "mcq") return "success"
|
||
if (type === "sort") return "info"
|
||
return "warning"
|
||
}
|
||
</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="typeTagType(ex.type)" :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 value="fill">代码填空</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="$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)
|
||
if (mcqAnswer >= mcqOptions.length)
|
||
mcqAnswer = mcqOptions.length - 1
|
||
}
|
||
"
|
||
>
|
||
✕
|
||
</n-button>
|
||
</n-flex>
|
||
<n-button size="small" @click="mcqOptions.push('')"
|
||
>+ 添加选项</n-button
|
||
>
|
||
</n-space>
|
||
</n-form-item>
|
||
</template>
|
||
|
||
<template v-else-if="formType === 'sort'">
|
||
<n-form-item label="题目">
|
||
<n-input
|
||
v-model:value="sortQuestion"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="将下列代码行排列为正确顺序"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item label="正确代码(每行将自动成为一道排序项)">
|
||
<n-input
|
||
v-model:value="sortCode"
|
||
type="textarea"
|
||
:rows="10"
|
||
placeholder="在此粘贴正确的代码,保存后将自动按行拆分并乱序"
|
||
style="font-family: monospace"
|
||
/>
|
||
</n-form-item>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<n-form-item label="题目说明">
|
||
<n-input
|
||
v-model:value="fillQuestion"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="例:补全下面的循环语句"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item label="含空位的代码">
|
||
<n-input
|
||
v-model:value="fillCode"
|
||
type="textarea"
|
||
:rows="10"
|
||
placeholder="用 {{答案}} 标记空位,多个合法答案用 | 分隔,例如:for {{i|idx}} in range(10):"
|
||
style="font-family: monospace"
|
||
/>
|
||
</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>
|