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

383 lines
21 KiB
Vue

<template>
<main class="exam-admin">
<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" />
<div class="toolbar-actions">
<el-dropdown trigger="click" @command="downloadTemplate">
<el-button :icon="Download">下载模板<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="word">Word 导入模板</el-dropdown-item>
<el-dropdown-item command="excel">Excel 导入模板</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="openImportPicker">
<el-button :icon="Upload" :loading="importing">批量导入<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="word">导入 Word 文件</el-dropdown-item>
<el-dropdown-item command="excel">导入 Excel 文件</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" :icon="Plus" @click="openQuestion()">新增题目</el-button>
<input ref="importInput" class="import-file-input" type="file" :accept="importAccept" @change="handleImportFile" />
</div>
</section>
<el-table v-loading="loading" :data="questions" class="data-table">
<el-table-column label="题型" min-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="更新时间" min-width="180"><template #default="{ row }">{{ formatTime(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" min-width="160"><template #default="{ row }">
<el-button text type="primary" :icon="Edit" @click="openQuestion(row)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="removeQuestion(row)">删除</el-button>
</template></el-table-column>
<template #empty>
<div class="table-empty">
<el-icon class="empty-icon">
<FolderOpened />
</el-icon>
<p class="empty-title">暂无题目</p>
<p class="empty-tip">可通过“新增题目”按钮添加题目</p>
</div>
</template>
</el-table>
</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>
<div class="toolbar-spacer" />
<el-button type="primary" :icon="Plus" @click="$router.push('/exam/manage/new')">新建考试</el-button>
</section>
<el-table v-loading="loading" :data="exams" class="data-table">
<el-table-column prop="name" label="考试名称" min-width="210"><template #default="{ row }"><div class="name-cell"><span class="exam-badge">{{ (row.name || "?").charAt(0) }}</span><span class="name-text">{{ row.name }}</span></div></template></el-table-column>
<el-table-column label="考试时间" min-width="320"><template #default="{ row }">{{ formatTime(row.startTime) }} 至 {{ formatTime(row.endTime) }}</template></el-table-column>
<el-table-column label="试卷" min-width="150"><template #default="{ row }">{{ row.questionCount || 0 }} 题 / {{ row.totalScore || 0 }} 分</template></el-table-column>
<el-table-column label="班级" min-width="90"><template #default="{ row }">{{ row.classCount || 0 }} 个</template></el-table-column>
<el-table-column label="状态" min-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="360"><template #default="{ row }">
<el-button v-if="['DRAFT','IN_PROGRESS'].includes(row.status)" text type="primary" :icon="Edit" @click="$router.push(`/exam/manage/${row.id}`)">编辑</el-button>
<el-button v-if="row.status === 'DRAFT'" text type="success" :icon="Promotion" @click="publish(row)">发布</el-button>
<el-button v-if="row.status === 'NOT_STARTED'" text :icon="RefreshLeft" @click="withdraw(row)">撤回</el-button>
<el-button v-if="row.status === 'IN_PROGRESS'" text type="warning" :icon="RefreshLeft" @click="$router.push(`/exam/review/${row.id}`)">指定人回退</el-button>
<template v-if="row.status === 'ENDED'">
<el-button text type="primary" :icon="EditPen" @click="$router.push(`/exam/review/${row.id}`)">评阅</el-button>
<el-button text type="primary" :icon="DataAnalysis" @click="$router.push(`/exam/analysis/${row.id}`)">分析</el-button>
<el-dropdown @command="(command) => handleExport(command, row)">
<el-button text type="primary" :icon="Download">导出<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 text type="danger" :icon="Delete" @click="removeExam(row)">删除</el-button>
</template>
</template></el-table-column>
<template #empty>
<div class="table-empty">
<el-icon class="empty-icon">
<FolderOpened />
</el-icon>
<p class="empty-title">暂无考试</p>
<p class="empty-tip">可通过“新建考试”按钮创建考试</p>
</div>
</template>
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="questionDialog" :title="questionForm.id ? '编辑题目' : '新增题目'" width="760px" class="teacher-dialog" 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-icon><Close /></el-icon>取消</el-button><el-button type="primary" :loading="saving" @click="saveQuestion"><el-icon><Check /></el-icon>保存</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, Check, Close, DataAnalysis, Delete, Download, Edit, EditPen, FolderOpened, Paperclip, Plus, Promotion, RefreshLeft, 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 importing = ref(false); const questions = ref([]); const exams = ref([]); const examKeyword = ref("");
const questionDialog = ref(false); const questionFormRef = ref();
const importInput = ref(); const importFormat = ref("word");
const importAccept = computed(() => importFormat.value === "word" ? ".docx" : ".xls,.xlsx");
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("资料已上传"); }
function openImportPicker(format) { importFormat.value = format; importInput.value.value = ""; importInput.value.click(); }
async function handleImportFile(event) { const [file] = event.target.files; if (!file) return; await doImport({ file }); }
async function doImport({ file }) { importing.value = true; try { const form = new FormData(); form.append("file", file); const { data } = await examApi.importQuestions(form); ElMessage.success(`成功导入 ${data.length} 道题`); loadQuestions(); } finally { importing.value = false; if (importInput.value) importInput.value.value = ""; } }
async function downloadTemplate(format) { const extension = format === "excel" ? "xlsx" : "docx"; saveAs(await examApi.downloadQuestionTemplate(format), `考试题库${format === "excel" ? "Excel" : "Word"}导入模板.${extension}`); }
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: var(--text-strong);
/* ===== 标签页(极简文字式,tab 文字不换行)===== */
.workspace-tabs {
:deep(.el-tabs__header) {
margin-bottom: var(--sp-4);
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
background-color: var(--border-color);
}
:deep(.el-tabs__item) {
flex: 0 0 auto;
height: 36px;
max-width: none;
padding: 0 var(--sp-4);
color: var(--text-body);
font-size: var(--fs-body);
font-weight: 400;
white-space: nowrap;
&:hover {
color: var(--text-strong);
}
&.is-active {
color: var(--edu-primary);
font-weight: 600;
}
}
:deep(.el-tabs__active-bar) {
height: 2px;
background-color: var(--edu-primary);
}
}
/* ===== 筛选 / 操作条(扁平,直接落在画布上;外观归 teacher-light.scss 全局接管)===== */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
.el-select,
.el-input {
width: var(--field-w);
}
}
.toolbar-spacer {
flex: 1;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.import-file-input {
display: none;
}
/* ===== 数据表格(纯白 + 灰线)===== */
.data-table {
width: 100%;
margin-top: 0 !important;
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: var(--r-card);
overflow: hidden;
:deep(.el-table__header-wrapper th) {
background: #ffffff;
color: var(--text-strong);
font-weight: 600;
font-size: var(--fs-body);
}
:deep(.el-table__body tr:hover > td) {
background: #f9fafb !important;
}
:deep(.el-table__cell) {
font-size: var(--fs-body);
color: var(--text-strong);
border-color: var(--border-color);
}
}
/* ===== 考试名称首字徽标(对齐班级/学生/任务分配基准)===== */
.name-cell {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
vertical-align: middle;
.exam-badge {
flex: 0 0 auto;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--edu-primary-soft);
color: var(--edu-primary);
border-radius: var(--r-control);
font-size: var(--fs-body);
font-weight: 600;
}
.name-text {
font-weight: 600;
color: var(--text-strong);
}
}
/* ===== 自定义空状态 ===== */
.table-empty {
padding: var(--sp-5) 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-1);
.empty-icon {
font-size: 28px;
color: var(--text-muted);
}
.empty-title {
margin: var(--sp-1) 0 0;
font-size: var(--fs-body);
font-weight: 600;
color: var(--text-body);
}
.empty-tip {
margin: 0;
font-size: var(--fs-tag);
color: var(--text-muted);
}
}
/* ===== 弹窗内选项编辑布局(外观由 teacher-light.scss 全局接管)===== */
.option-editor {
width: 100%;
}
.option-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 36px;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.option-label {
font-weight: 700;
}
.file-name {
margin-left: var(--sp-3);
color: var(--edu-primary);
}
@media (max-width: 960px) {
.toolbar-spacer {
flex-basis: 100%;
height: 0;
}
}
@media (max-width: 760px) {
.toolbar {
align-items: stretch;
flex-direction: column;
}
.toolbar-spacer {
display: none;
}
.toolbar-actions {
align-items: stretch;
flex-direction: column;
width: 100%;
}
.toolbar-actions :deep(.el-dropdown) {
width: 100%;
}
.toolbar .el-input,
.toolbar .el-select {
width: 100%;
}
:deep(.toolbar .el-button) {
width: 100%;
}
}
}
</style>