You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/teacherEnd/exam/index.vue

152 lines
15 KiB
Vue

<template>
<main class="exam-admin app-container">
<header class="page-head">
<div><h1>综合考核</h1><p>维护题库并管理考试全流程</p></div>
<el-button v-if="activeTab === 'questions'" type="primary" :icon="Plus" @click="openQuestion()">新增题目</el-button>
<el-button v-else type="primary" :icon="Plus" @click="$router.push('/exam/manage/new')">新建考试</el-button>
</header>
<el-tabs v-model="activeTab" class="workspace-tabs">
<el-tab-pane label="题库管理" name="questions">
<section class="toolbar" aria-label="题库筛选">
<el-select v-model="questionQuery.type" placeholder="全部题型" clearable @change="loadQuestions">
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input v-model="questionQuery.keyword" placeholder="搜索题干" clearable :prefix-icon="Search" @keyup.enter="loadQuestions" />
<el-button :icon="Search" @click="loadQuestions">查询</el-button>
<div class="toolbar-spacer" />
<el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button>
<el-upload :show-file-list="false" accept=".docx" :http-request="doImport">
<el-button :icon="Upload">Word 批量导入</el-button>
</el-upload>
</section>
<el-table v-loading="loading" :data="questions" class="data-table">
<el-table-column label="题型" width="110"><template #default="{ row }"><el-tag :type="typeTone[row.type]">{{ typeName[row.type] }}</el-tag></template></el-table-column>
<el-table-column prop="stem" label="题干" min-width="420" show-overflow-tooltip />
<el-table-column label="正确答案" min-width="150"><template #default="{ row }">{{ displayAnswer(row.answer) }}</template></el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="180"><template #default="{ row }">{{ formatTime(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }">
<el-button link type="primary" @click="openQuestion(row)">编辑</el-button>
<el-button link type="danger" @click="removeQuestion(row)">删除</el-button>
</template></el-table-column>
</el-table>
<el-empty v-if="!loading && !questions.length" description="暂无题目" />
</el-tab-pane>
<el-tab-pane label="考评管理" name="exams">
<section class="toolbar" aria-label="考试筛选">
<el-input v-model="examKeyword" placeholder="搜索考试名称" clearable :prefix-icon="Search" @keyup.enter="loadExams" />
<el-button :icon="Search" @click="loadExams">查询</el-button>
</section>
<el-table v-loading="loading" :data="exams" class="data-table">
<el-table-column prop="name" label="考试名称" min-width="210" />
<el-table-column label="考试时间" min-width="320"><template #default="{ row }">{{ formatTime(row.startTime) }} 至 {{ formatTime(row.endTime) }}</template></el-table-column>
<el-table-column label="试卷" width="150"><template #default="{ row }">{{ row.questionCount || 0 }} 题 / {{ row.totalScore || 0 }} 分</template></el-table-column>
<el-table-column label="班级" width="90"><template #default="{ row }">{{ row.classCount || 0 }} 个</template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusMeta[row.status]?.tone">{{ statusMeta[row.status]?.label }}</el-tag></template></el-table-column>
<el-table-column label="操作" min-width="330" fixed="right"><template #default="{ row }">
<el-button v-if="['DRAFT','IN_PROGRESS'].includes(row.status)" link type="primary" @click="$router.push(`/exam/manage/${row.id}`)">编辑</el-button>
<el-button v-if="row.status === 'DRAFT'" link type="success" @click="publish(row)">发布</el-button>
<el-button v-if="row.status === 'NOT_STARTED'" link @click="withdraw(row)">撤回</el-button>
<el-button v-if="row.status === 'IN_PROGRESS'" link type="warning" @click="$router.push(`/exam/review/${row.id}`)">指定人回退</el-button>
<template v-if="row.status === 'ENDED'">
<el-button link type="primary" @click="$router.push(`/exam/review/${row.id}`)">评阅</el-button>
<el-button link type="primary" @click="$router.push(`/exam/analysis/${row.id}`)">分析</el-button>
<el-dropdown @command="(command) => handleExport(command, row)">
<el-button link type="primary">导出<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="paper">空白试卷</el-dropdown-item><el-dropdown-item command="scores">班级成绩</el-dropdown-item><el-dropdown-item command="submissions">全部答卷</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button link type="danger" @click="removeExam(row)">删除</el-button>
</template>
</template></el-table-column>
</el-table>
<el-empty v-if="!loading && !exams.length" description="暂无考试" />
</el-tab-pane>
</el-tabs>
<el-dialog v-model="questionDialog" :title="questionForm.id ? '编辑题目' : '新增题目'" width="720px" destroy-on-close>
<el-form ref="questionFormRef" :model="questionForm" :rules="questionRules" label-width="92px">
<el-form-item label="题型" prop="type"><el-radio-group v-model="questionForm.type" @change="resetAnswer"><el-radio-button v-for="item in typeOptions" :key="item.value" :label="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="题干" prop="stem"><el-input v-model="questionForm.stem" type="textarea" :rows="4" maxlength="2000" show-word-limit /></el-form-item>
<el-form-item v-if="isChoice" label="选项">
<div class="option-editor">
<div v-for="(option, index) in questionForm.options" :key="option.label" class="option-row">
<el-checkbox v-if="questionForm.type === 'MULTIPLE'" :model-value="questionForm.answer.includes(option.label)" @change="toggleMultiple(option.label, $event)" />
<el-radio v-else v-model="singleAnswer" :label="option.label"><span class="option-label">{{ option.label }}</span></el-radio>
<el-input v-model="option.content" :placeholder="`选项 ${option.label}`" />
<el-button v-if="questionForm.options.length > 2" text :icon="Delete" aria-label="删除选项" @click="deleteOption(index)" />
</div>
<el-button v-if="questionForm.options.length < 8" text type="primary" :icon="Plus" @click="addOption">添加选项</el-button>
</div>
</el-form-item>
<el-form-item v-else-if="questionForm.type === 'TRUE_FALSE'" label="正确答案"><el-radio-group v-model="questionForm.answer"><el-radio :label="true">正确</el-radio><el-radio :label="false">错误</el-radio></el-radio-group></el-form-item>
<el-form-item v-else label="参考答案" prop="answer"><el-input v-model="questionForm.answer" type="textarea" :rows="4" /></el-form-item>
<el-form-item v-if="questionForm.type === 'SHORT_ANSWER'" label="题目资料">
<el-upload :show-file-list="false" :http-request="uploadMaterial" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"><el-button :icon="Paperclip">上传资料</el-button></el-upload>
<span v-if="questionForm.attachmentName" class="file-name">{{ questionForm.attachmentName }}</span>
</el-form-item>
<el-form-item label="答案解析"><el-input v-model="questionForm.analysis" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer><el-button @click="questionDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveQuestion">保存</el-button></template>
</el-dialog>
</main>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown, Delete, Download, Paperclip, Plus, Search, Upload } from "@element-plus/icons-vue";
import { saveAs } from "file-saver";
import * as examApi from "@/api/exam";
const router = useRouter();
const activeTab = ref("questions");
const loading = ref(false); const saving = ref(false); const questions = ref([]); const exams = ref([]); const examKeyword = ref("");
const questionDialog = ref(false); const questionFormRef = ref();
const typeOptions = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
const typeName = Object.fromEntries(typeOptions.map((item) => [item.value, item.label]));
const typeTone = { SINGLE: "", MULTIPLE: "success", TRUE_FALSE: "warning", SHORT_ANSWER: "info" };
const statusMeta = { DRAFT: { label: "未发布", tone: "info" }, NOT_STARTED: { label: "未开始", tone: "" }, IN_PROGRESS: { label: "进行中", tone: "success" }, ENDED: { label: "已结束", tone: "warning" } };
const questionQuery = reactive({ type: "", keyword: "" });
const emptyQuestion = () => ({ id: null, type: "SINGLE", stem: "", options: [{ label: "A", content: "" }, { label: "B", content: "" }, { label: "C", content: "" }, { label: "D", content: "" }], answer: [], analysis: "", attachmentName: "", attachmentUrl: "" });
const questionForm = reactive(emptyQuestion());
const questionRules = { type: [{ required: true, message: "请选择题型" }], stem: [{ required: true, message: "请输入题干" }], answer: [{ required: true, message: "请输入参考答案" }] };
const isChoice = computed(() => ["SINGLE", "MULTIPLE"].includes(questionForm.type));
const singleAnswer = computed({ get: () => questionForm.answer?.[0] || "", set: (value) => { questionForm.answer = value ? [value] : []; } });
onMounted(() => Promise.all([loadQuestions(), loadExams()]));
async function loadQuestions() { loading.value = true; try { questions.value = (await examApi.getQuestions(questionQuery)).data || []; } finally { loading.value = false; } }
async function loadExams() { loading.value = true; try { exams.value = (await examApi.getTeacherExams({ keyword: examKeyword.value })).data || []; } finally { loading.value = false; } }
function openQuestion(row) { Object.assign(questionForm, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(questionForm.answer) && isChoice.value) questionForm.answer = []; questionDialog.value = true; }
function resetAnswer() { questionForm.answer = questionForm.type === "TRUE_FALSE" ? true : questionForm.type === "SHORT_ANSWER" ? "" : []; }
function toggleMultiple(label, checked) { questionForm.answer = checked ? [...new Set([...questionForm.answer, label])] : questionForm.answer.filter((item) => item !== label); }
function addOption() { questionForm.options.push({ label: String.fromCharCode(65 + questionForm.options.length), content: "" }); }
function deleteOption(index) { const [removed] = questionForm.options.splice(index, 1); questionForm.answer = questionForm.answer.filter((item) => item !== removed.label); }
async function saveQuestion() { await questionFormRef.value.validate(); if (isChoice.value && (!questionForm.answer.length || questionForm.options.some((item) => !item.content.trim()))) return ElMessage.warning("请补全选项并设置正确答案"); saving.value = true; try { questionForm.id ? await examApi.updateQuestion(questionForm.id, questionForm) : await examApi.createQuestion(questionForm); ElMessage.success("题目已保存"); questionDialog.value = false; loadQuestions(); } finally { saving.value = false; } }
async function removeQuestion(row) { await ElMessageBox.confirm("删除后无法恢复,确认删除该题目?", "删除题目", { type: "warning" }); await examApi.deleteQuestion(row.id); ElMessage.success("已删除"); loadQuestions(); }
async function uploadMaterial({ file }) { const form = new FormData(); form.append("file", file); const { data } = await examApi.uploadQuestionAttachment(form); questionForm.attachmentName = data.name; questionForm.attachmentUrl = data.url; ElMessage.success("资料已上传"); }
async function doImport({ file }) { const form = new FormData(); form.append("file", file); const { data } = await examApi.importQuestions(form); ElMessage.success(`成功导入 ${data.length} 道题`); loadQuestions(); }
async function downloadTemplate() { saveAs(await examApi.downloadQuestionTemplate(), "考试题库导入模板.docx"); }
async function publish(row) { await ElMessageBox.confirm(`确认发布“${row.name}”?发布后将在设定时间自动开始。`, "发布考试", { type: "warning" }); await examApi.publishExam(row.id); ElMessage.success("已发布"); loadExams(); }
async function withdraw(row) { await examApi.withdrawExam(row.id); ElMessage.success("已撤回"); loadExams(); }
async function removeExam(row) { await ElMessageBox.confirm(`确认永久删除“${row.name}”?仅在已导出全部答卷后允许删除。`, "删除考试", { type: "warning", confirmButtonText: "确认删除" }); await examApi.deleteExam(row.id); ElMessage.success("已删除"); loadExams(); }
async function handleExport(command, row) { const actions = { paper: [examApi.exportExamPaper, `${row.name}试卷.docx`], scores: [examApi.exportExamScores, `${row.name}班级成绩.xlsx`], submissions: [examApi.exportExamSubmissions, `${row.name}全部试卷.zip`] }; const [action, filename] = actions[command]; saveAs(await action(row.id), filename); if (command === "submissions") loadExams(); }
function displayAnswer(value) { if (Array.isArray(value)) return value.join("、"); if (value === true) return "正确"; if (value === false) return "错误"; return value || "-"; }
function formatTime(value) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }).replaceAll("/", "-") : "-"; }
</script>
<style scoped lang="scss">
.exam-admin { color: #1f2937; }
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; h1 { margin: 0; font-size: 24px; letter-spacing: 0; } p { margin: 6px 0 0; color: #6b7280; } }
.workspace-tabs { --el-color-primary: #176b87; :deep(.el-tabs__header) { margin-bottom: 18px; } }
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; .el-select { width: 150px; } .el-input { width: 260px; } }
.toolbar-spacer { flex: 1; }
.data-table { width: 100%; border-top: 1px solid #e5e7eb; }
.option-editor { width: 100%; }
.option-row { display: grid; grid-template-columns: 28px 42px 1fr 36px; align-items: center; gap: 6px; margin-bottom: 10px; }
.option-label { font-weight: 700; }
.file-name { margin-left: 12px; color: #176b87; }
@media (max-width: 760px) { .page-head, .toolbar { align-items: stretch; flex-direction: column; } .toolbar-spacer { display: none; } .toolbar .el-input, .toolbar .el-select { width: 100%; } }
</style>