feat(course): 添加自定义考试功能并优化试卷组件

- 新增自定义考试试卷类型支持
- 实现试卷预览和编辑功能
- 添加试题管理组件,支持单选、多选、判断题
- 集成雪花ID生成器用于试题唯一标识
- 优化课程创建流程,支持考试内容配置
- 扩展SCSS样式库,增加flex布局和间距工具类
- 新增课程API模块,完善考试相关接口
- 实现试卷内容动态加载和保存逻辑
This commit is contained in:
陈昱达
2025-11-25 14:45:44 +08:00
parent f07582d5c1
commit 6c87968ab4
8 changed files with 1240 additions and 14 deletions

View File

@@ -118,18 +118,41 @@ const columns = [
];
// 事件发射
const emit = defineEmits(["chooseItem", "choosePreviewItem"]);
const emit = defineEmits(["chooseItem", "choosePreviewItem", "chooseCusExam"]);
// 处理选择项目
const handleChooseItem = (row) => {
emit("chooseItem", {
switch (props.resType) {
case 61:
console.log(row);
if (row.counts === 0) {
ElMessage.error("此试卷无试题内容,请重新选择");
return;
}
break;
default:
// emit("chooseItem", {
// ...row,
// isDrag: false,
// completeSetup: 0,
// setupTage: "",
// resType: props.resType,
// dir: props.resType === 50 ? "scorm" : "course",
// });
break;
}
let obj = {
...row,
isDrag: false,
completeSetup: 0,
setupTage: "",
resType: props.resType,
dir: props.resType === 50 ? "scorm" : "course",
});
};
if (props.resType === 61) {
obj.paperType = 2;
}
emit("chooseItem", obj);
};
const handlePreviewItem = (row) => {
@@ -267,6 +290,14 @@ const handleBeforeUpload = (file) => {
return true;
};
const openCusExam = () => {
emit("chooseCusExam", {
resType: props.resType,
dir: props.resType === 50 ? "scorm" : "course",
paperType: 1,
name: "自定义考试",
});
};
// 生命周期
onMounted(() => {
@@ -292,7 +323,10 @@ onMounted(() => {
>上传新{{ getType(props.resType) }}</el-button
>
</el-upload>
<el-button v-if="[61].includes(props.resType)" type="primary"
<el-button
v-if="[61].includes(props.resType)"
type="primary"
@click="openCusExam"
>自定义考试</el-button
>
<span class="desc ml10" v-if="![61].includes(props.resType)"

View File

@@ -0,0 +1,417 @@
<template>
<div class="simple-paper">
<!-- 顶部操作栏 -->
<div class="toolbar">
<div class="toolbar-buttons">
<el-button-group>
<el-button
type="primary"
@click="addQuestion(101)"
:icon="Plus"
size="small"
:disabled="disabled"
>单选</el-button
>
<el-button
type="primary"
@click="addQuestion(102)"
:icon="Plus"
size="small"
:disabled="disabled"
>多选</el-button
>
<el-button
type="primary"
@click="addQuestion(103)"
:icon="Plus"
size="small"
:disabled="disabled"
>判断</el-button
>
</el-button-group>
</div>
<div class="toolbar-tip">点题干编辑</div>
<div class="toolbar-info">
<el-checkbox v-model="optShow" :disabled="disabled"
>显示选项</el-checkbox
>
<span class="toolbar-stats">
<span class="bigred"> {{ data.items.length }} </span>
<span class="bigred">{{ total }}</span>
</span>
</div>
</div>
<!-- 试卷内容 -->
<div class="paper-container">
<div
v-for="(item, idx) in data.items"
:key="idx"
class="paper-item"
@mouseenter="showOptions(item)"
@mouseleave="hideOptions(item)"
>
<div class="paper-item-content">
<div class="paper-item-main">
<!-- 显示模式 -->
<div
v-if="editIndex !== idx"
class="paper-item-question"
@click="handleEditItem(item, idx)"
>
{{ idx + 1 }}.
<span v-if="item.type === 101">单选题</span>
<span v-if="item.type === 102">多选题</span>
<span v-if="item.type === 103">判断题</span>
{{ item.content }}
</div>
<!-- 选项显示悬停或全局显示 -->
<div v-if="optShow || item.optShow" class="paper-options">
<div
v-for="(opt, optIdx) in item.options"
:key="optIdx"
class="paper-option"
:class="{ 'paper-option-selected': opt.answer }"
@click="setOptAnswer(item, opt)"
>
{{ String.fromCharCode(65 + optIdx) }}. {{ opt.content }}
<i v-if="opt.answer" class="el-icon-check option-checkmark"></i>
</div>
</div>
<!-- 编辑模式 -->
<div v-if="editIndex === idx" class="paper-item-editor">
<el-input v-model="item.content" placeholder="试题的内容" />
<div class="editor-tip">一行一个选项</div>
<el-input
v-model="curTextOptions"
type="textarea"
:rows="5"
placeholder="每行一个选项内容"
class="editor-textarea"
/>
<el-button
@click="handleSaveItem(item)"
type="warning"
size="small"
class="editor-save-btn"
>
编辑完成
</el-button>
</div>
</div>
<!-- 分数与删除 -->
<div class="paper-item-actions">
<el-input
:disabled="disabled"
v-model="item.score"
class="score-input"
size="small"
placeholder="分数"
/>
<el-button
:disabled="disabled"
@click="removeQuestion(idx)"
type="danger"
size="small"
:icon="Delete"
class="remove-btn"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { Plus, Delete } from "@element-plus/icons-vue";
import { snowflakeGenerator } from "snowflake-id-js";
import { ElButtonGroup, ElButton, ElInput, ElCheckbox } from "element-plus";
// Props
const props = defineProps({
data: {
type: Object,
default: () => ({
items: [],
}),
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:data"]);
// 监听 data 变化并触发 update 事件
watch(
() => props.data,
(newVal) => {
emit("update:data", newVal);
},
{ deep: true }
);
// 响应式状态
const editIndex = ref(-1);
const curTextOptions = ref("");
const optShow = ref(true);
const generator = ref(null);
// 默认测试题
const qdata = [
{
id: "1",
type: 101,
score: 5,
checked: false,
optShow: false,
content: "点击编辑试题内容",
options: [
{ id: "11", content: "选项", answer: false },
{ id: "12", content: "选项", answer: true },
{ id: "13", content: "选项", answer: false },
{ id: "14", content: "选项", answer: false },
],
},
];
// 初始化
onMounted(() => {
const seed = Math.floor(Math.random() * 50) + 1;
generator.value = snowflakeGenerator(seed);
initItem();
});
// 总分计算
const total = computed(() => {
return props.data.items.reduce((sum, item) => {
return sum + (parseFloat(item.score) || 0);
}, 0);
});
// 初始化测试数据(仅当 items 为空)
function initItem() {
if (props.data.items.length === 0) {
qdata.forEach((item) => {
props.data.items.push({ ...item });
});
}
}
// 显示/隐藏选项
function showOptions(item) {
item.optShow = true;
}
function hideOptions(item) {
item.optShow = false;
}
// 设置答案(单选/多选逻辑)
function setOptAnswer(item, opt) {
if (props.disabled) return;
if (item.type !== 102) {
// 单选或判断:清空其他选项
item.options.forEach((o) => (o.answer = false));
}
// 切换当前选项
opt.answer = !opt.answer;
}
// 进入编辑
function handleEditItem(item, idx) {
if (props.disabled) return;
editIndex.value = idx;
// 拼接选项为文本(每行一个)
const text = item.options.map((opt) => opt.content).join("\n");
curTextOptions.value = text;
}
// 保存编辑
function handleSaveItem(item) {
if (props.disabled) return;
const lines = curTextOptions.value
.trim()
.split("\n")
.filter((line) => line.trim() !== "");
if (lines.length === 0) return;
// 保存当前选中的索引
const oldAnswers = item.options
.map((opt, i) => (opt.answer ? i : -1))
.filter((i) => i !== -1);
// 重建 options
const newOptions = lines.map((content, i) => {
const id = generator.value.next().value;
const isAnswer = oldAnswers.includes(i);
return { id, content, answer: isAnswer };
});
item.options = newOptions;
editIndex.value = -1;
}
// 添加题目
function addQuestion(type) {
const id = generator.value.next().value;
let options = [];
if (type === 101 || type === 102) {
// 单选/多选3个默认选项
options = Array.from({ length: 3 }, (_, i) => ({
id: generator.value.next().value,
content: "选项",
answer: false,
}));
} else if (type === 103) {
// 判断题
options = [
{ id: generator.value.next().value, content: "正确", answer: false },
{ id: generator.value.next().value, content: "错误", answer: false },
];
}
const newItem = {
id,
type,
score: 5,
optShow: false,
content: "点击编辑试题内容",
options,
};
props.data.items.push(newItem);
}
// 删除题目
function removeQuestion(index) {
props.data.items.splice(index, 1);
}
</script>
<style lang="scss" scoped>
.simple-paper {
.toolbar {
display: flex;
justify-content: space-between;
padding: 5px;
align-items: center;
.toolbar-buttons {
flex: 1;
}
.toolbar-tip {
flex: 1;
text-align: center;
padding-top: 10px;
color: #919191;
}
.toolbar-info {
flex: 1;
line-height: 40px;
text-align: right;
font-size: 16px;
.toolbar-stats {
margin-left: 8px;
}
}
}
.paper-container {
border: 1px solid #dfdfdf;
padding: 5px 0;
//height: 500px;
//overflow: auto;
.paper-item {
padding: 5px 10px;
border-bottom: 1px solid #cccccc;
.paper-item-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 5px;
.paper-item-main {
flex: 1;
padding-right: 5px;
.paper-item-question {
cursor: pointer;
margin-bottom: 5px;
}
.paper-options {
padding: 5px 0;
.paper-option {
line-height: 25px;
cursor: pointer;
padding: 2px 5px;
&:hover {
background-color: #f5f5f5;
}
}
.paper-option-selected {
color: green;
font-weight: bold;
}
.option-checkmark {
float: right;
color: green;
}
}
.paper-item-editor {
.editor-tip {
color: red;
margin-top: 4px;
}
.editor-textarea {
margin-top: 4px;
}
.editor-save-btn {
margin-top: 8px;
}
}
}
.paper-item-actions {
width: 110px;
display: flex;
align-items: center;
.score-input {
width: 50px;
}
.remove-btn {
margin-left: 6px;
}
}
}
}
}
}
.bigred {
color: red;
font-size: 20px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,214 @@
<script setup>
import {
ElForm,
ElFormItem,
ElInput,
ElRadioGroup,
ElRadio,
ElCheckbox,
ElInputNumber,
} from "element-plus";
import { onMounted, ref } from "vue";
import apiCourse from "@/api/modules/course";
import apiExamPaper from "@/api/modules/paper";
import SimplePaper from "./ChildrenComponent/SimplePaper.vue";
defineOptions({
resType: 61,
});
const props = defineProps({
dialogVideoForm: {
type: Object,
default: () => ({
name: "",
filePath: "",
isDrag: true,
completeSetup: 0,
setupTage: 0,
openType: "",
}),
},
isPreview: {
type: Boolean,
default: false,
},
classId: {
type: String,
default: "",
},
});
import { useMediaComponent } from "@/hooks/useMediaComponent";
// Emit updates to parent component
const emit = defineEmits(["update:dialogVideoForm"]);
// 使用hook处理公共逻辑
const { localDialogVideoForm, updateFormValue, fileBaseUrl } =
useMediaComponent(props, emit);
const loadExamFile = () => {
// 查询课程详情,在查询课程考试内
// console.log(props, "props");
// apiCourse.getExam(props.classId).then((rs) => {
// console.log(rs);
// });
// apiExamPaper.detail(localDialogVideoForm.value.id).then((res) => {
// if (res.status === 200) {
// console.log(res);
// localDialogVideoForm.value.counts = res.result.counts;
// localDialogVideoForm.value.name = res.result.testName;
// localDialogVideoForm.value.paperId = res.result.id;
// }
// });
};
onMounted(() => {
console.log(localDialogVideoForm.value, 123);
if (localDialogVideoForm.value.paperType === 1) {
if (!localDialogVideoForm.value.id) {
localDialogVideoForm.value = Object.assign(localDialogVideoForm.value, {
testDuration: localDialogVideoForm.value.testDuration || 30,
passLine: localDialogVideoForm.value.passLine || 60,
scoringType: localDialogVideoForm.value.scoringType || 1,
percentScore: localDialogVideoForm.value.percentScore || false,
randomMode: localDialogVideoForm.value.randomMode || false,
qnum: localDialogVideoForm.value.qnum || 1,
counts: localDialogVideoForm.value.counts || 1,
paperJson: localDialogVideoForm.value.paperJson || {
items: [],
},
});
}
} else {
// 选择的试卷 还没做
loadExamFile();
}
});
</script>
<template>
<el-form label-position="right" label-width="100px">
<el-form-item label="考试名称">
<el-input
v-model="localDialogVideoForm.name"
:disabled="isPreview"
@update:modelValue="(val) => updateFormValue('name', val)"
></el-input>
</el-form-item>
<el-form-item label="考试时长">
<el-input
v-model="localDialogVideoForm.testDuration"
:disabled="isPreview"
@update:modelValue="(val) => updateFormValue('testDuration', val)"
>
<template #append>分钟</template>
</el-input>
</el-form-item>
<el-form-item label="及格线">
<el-input
v-model="localDialogVideoForm.passLine"
:disabled="isPreview"
@update:modelValue="(val) => updateFormValue('passLine', val)"
></el-input>
</el-form-item>
<el-form-item label="打开方式">
<el-radio-group
v-model="localDialogVideoForm.scoringType"
@update:modelValue="(val) => updateFormValue('scoringType', val)"
:disabled="isPreview"
>
<el-radio :value="1" :label="1">最高一次</el-radio>
<el-radio :value="2" :label="2">最后一次</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="百分制">
<el-checkbox
:label="true"
:value="true"
v-model="localDialogVideoForm.percentScore"
:disabled="isPreview"
@update:modelValue="(val) => updateFormValue('percentScore', val)"
>实际成绩*100/实际总分</el-checkbox
>
</el-form-item>
<el-form-item label="考试说明">
<el-input
v-model="localDialogVideoForm.info"
type="textarea"
:disabled="isPreview"
@update:modelValue="(val) => updateFormValue('info', val)"
></el-input>
</el-form-item>
<el-form-item label="随机模式">
<el-radio-group
:disabled="isPreview"
v-model="localDialogVideoForm.randomMode"
@update:modelValue="(val) => updateFormValue('randomMode', val)"
>
<el-radio :label="true"></el-radio>
<el-radio :label="false"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="数量"
v-if="
localDialogVideoForm.randomMode && localDialogVideoForm.paperType === 1
"
>
<div class="flex align-center">
<el-input-number
:disabled="isPreview"
v-model="localDialogVideoForm.qnum"
@update:modelValue="(val) => updateFormValue('qnum', val)"
:min="1"
:max="
localDialogVideoForm.paperType === 1
? localDialogVideoForm.paperJson.items.length
: localDialogVideoForm.counts
"
>
</el-input-number>
<span
style="margin-left: 10px"
v-if="
localDialogVideoForm.paperType === 1
? localDialogVideoForm.paperJson.items.length <= 0
: localDialogVideoForm.counts <= 0
"
>先{{
localDialogVideoForm.paperType === 1 ? "添加试题" : "选择试卷"
}}</span
>
<span
style="margin-left: 10px"
v-if="
localDialogVideoForm.paperType === 1
? localDialogVideoForm.paperJson.items.length > 0
: localDialogVideoForm.counts > 0
"
>试卷有
{{
localDialogVideoForm.paperType === 1
? localDialogVideoForm.paperJson.items.length
: localDialogVideoForm.counts
}}
道试题</span
>
</div>
</el-form-item>
<div class="parer-comp" v-if="localDialogVideoForm.paperType === 1">
<SimplePaper
v-model:data="localDialogVideoForm.paperJson"
:disabled="isPreview"
@update:data="(val) => updateFormValue('paperJson', val)"
></SimplePaper>
</div>
<!-- <div v-if="localDialogVideoForm.paperType === 2">-->
<!-- <div class="flex align-center">-->
<!-- <span style="padding-right: 12px; width: 100px; text-align: right"-->
<!-- >试卷</span-->
<!-- >-->
<!-- {{ localDialogVideoForm.name }}-->
<!-- </div>-->
<!-- </div>-->
</el-form>
</template>
<style scoped lang="scss"></style>