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/editor.vue

130 lines
16 KiB
Vue

<template>
<main class="exam-editor app-container" v-loading="loading">
<header class="editor-head">
<el-button text :icon="ArrowLeft" @click="$router.push('/exam/index')">返回考评管理</el-button>
<div class="title-block"><h1>{{ isNew ? '新建考试' : form.name || '编辑考试' }}</h1><el-tag v-if="!isNew" :type="statusMeta[status]?.tone">{{ statusMeta[status]?.label }}</el-tag></div>
<div><el-button @click="save(false)">{{ inProgress ? '保存结束时间' : '保存' }}</el-button><el-button v-if="status === 'DRAFT'" type="primary" @click="save(true)">保存并发布</el-button></div>
</header>
<el-steps v-if="!inProgress" :active="activeStep" align-center class="steps">
<el-step title="基础设置" /><el-step title="选择试题" /><el-step title="试卷概览" />
</el-steps>
<section v-if="activeStep === 0" class="editor-panel">
<h2>基础设置</h2>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" class="base-form">
<el-form-item label="考试名称" prop="name"><el-input v-model="form.name" maxlength="20" show-word-limit :disabled="inProgress" /></el-form-item>
<el-form-item label="开始时间" prop="startTime"><el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" :disabled="inProgress" /></el-form-item>
<el-form-item label="考试时长"><el-input-number v-model="duration" :min="1" :max="1440" @change="syncEndTime" /><span class="unit">分钟</span></el-form-item>
<el-form-item label="结束时间" prop="endTime"><el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
<el-form-item label="考试班级" prop="classIds">
<el-select v-model="form.classIds" multiple collapse-tags placeholder="选择教师名下的教学班" :disabled="inProgress"><el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" /></el-select>
</el-form-item>
<el-form-item label="考试回退"><el-switch v-model="form.allowRollback" active-text="允许教师将已交卷学生回退" :disabled="inProgress" /></el-form-item>
</el-form>
</section>
<section v-if="!inProgress && activeStep === 1" class="editor-panel question-stage">
<header class="section-head"><div><h2>选择试题</h2><p>勾选题目并设置该题分值,选择结果实时同步到试卷概览</p></div>
<div class="import-actions"><el-button :icon="Download" @click="downloadTemplate">导入模板</el-button><el-upload :show-file-list="false" accept=".docx" :http-request="importPaperQuestions"><el-button type="primary" plain :icon="Upload">导入 Word 试题</el-button></el-upload></div>
</header>
<div class="question-layout">
<aside class="type-nav">
<button v-for="item in typeOptions" :key="item.value" :class="{ active: activeType === item.value }" @click="activeType=item.value">
<span>{{ item.label }}</span><b>{{ selectedByType(item.value) }} / {{ questionCount(item.value) }}</b>
</button>
</aside>
<div class="question-list">
<article v-for="question in filteredQuestions" :key="question.id" :class="['question-item', { selected: isSelected(question.id) }]">
<el-checkbox :model-value="isSelected(question.id)" @change="toggleQuestion(question, $event)" />
<div class="question-body"><h3>{{ question.stem }}</h3><div v-if="question.options?.length" class="options"><span v-for="option in question.options" :key="option.label">{{ option.label }}. {{ option.content }}</span></div></div>
<div v-if="isSelected(question.id)" class="score-input"><el-input-number :model-value="selectedScore(question.id)" :min="0.5" :precision="1" :step="0.5" controls-position="right" @change="setScore(question.id, $event)" /><span>分</span></div>
</article>
<el-empty v-if="!filteredQuestions.length" description="该题型暂无题目" />
</div>
</div>
</section>
<section v-if="!inProgress && activeStep === 2" class="editor-panel preview-stage">
<header class="section-head"><div><h2>试卷概览</h2><p>{{ form.questions.length }} 道题,共 {{ totalScore }} 分</p></div><el-button :icon="Download" @click="exportPaper" :disabled="isNew">导出 Word 试卷</el-button></header>
<div class="score-summary">
<div v-for="item in typeOptions" :key="item.value"><span>{{ item.label }}</span><strong>{{ selectedByType(item.value) }}</strong><small>{{ scoreByType(item.value) }} 分</small></div>
<div class="total"><span>总分</span><strong>{{ totalScore }}</strong><small>自动合计</small></div>
</div>
<nav class="anchor-nav"><button v-for="item in selectedTypes" :key="item.value" @click="scrollToType(item.value)">{{ item.label }}</button></nav>
<section v-for="item in selectedTypes" :id="`paper-${item.value}`" :key="item.value" class="paper-section">
<h3>{{ item.label }}</h3>
<article v-for="(question, index) in selectedQuestions(item.value)" :key="question.id" class="paper-question">
<el-checkbox :model-value="true" @change="toggleQuestion(question, false)" /><div><h4>{{ index + 1 }}. {{ question.stem }} <b>{{ selectedScore(question.id) }} 分</b></h4><p v-for="option in question.options" :key="option.label">{{ option.label }}. {{ option.content }}</p></div>
</article>
</section>
<el-empty v-if="!form.questions.length" description="尚未选择试题" />
</section>
<footer v-if="!inProgress" class="editor-footer"><el-button v-if="activeStep > 0" :icon="ArrowLeft" @click="activeStep--">上一步</el-button><el-button v-if="activeStep < 2" type="primary" @click="nextStep">下一步<el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button></footer>
</main>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft, ArrowRight, Download, Upload } from "@element-plus/icons-vue";
import { saveAs } from "file-saver";
import * as examApi from "@/api/exam";
const route = useRoute(); const router = useRouter(); const id = computed(() => route.params.id); const isNew = computed(() => id.value === "new");
const loading = ref(false); const activeStep = ref(0); const activeType = ref("SINGLE"); const status = ref("DRAFT"); const classes = ref([]); const bank = ref([]); const formRef = ref(); const duration = ref(60);
const typeOptions = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
const statusMeta = { DRAFT: { label: "未发布", tone: "info" }, NOT_STARTED: { label: "未开始", tone: "" }, IN_PROGRESS: { label: "进行中", tone: "success" }, ENDED: { label: "已结束", tone: "warning" } };
const form = reactive({ name: "", startTime: "", endTime: "", classIds: [], allowRollback: true, questions: [] });
const rules = { name: [{ required: true, message: "请输入考试名称" }, { pattern: /^[\p{L}\p{N}]+$/u, message: "仅支持汉字、字母和数字" }], startTime: [{ required: true, message: "请选择开始时间" }], endTime: [{ required: true, message: "请选择结束时间" }], classIds: [{ type: "array", required: true, min: 1, message: "至少选择一个班级" }] };
const inProgress = computed(() => status.value === "IN_PROGRESS");
const filteredQuestions = computed(() => bank.value.filter((item) => item.type === activeType.value));
const totalScore = computed(() => form.questions.reduce((sum, item) => sum + Number(item.score || 0), 0));
const selectedTypes = computed(() => typeOptions.filter((item) => selectedByType(item.value)));
onMounted(load);
async function load() { loading.value = true; try { const [classRes, questionRes] = await Promise.all([examApi.getExamClasses(), examApi.getQuestions({})]); classes.value = classRes.data || []; bank.value = questionRes.data || []; if (!isNew.value) { const { data } = await examApi.getTeacherExam(id.value); status.value = data.status; const bankIds = new Set(bank.value.map((item) => item.id)); bank.value = [...bank.value, ...data.questions.filter((item) => !bankIds.has(item.id))]; Object.assign(form, { name: data.name, startTime: formatValue(data.startTime), endTime: formatValue(data.endTime), classIds: data.classes.map((item) => item.id), allowRollback: data.allowRollback, questions: data.questions.map((item) => ({ questionId: item.id, score: Number(item.score) })) }); duration.value = data.durationMinutes; } else { const start = new Date(Date.now() + 3600000); form.startTime = formatValue(start); form.endTime = formatValue(new Date(start.getTime() + 3600000)); } } finally { loading.value = false; } }
function syncEndTime() { if (!form.startTime) return; form.endTime = formatValue(new Date(new Date(form.startTime.replace(" ", "T")).getTime() + duration.value * 60000)); }
function isSelected(questionId) { return form.questions.some((item) => item.questionId === questionId); }
function selectedScore(questionId) { return form.questions.find((item) => item.questionId === questionId)?.score || 1; }
function toggleQuestion(question, checked) { if (checked) form.questions.push({ questionId: question.id, score: 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); }
function setScore(questionId, score) { const item = form.questions.find((row) => row.questionId === questionId); if (item) item.score = Number(score); }
function selectedByType(type) { const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); return form.questions.filter((item) => ids.has(item.questionId)).length; }
function questionCount(type) { return bank.value.filter((item) => item.type === type).length; }
function scoreByType(type) { return selectedQuestions(type).reduce((sum, item) => sum + Number(selectedScore(item.id)), 0); }
function selectedQuestions(type) { const selected = new Set(form.questions.map((item) => item.questionId)); return bank.value.filter((item) => item.type === type && selected.has(item.id)); }
async function nextStep() { if (activeStep.value === 0) { try { await formRef.value.validate(); const start = new Date(form.startTime.replace(" ", "T")); const end = new Date(form.endTime.replace(" ", "T")); if (end <= start) return ElMessage.warning("结束时间必须晚于开始时间"); duration.value = Math.round((end - start) / 60000); } catch { return; } } if (activeStep.value === 1 && !form.questions.length) return ElMessage.warning("至少选择一道试题"); activeStep.value++; }
async function save(publish) { if (inProgress.value) { await examApi.updateExam(id.value, form); ElMessage.success("结束时间已更新"); return router.push("/exam/index"); } try { await formRef.value?.validate(); } catch { activeStep.value = 0; return; } if (!form.questions.length) { activeStep.value = 1; return ElMessage.warning("至少选择一道试题"); } const result = isNew.value ? await examApi.createExam(form) : await examApi.updateExam(id.value, form); const examId = result.data.id; if (publish) await examApi.publishExam(examId); ElMessage.success(publish ? "考试已保存并发布" : "考试已保存"); router.push("/exam/index"); }
async function importPaperQuestions({ file }) { const formData = new FormData(); formData.append("file", file); const { data } = await examApi.importQuestions(formData); bank.value = [...data, ...bank.value]; for (const q of data) if (!isSelected(q.id)) form.questions.push({ questionId: q.id, score: 1 }); ElMessage.success(`导入并选中 ${data.length} 道题`); }
async function downloadTemplate() { saveAs(await examApi.downloadQuestionTemplate(), "考试题库导入模板.docx"); }
async function exportPaper() { saveAs(await examApi.exportExamPaper(id.value), `${form.name}试卷.docx`); }
function scrollToType(type) { document.getElementById(`paper-${type}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
function formatValue(value) { const date = value instanceof Date ? value : new Date(value); const pad = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }
</script>
<style scoped lang="scss">
.exam-editor { color: #1f2937; padding-bottom: 84px; }
.editor-head { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; border-bottom: 1px solid #e5e7eb; padding-bottom: 14px; > :last-child { justify-self: end; } }
.title-block { display: flex; align-items: center; gap: 10px; h1 { margin: 0; font-size: 23px; letter-spacing: 0; } }
.steps { max-width: 760px; margin: 28px auto; }
.editor-panel { max-width: 1120px; margin: auto; h2 { font-size: 19px; margin: 0 0 22px; } }
.base-form { max-width: 650px; .el-input, .el-select { width: 100%; } .el-date-editor { width: 100%; } }
.unit { margin-left: 8px; color: #6b7280; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; h2 { margin: 0; } p { margin: 6px 0 0; color: #6b7280; } }
.import-actions { display: flex; gap: 8px; }
.question-layout { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 20px; }
.type-nav { border-right: 1px solid #e5e7eb; padding-right: 14px; button { width: 100%; border: 0; background: transparent; padding: 12px; display: flex; justify-content: space-between; color: #4b5563; cursor: pointer; border-radius: 4px; &.active { color: #0f5d72; background: #eaf5f7; } b { font-weight: 600; } } }
.question-list { min-width: 0; }
.question-item { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; padding: 16px 12px; border-bottom: 1px solid #e5e7eb; &.selected { background: #f3fafb; } h3 { font-size: 15px; line-height: 1.6; margin: 0; font-weight: 600; } }
.options { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 6px 20px; color: #6b7280; margin-top: 9px; }
.score-input { display: flex; align-items: center; gap: 6px; .el-input-number { width: 104px; } }
.score-summary { display: grid; grid-template-columns: repeat(5, 1fr); border: 1px solid #d9e1e5; margin-bottom: 18px; > div { padding: 16px; border-right: 1px solid #d9e1e5; display: grid; gap: 5px; &:last-child { border-right: 0; } span, small { color: #6b7280; } strong { font-size: 24px; color: #154f5f; } } .total { background: #edf7f4; } }
.anchor-nav { display: flex; gap: 8px; margin: 18px 0; button { border: 1px solid #b9cdd3; background: white; color: #176b87; padding: 7px 14px; border-radius: 4px; cursor: pointer; } }
.paper-section { scroll-margin-top: 20px; border-top: 2px solid #1f6f78; padding-top: 16px; margin-top: 26px; h3 { font-size: 17px; } }
.paper-question { display: grid; grid-template-columns: 26px 1fr; padding: 12px 0; border-bottom: 1px solid #eef1f3; h4 { margin: 0 0 8px; line-height: 1.6; font-weight: 500; b { float: right; color: #176b87; } } p { margin: 4px 0; color: #4b5563; } }
.editor-footer { position: fixed; bottom: 0; left: 0; right: 0; height: 64px; display: flex; justify-content: center; align-items: center; gap: 12px; background: rgba(255,255,255,.96); border-top: 1px solid #e5e7eb; z-index: 10; }
@media (max-width: 760px) { .editor-head { grid-template-columns: 1fr; gap: 12px; .title-block { grid-row: 1; } > :last-child { justify-self: start; } } .question-layout { grid-template-columns: 1fr; } .type-nav { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid #e5e7eb; } .score-summary { grid-template-columns: 1fr 1fr; } .section-head { align-items: flex-start; flex-direction: column; gap: 12px; } }
</style>