feat: add teacher and student exam pages
parent
43b48f6d6c
commit
59f45f63e8
@ -0,0 +1,36 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
const base = "/api/exams";
|
||||
|
||||
export const getExamClasses = () => request({ url: `${base}/teacher/classes`, method: "get" });
|
||||
export const getQuestions = (params) => request({ url: `${base}/teacher/questions`, method: "get", params });
|
||||
export const createQuestion = (data) => request({ url: `${base}/teacher/questions`, method: "post", data });
|
||||
export const updateQuestion = (id, data) => request({ url: `${base}/teacher/questions/${id}`, method: "put", data });
|
||||
export const deleteQuestion = (id) => request({ url: `${base}/teacher/questions/${id}`, method: "delete" });
|
||||
export const uploadQuestionAttachment = (data) => request({ url: `${base}/teacher/questions/attachment`, method: "post", data, headers: { "Content-Type": "multipart/form-data" } });
|
||||
export const importQuestions = (data) => request({ url: `${base}/teacher/questions/import`, method: "post", data, headers: { "Content-Type": "multipart/form-data" } });
|
||||
export const downloadQuestionTemplate = () => request({ url: `${base}/teacher/questions/template`, method: "get", responseType: "blob" });
|
||||
|
||||
export const getTeacherExams = (params) => request({ url: `${base}/teacher`, method: "get", params });
|
||||
export const getTeacherExam = (id) => request({ url: `${base}/teacher/${id}`, method: "get" });
|
||||
export const createExam = (data) => request({ url: `${base}/teacher`, method: "post", data });
|
||||
export const updateExam = (id, data) => request({ url: `${base}/teacher/${id}`, method: "put", data });
|
||||
export const publishExam = (id) => request({ url: `${base}/teacher/${id}/publish`, method: "post" });
|
||||
export const withdrawExam = (id) => request({ url: `${base}/teacher/${id}/withdraw`, method: "post" });
|
||||
export const deleteExam = (id) => request({ url: `${base}/teacher/${id}`, method: "delete" });
|
||||
export const exportExamPaper = (id) => request({ url: `${base}/teacher/${id}/paper`, method: "get", responseType: "blob" });
|
||||
export const getReviewStudents = (id) => request({ url: `${base}/teacher/${id}/review`, method: "get" });
|
||||
export const getReviewDetail = (id, attemptId) => request({ url: `${base}/teacher/${id}/review/${attemptId}`, method: "get" });
|
||||
export const saveSubjectiveScores = (id, attemptId, data) => request({ url: `${base}/teacher/${id}/review/${attemptId}`, method: "put", data });
|
||||
export const rollbackAttempt = (id, attemptId) => request({ url: `${base}/teacher/${id}/review/${attemptId}/rollback`, method: "post" });
|
||||
export const exportExamScores = (id) => request({ url: `${base}/teacher/${id}/scores`, method: "get", responseType: "blob" });
|
||||
export const getExamAnalysis = (id, params) => request({ url: `${base}/teacher/${id}/analysis`, method: "get", params });
|
||||
export const exportExamSubmissions = (id) => request({ url: `${base}/teacher/${id}/submissions`, method: "get", responseType: "blob" });
|
||||
|
||||
export const getStudentExams = () => request({ url: `${base}/student`, method: "get" });
|
||||
export const enterExam = (id, deviceToken) => request({ url: `${base}/student/${id}/enter`, method: "post", data: { deviceToken } });
|
||||
export const getActivePaper = (id, deviceToken) => request({ url: `${base}/student/${id}/paper`, method: "get", params: { deviceToken } });
|
||||
export const saveExamAnswer = (id, questionId, data) => request({ url: `${base}/student/${id}/answers/${questionId}`, method: "put", data });
|
||||
export const uploadExamAnswerFile = (id, questionId, deviceToken, data) => request({ url: `${base}/student/${id}/answers/${questionId}/file`, method: "post", params: { deviceToken }, data, headers: { "Content-Type": "multipart/form-data" } });
|
||||
export const submitExam = (id, deviceToken) => request({ url: `${base}/student/${id}/submit`, method: "post", data: { deviceToken } });
|
||||
export const getExamResult = (id) => request({ url: `${base}/student/${id}/result`, method: "get" });
|
||||
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<main class="student-exams app-container" v-loading="loading">
|
||||
<header class="page-head"><div><h1>考试考核</h1><p>按考试时间完成作答并查看已结束考试成绩</p></div><el-button :icon="Refresh" circle title="刷新" @click="load" /></header>
|
||||
<section class="status-filter"><button v-for="item in filters" :key="item.value" :class="{ active: filter === item.value }" @click="filter=item.value">{{ item.label }}<b>{{ count(item.value) }}</b></button></section>
|
||||
<section class="exam-list">
|
||||
<article v-for="exam in visibleExams" :key="exam.id" class="exam-row">
|
||||
<div class="date-block"><strong>{{ day(exam.startTime) }}</strong><span>{{ month(exam.startTime) }}</span></div>
|
||||
<div class="exam-info"><div><el-tag :type="statusMeta[exam.status].tone">{{ statusMeta[exam.status].label }}</el-tag><span v-if="exam.attemptStatus === 'SUBMITTED'" class="submitted">已交卷</span></div><h2>{{ exam.name }}</h2><p><el-icon><Clock /></el-icon>{{ formatTime(exam.startTime) }} 至 {{ formatTime(exam.endTime) }} · {{ exam.durationMinutes }} 分钟</p></div>
|
||||
<div class="exam-action">
|
||||
<el-button v-if="exam.status === 'IN_PROGRESS'" type="primary" :disabled="exam.attemptStatus === 'SUBMITTED'" @click="enter(exam)">{{ exam.attemptStatus === 'IN_PROGRESS' ? '继续考试' : exam.attemptStatus === 'SUBMITTED' ? '已交卷' : '进入考试' }}</el-button>
|
||||
<el-button v-else-if="exam.status === 'ENDED' && exam.attemptStatus === 'SUBMITTED'" type="primary" plain @click="$router.push(`/examination/result/${exam.id}`)">查看成绩</el-button>
|
||||
<span v-else-if="exam.status === 'NOT_STARTED'">{{ countdown(exam.startTime) }}</span><span v-else>未参加</span>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!visibleExams.length" description="当前没有考试" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; import { Clock, Refresh } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam";
|
||||
const router=useRouter();const loading=ref(false);const exams=ref([]);const filter=ref("ALL");const filters=[{label:"全部",value:"ALL"},{label:"未开始",value:"NOT_STARTED"},{label:"进行中",value:"IN_PROGRESS"},{label:"已结束",value:"ENDED"}];const statusMeta={NOT_STARTED:{label:"未开始",tone:""},IN_PROGRESS:{label:"进行中",tone:"success"},ENDED:{label:"已结束",tone:"info"}};
|
||||
const visibleExams=computed(()=>filter.value==="ALL"?exams.value:exams.value.filter(e=>e.status===filter.value));onMounted(load);
|
||||
async function load(){loading.value=true;try{exams.value=(await examApi.getStudentExams()).data||[]}finally{loading.value=false}}
|
||||
async function enter(exam){const token=deviceToken(exam.id);await examApi.enterExam(exam.id,token);router.push(`/examination/take/${exam.id}`)}
|
||||
function deviceToken(id){const key=`exam-device-${id}`;let value=localStorage.getItem(key);if(!value){value=crypto?.randomUUID?.()||`${Date.now()}-${Math.random().toString(36).slice(2)}`;localStorage.setItem(key,value)}return value}
|
||||
function count(value){return value==="ALL"?exams.value.length:exams.value.filter(e=>e.status===value).length}function day(v){return String(new Date(v).getDate()).padStart(2,"0")}function month(v){return `${new Date(v).getMonth()+1}月`}function formatTime(v){return new Date(v).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-")}function countdown(v){const minutes=Math.max(0,Math.ceil((new Date(v)-Date.now())/60000));return minutes<60?`${minutes} 分钟后开始`:`${Math.ceil(minutes/60)} 小时后开始`}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.student-exams{color:#20282d;max-width:1180px;margin:auto}.page-head{display:flex;justify-content:space-between;align-items:center;h1{font-size:26px;margin:0;letter-spacing:0}p{color:#6b7280;margin:6px 0 0}}
|
||||
.status-filter{display:flex;gap:28px;border-bottom:1px solid #dfe5e7;margin-top:24px;button{border:0;background:none;padding:12px 2px;color:#68747a;cursor:pointer;position:relative;b{margin-left:7px;font-weight:500}&.active{color:#12637a;font-weight:600;&:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:3px;background:#16839c}}}}
|
||||
.exam-row{display:grid;grid-template-columns:78px 1fr 150px;gap:22px;align-items:center;padding:22px 8px;border-bottom:1px solid #e6eaec}.date-block{border-right:1px solid #dfe5e7;display:grid;text-align:center;strong{font-size:28px;color:#185e70}span{color:#6b7280;font-size:13px}}.exam-info{h2{font-size:18px;margin:9px 0}.submitted{margin-left:8px;color:#43856e;font-size:13px}p{display:flex;align-items:center;gap:5px;margin:0;color:#6b7280}}.exam-action{text-align:right;color:#7b858a}
|
||||
@media(max-width:650px){.exam-row{grid-template-columns:58px 1fr}.exam-action{grid-column:2;text-align:left}.status-filter{gap:14px;overflow-x:auto}}
|
||||
</style>
|
||||
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<main class="analysis-page app-container" v-loading="loading">
|
||||
<header class="page-head"><div><el-button text :icon="ArrowLeft" @click="$router.push('/exam/index')">返回考评管理</el-button><h1>{{ data.examName || '考试数据分析' }}</h1></div><el-select v-model="classId" clearable placeholder="全部班级" @change="load"><el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" /></el-select></header>
|
||||
<section class="metrics"><div v-for="item in metrics" :key="item.label"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div></section>
|
||||
<section class="analysis-grid">
|
||||
<div class="chart-block"><h2>成绩分布</h2><div ref="chartEl" class="chart" /></div>
|
||||
<div class="hardest"><h2>失分率最高题目</h2><ol><li v-for="item in data.hardestQuestions || []" :key="item.id"><div>{{ item.stem }}</div><span>{{ item.wrongCount }} 人答错</span></li></ol><el-empty v-if="!data.hardestQuestions?.length" description="暂无客观题错题数据" /></div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ArrowLeft } from "@element-plus/icons-vue";
|
||||
import * as echarts from "echarts";
|
||||
import * as examApi from "@/api/exam";
|
||||
const route = useRoute(); const examId = route.params.id; const loading = ref(false); const data = ref({}); const classes = ref([]); const classId = ref(""); const chartEl = ref(); let chart;
|
||||
const metrics = computed(() => [{ label: "应考人数", value: data.value.expected || 0 }, { label: "实考人数", value: data.value.attended || 0 }, { label: "及格人数", value: data.value.passed || 0 }, { label: "不及格人数", value: data.value.failed || 0 }, { label: "最高分", value: data.value.highest || 0 }, { label: "最低分", value: data.value.lowest || 0 }, { label: "平均分", value: data.value.average || 0 }]);
|
||||
onMounted(async () => { classes.value = (await examApi.getTeacherExam(examId)).data?.classes || []; await load(); window.addEventListener("resize", resize); }); onBeforeUnmount(() => window.removeEventListener("resize", resize));
|
||||
async function load() { loading.value = true; try { data.value = (await examApi.getExamAnalysis(examId, { classId: classId.value || undefined })).data; await nextTick(); renderChart(); } finally { loading.value = false; } }
|
||||
function renderChart() { chart ||= echarts.init(chartEl.value); chart.setOption({ tooltip: { trigger: "item" }, legend: { bottom: 0 }, color: ["#176b87", "#41a186", "#e1a43b", "#d95d50"], series: [{ type: "pie", radius: ["48%", "72%"], center: ["50%", "44%"], label: { formatter: "{b}\n{c}人" }, data: data.value.distribution || [] }] }); }
|
||||
function resize() { chart?.resize(); }
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.analysis-page { color: #1f2937; }.page-head{display:flex;justify-content:space-between;align-items:flex-end;h1{margin:10px 0 0;font-size:24px}.el-select{width:220px}}
|
||||
.metrics{display:grid;grid-template-columns:repeat(7,1fr);border-block:1px solid #dfe5e7;margin:22px 0 28px;div{padding:16px 12px;border-right:1px solid #e5e7eb;display:grid;gap:6px;&:last-child{border:0}}span{font-size:13px;color:#6b7280}strong{font-size:23px;color:#165f71}}
|
||||
.analysis-grid{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(360px,.9fr);gap:36px}h2{font-size:18px;margin:0 0 16px}.chart{height:390px}.hardest ol{list-style:none;padding:0;margin:0;counter-reset:item}.hardest li{display:grid;grid-template-columns:1fr auto;gap:16px;padding:16px 0;border-bottom:1px solid #e5e7eb;line-height:1.6;&:before{counter-increment:item;content:counter(item);grid-row:1;display:none}span{color:#c04d42;white-space:nowrap}}
|
||||
@media(max-width:900px){.metrics{grid-template-columns:repeat(3,1fr)}.analysis-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@ -0,0 +1,151 @@
|
||||
<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>
|
||||
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<main class="review-page app-container" v-loading="loading">
|
||||
<header class="page-head">
|
||||
<div><el-button text :icon="ArrowLeft" @click="$router.push('/exam/index')">返回考评管理</el-button><h1>{{ exam.name || '考试评阅' }}</h1><p>{{ exam.status === 'IN_PROGRESS' ? '仅显示已进入考试的学生,已交卷学生置顶' : '仅显示已交卷学生,客观题已由系统自动评分' }}</p></div>
|
||||
<el-button :icon="Download" @click="exportScores">导出班级成绩</el-button>
|
||||
</header>
|
||||
<section class="summary-strip">
|
||||
<div><span>已交卷</span><strong>{{ submittedCount }}</strong></div>
|
||||
<div><span>试卷总分</span><strong>{{ exam.totalScore || 0 }}</strong></div>
|
||||
</section>
|
||||
<el-table :data="students" class="student-table">
|
||||
<el-table-column prop="className" label="班级" min-width="170" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="studentNo" label="学号" width="160" />
|
||||
<el-table-column prop="submittedAt" label="交卷时间" min-width="180"><template #default="{ row }">{{ formatTime(row.submittedAt) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'SUBMITTED' ? 'success' : 'info'">{{ row.status === 'SUBMITTED' ? '已交卷' : '未交卷' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="objectiveScore" label="客观题" width="100" />
|
||||
<el-table-column prop="subjectiveScore" label="主观题" width="100" />
|
||||
<el-table-column prop="totalScore" label="总分" width="90"><template #default="{ row }"><strong class="score">{{ row.totalScore }}</strong></template></el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right"><template #default="{ row }">
|
||||
<el-button v-if="row.status === 'SUBMITTED'" link type="primary" @click="openReview(row)">评阅试卷</el-button>
|
||||
<el-button v-if="row.status === 'SUBMITTED' && exam.status === 'IN_PROGRESS' && exam.allowRollback" link type="warning" @click="rollback(row)">回退</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && !students.length" description="暂无已交卷学生" />
|
||||
|
||||
<el-drawer v-model="drawer" title="试卷评阅" size="70%" destroy-on-close>
|
||||
<template v-if="detail">
|
||||
<div class="student-info"><div><span>学生</span><b>{{ currentStudent.name }}</b></div><div><span>学号</span><b>{{ currentStudent.studentNo }}</b></div><div><span>客观题得分</span><b>{{ detail.objectiveScore }}</b></div><div><span>主观题得分</span><b>{{ detail.subjectiveScore }}</b></div><div><span>总分</span><b>{{ detail.totalScore }}</b></div></div>
|
||||
<el-tabs v-model="activeType">
|
||||
<el-tab-pane v-for="item in visibleTypes" :key="item.value" :label="item.label" :name="item.value" />
|
||||
</el-tabs>
|
||||
<article v-for="(question, index) in filteredDetailQuestions" :key="question.id" class="review-question">
|
||||
<header><h3>{{ index + 1 }}. {{ question.stem }}</h3><span>满分 {{ question.score }} 分</span></header>
|
||||
<div v-if="question.options?.length" class="question-options"><p v-for="option in question.options" :key="option.label">{{ option.label }}. {{ option.content }}</p></div>
|
||||
<dl><dt>正确答案</dt><dd>{{ displayAnswer(question.answer) }}</dd><dt>学生答案</dt><dd>{{ displayAnswer(question.studentAnswer) }}</dd><template v-if="question.analysis"><dt>答案解析</dt><dd>{{ question.analysis }}</dd></template></dl>
|
||||
<a v-if="question.answerFileUrl" :href="question.answerFileUrl" target="_blank" class="answer-file"><el-icon><Paperclip /></el-icon>{{ question.answerFileName }}</a>
|
||||
<div v-if="question.type === 'SHORT_ANSWER'" class="mark"><span>教师评分</span><el-input-number v-model="scores[question.id]" :min="0" :max="Number(question.score)" :precision="1" :step="0.5" /><b>/ {{ question.score }} 分</b></div>
|
||||
<el-tag v-else :type="question.correct ? 'success' : 'danger'">{{ question.correct ? '回答正确' : '回答错误' }} · {{ question.awardedScore || 0 }} 分</el-tag>
|
||||
</article>
|
||||
</template>
|
||||
<template #footer><el-button @click="drawer=false">关闭</el-button><el-button v-if="activeType === 'SHORT_ANSWER'" type="primary" :loading="saving" @click="saveScores">完成评阅</el-button></template>
|
||||
</el-drawer>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowLeft, Download, Paperclip } from "@element-plus/icons-vue";
|
||||
import { saveAs } from "file-saver";
|
||||
import * as examApi from "@/api/exam";
|
||||
|
||||
const route = useRoute(); const examId = route.params.id; const loading = ref(false); const saving = ref(false); const students = ref([]); const exam = ref({});
|
||||
const drawer = ref(false); const detail = ref(null); const currentStudent = ref({}); const activeType = ref("SHORT_ANSWER"); const scores = reactive({});
|
||||
const types = [{ label: "简答题", value: "SHORT_ANSWER" }, { label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }];
|
||||
const submittedCount = computed(() => students.value.filter((item) => item.status === "SUBMITTED").length);
|
||||
const visibleTypes = computed(() => types.filter((type) => detail.value?.questions.some((q) => q.type === type.value)));
|
||||
const filteredDetailQuestions = computed(() => detail.value?.questions.filter((q) => q.type === activeType.value) || []);
|
||||
|
||||
onMounted(load);
|
||||
async function load() { loading.value = true; try { const [examRes, studentRes] = await Promise.all([examApi.getTeacherExam(examId), examApi.getReviewStudents(examId)]); exam.value = examRes.data; students.value = studentRes.data || []; } finally { loading.value = false; } }
|
||||
async function openReview(row) { currentStudent.value = row; const { data } = await examApi.getReviewDetail(examId, row.attemptId); detail.value = data; Object.keys(scores).forEach((key) => delete scores[key]); data.questions.filter((q) => q.type === "SHORT_ANSWER").forEach((q) => { scores[q.id] = Number(q.awardedScore || 0); }); activeType.value = visibleTypes.value[0]?.value || "SINGLE"; drawer.value = true; }
|
||||
async function saveScores() { saving.value = true; try { detail.value = (await examApi.saveSubjectiveScores(examId, currentStudent.value.attemptId, scores)).data; ElMessage.success("评阅结果已保存"); await load(); } finally { saving.value = false; } }
|
||||
async function rollback(row) { await ElMessageBox.confirm(`确认将 ${row.name} 的试卷回退为未交卷?已保存的作答内容会保留。`, "回退考试", { type: "warning" }); await examApi.rollbackAttempt(examId, row.attemptId); ElMessage.success("已回退"); load(); }
|
||||
async function exportScores() { saveAs(await examApi.exportExamScores(examId), `${exam.value.name}班级成绩.xlsx`); }
|
||||
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">
|
||||
.review-page { color: #1f2937; }
|
||||
.page-head { display: flex; justify-content: space-between; align-items: flex-end; h1 { margin: 10px 0 4px; font-size: 24px; } p { margin: 0; color: #6b7280; } }
|
||||
.summary-strip { display: flex; border-block: 1px solid #e5e7eb; margin: 22px 0; > div { min-width: 170px; padding: 14px 26px 14px 0; display: grid; gap: 4px; } span { color: #6b7280; } strong { font-size: 22px; color: #185e70; } }
|
||||
.student-table { border-top: 1px solid #e5e7eb; }
|
||||
.score { color: #12637a; font-size: 16px; }
|
||||
.student-info { display: grid; grid-template-columns: repeat(5, 1fr); background: #eef7f5; padding: 14px 18px; margin-bottom: 18px; > div { display: grid; gap: 5px; } span { color: #6b7280; font-size: 13px; } b { font-size: 16px; } }
|
||||
.review-question { padding: 18px 4px; border-bottom: 1px solid #e5e7eb; header { display: flex; gap: 18px; justify-content: space-between; } h3 { margin: 0; font-size: 16px; line-height: 1.6; } header span { flex: none; color: #176b87; } }
|
||||
.question-options { display: grid; grid-template-columns: 1fr 1fr; color: #4b5563; }
|
||||
dl { display: grid; grid-template-columns: 88px 1fr; gap: 8px 14px; background: #f7f8f8; padding: 12px 16px; dt { color: #6b7280; } dd { margin: 0; white-space: pre-wrap; } }
|
||||
.answer-file { display: inline-flex; gap: 6px; align-items: center; margin: 8px 0; color: #176b87; }
|
||||
.mark { display: flex; align-items: center; gap: 12px; margin-top: 14px; b { font-weight: 500; color: #6b7280; } }
|
||||
@media (max-width: 760px) { .student-info { grid-template-columns: 1fr 1fr; gap: 12px; } .summary-strip { overflow-x: auto; } }
|
||||
</style>
|
||||
Loading…
Reference in New Issue