feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
43
apps/web/src/admin/tutorial/components/Actions.vue
Normal file
43
apps/web/src/admin/tutorial/components/Actions.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import { deleteTutorial } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
tutorialID: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["deleted"])
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
name: "admin tutorial edit",
|
||||
params: { tutorialID: props.tutorialID },
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteTutorial(props.tutorialID)
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="success" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="handleDelete">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" secondary>删除</n-button>
|
||||
</template>
|
||||
确定删除这个教程吗?
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
652
apps/web/src/admin/tutorial/components/ExerciseManager.vue
Normal file
652
apps/web/src/admin/tutorial/components/ExerciseManager.vue
Normal file
@@ -0,0 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Exercise,
|
||||
ExerciseType,
|
||||
ExerciseMcqData,
|
||||
ExerciseSortData,
|
||||
ExerciseFillData,
|
||||
ExerciseMatchData,
|
||||
ExercisePredictData,
|
||||
ExerciseDebugData,
|
||||
ExerciseGroupData,
|
||||
} 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<ExerciseType>("mcq")
|
||||
const formOrder = ref(0)
|
||||
|
||||
const mcqQuestion = ref("")
|
||||
const mcqOptions = ref(["", ""])
|
||||
const mcqAnswer = ref<number[]>([])
|
||||
|
||||
const sortQuestion = ref("")
|
||||
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 resetForms() {
|
||||
mcqQuestion.value = ""
|
||||
mcqOptions.value = ["", ""]
|
||||
mcqAnswer.value = []
|
||||
sortQuestion.value = ""
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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 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
|
||||
}
|
||||
|
||||
function toggleAnswer(i: number) {
|
||||
const idx = mcqAnswer.value.indexOf(i)
|
||||
if (idx === -1) mcqAnswer.value.push(i)
|
||||
else mcqAnswer.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function toggleDebug(i: number) {
|
||||
const idx = debugAnswer.value.indexOf(i)
|
||||
if (idx === -1) debugAnswer.value.push(i)
|
||||
else debugAnswer.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
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") {
|
||||
if (mcqAnswer.value.length === 0) {
|
||||
message.error("请至少勾选一个正确答案")
|
||||
return null
|
||||
}
|
||||
return {
|
||||
question: mcqQuestion.value || "下面选项中正确是哪个?",
|
||||
options: mcqOptions.value,
|
||||
answer: mcqAnswer.value,
|
||||
}
|
||||
}
|
||||
if (formType.value === "sort") {
|
||||
return {
|
||||
question: sortQuestion.value || "将下列代码行排列为正确顺序",
|
||||
lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""),
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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}]]`)
|
||||
}
|
||||
|
||||
const TYPE_NAMES: Record<ExerciseType, string> = {
|
||||
mcq: "选择题",
|
||||
sort: "代码排序",
|
||||
fill: "代码填空",
|
||||
match: "连线匹配",
|
||||
predict: "输出预测",
|
||||
debug: "代码找错",
|
||||
group: "归类分组",
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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"
|
||||
placeholder="下面选项中正确是哪个?"
|
||||
/>
|
||||
</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-checkbox
|
||||
:checked="mcqAnswer.includes(i)"
|
||||
@update:checked="toggleAnswer(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)
|
||||
mcqAnswer = mcqAnswer
|
||||
.filter((a) => a !== i)
|
||||
.map((a) => (a > i ? a - 1 : a))
|
||||
}
|
||||
"
|
||||
>
|
||||
✕
|
||||
</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: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="formType === 'fill'">
|
||||
<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: "Monaco""
|
||||
/>
|
||||
</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 len type"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="右列(与左列按行一一对应,保存后右列自动乱序)">
|
||||
<n-input
|
||||
v-model:value="matchRight"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="输出内容 返回长度 返回类型"
|
||||
/>
|
||||
</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: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="正确输出(多个可接受答案之间用单独一行 === 分隔)"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="predictAnswer"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="3"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</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: "Monaco""
|
||||
/>
|
||||
</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 float str"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="项目(每行「项目 => 分组名」)">
|
||||
<n-input
|
||||
v-model:value="groupItems"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="3 => int 3.14 => float hello => str"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</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>
|
||||
133
apps/web/src/admin/tutorial/detail.vue
Normal file
133
apps/web/src/admin/tutorial/detail.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||
import type { Tutorial } from "utils/types"
|
||||
import { createTutorial, getTutorial, updateTutorial } from "../api"
|
||||
import ExerciseManager from "./components/ExerciseManager.vue"
|
||||
|
||||
interface Props {
|
||||
tutorialID?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const tutorial = reactive<Tutorial>({
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
code: "",
|
||||
is_public: false,
|
||||
order: 0,
|
||||
type: "python", // 默认选择 Python
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "Python", value: "python" },
|
||||
{ label: "C 语言", value: "c" },
|
||||
]
|
||||
|
||||
async function init() {
|
||||
if (!props.tutorialID) {
|
||||
return
|
||||
}
|
||||
const id = parseInt(route.params.tutorialID as string)
|
||||
const data = await getTutorial(id)
|
||||
tutorial.id = data.id
|
||||
tutorial.title = data.title
|
||||
tutorial.content = data.content
|
||||
tutorial.code = data.code || ""
|
||||
tutorial.is_public = data.is_public
|
||||
tutorial.order = data.order
|
||||
tutorial.type = data.type || "python"
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!tutorial.title || !tutorial.content) {
|
||||
message.error("标题和正文必填")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (route.name === "admin tutorial create") {
|
||||
await createTutorial({
|
||||
title: tutorial.title,
|
||||
content: tutorial.content,
|
||||
code: tutorial.code,
|
||||
is_public: tutorial.is_public,
|
||||
order: tutorial.order,
|
||||
type: tutorial.type,
|
||||
})
|
||||
message.success("成功新建教程 💐")
|
||||
} else {
|
||||
await updateTutorial(tutorial)
|
||||
message.success("修改已保存")
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
<template>
|
||||
<h2 class="title">
|
||||
{{ route.name === "admin tutorial create" ? "新建教程" : "编辑教程" }}
|
||||
</h2>
|
||||
<n-form inline>
|
||||
<n-form-item label="标题">
|
||||
<n-input class="contestTitle" v-model:value="tutorial.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="语言">
|
||||
<n-select
|
||||
v-model:value="tutorial.type"
|
||||
:options="typeOptions"
|
||||
class="select"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
style="width: 100px"
|
||||
v-model:value="tutorial.order"
|
||||
:min="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="tutorial.is_public" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="content" tab="教程内容">
|
||||
<MarkdownEditor v-model:value="tutorial.content" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="code" tab="示例代码">
|
||||
<CodeEditor
|
||||
v-model:value="tutorial.code"
|
||||
:language="tutorial.type === 'python' ? 'Python3' : 'C'"
|
||||
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>
|
||||
.title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.contestTitle {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
107
apps/web/src/admin/tutorial/list.vue
Normal file
107
apps/web/src/admin/tutorial/list.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Tutorial } from "utils/types"
|
||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
const tutorials = ref<{ [key: string]: Tutorial[] }>({
|
||||
python: [],
|
||||
c: [],
|
||||
})
|
||||
const message = useMessage()
|
||||
const activeTab = ref("python")
|
||||
|
||||
const columns: DataTableColumn<Tutorial>[] = [
|
||||
{
|
||||
title: "顺序",
|
||||
key: "order",
|
||||
width: 80,
|
||||
},
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: "作者",
|
||||
key: "created_by",
|
||||
render: (row) => row.created_by?.username,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.created_at!, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
key: "updated_at",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.updated_at!, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "is_public",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.is_public,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
render: (row) =>
|
||||
h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
|
||||
},
|
||||
]
|
||||
|
||||
async function toggleVisible(tutorial: Tutorial) {
|
||||
tutorial.is_public = !tutorial.is_public
|
||||
try {
|
||||
await setTutorialVisibility(tutorial.id, tutorial.is_public)
|
||||
message.success("更新成功")
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
tutorial.is_public = !tutorial.is_public
|
||||
}
|
||||
}
|
||||
|
||||
async function listTutorials() {
|
||||
tutorials.value = await getTutorialList()
|
||||
}
|
||||
|
||||
onMounted(listTutorials)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex align="center" class="titleWrapper">
|
||||
<h2 class="title">教程列表</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin tutorial create' })"
|
||||
>
|
||||
新建
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="python" tab="Python">
|
||||
<n-data-table striped :columns="columns" :data="tutorials.python" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="c" tab="C 语言">
|
||||
<n-data-table striped :columns="columns" :data="tutorials.c" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user