feat: add teacher and student exam pages

bugfix/ai-assessment-evidence-rescore
chenyuan 4 weeks ago
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" });

@ -363,6 +363,7 @@ const teacherManageNav = [
{ title: "任务分配", path: "/task/index" },
{ title: "任务评阅", path: "/task-review/index" },
{ title: "成绩中心", path: "/score/index" },
{ title: "综合考核", path: "/exam/index" },
],
},
];
@ -394,6 +395,7 @@ const trainingTaskKeyByPath = {
};
const studentCourseBaseNav = [
{ title: "AI智能助手", path: "/assistant/index", activePrefix: "/assistant" },
{ title: "考试考核", path: "/examination/index", activePrefix: "/examination" },
];
const studentTaskSections = ref([]);
const currentCourseNav = computed(() => (isTeacherRole.value
@ -435,6 +437,8 @@ const studentIconByPath = {
"/task/index": "Tickets",
"/task-review/index": "DocumentChecked",
"/score/index": "DataBoard",
"/exam/index": "Finished",
"/examination/index": "Finished",
};
const studentIconBySectionStart = [
{ path: "/training/", icon: "Document" },

@ -495,9 +495,37 @@ export const dynamicRoutes = [
meta: { title: "BI可视化分析", icon: "dashboard", affix: true },
},
],
},
];
// 教师端路由
},
{
path: "/examination",
component: Layout,
redirect: "/examination/index",
roles: ["student"],
children: [
{
path: "index",
component: () => import("@/views/exam/index.vue"),
name: "StudentExaminations",
meta: { title: "考试考核", icon: "Finished", affix: true },
},
{
path: "take/:id",
component: () => import("@/views/exam/take.vue"),
name: "StudentExamTake",
hidden: true,
meta: { title: "考试作答", affix: false },
},
{
path: "result/:id",
component: () => import("@/views/exam/result.vue"),
name: "StudentExamResult",
hidden: true,
meta: { title: "考试成绩", affix: false },
},
],
},
];
// 教师端路由
export const teacherRoutes = [
{
path: "/task-review-preview",
@ -669,7 +697,43 @@ export const teacherRoutes = [
meta: { title: "成绩中心", icon: "成绩中心", affix: true },
},
],
},
},
{
path: "/exam",
component: Layout,
redirect: "/exam/index",
roles: ["teacher"],
meta: { title: "综合考核", icon: "Finished", affix: true },
children: [
{
path: "index",
component: () => import("@/views/teacherEnd/exam/index.vue"),
name: "TeacherExams",
meta: { title: "综合考核", icon: "Finished", affix: true },
},
{
path: "manage/:id",
component: () => import("@/views/teacherEnd/exam/editor.vue"),
name: "TeacherExamEditor",
hidden: true,
meta: { title: "考试设置", affix: false },
},
{
path: "review/:id",
component: () => import("@/views/teacherEnd/exam/review.vue"),
name: "TeacherExamReview",
hidden: true,
meta: { title: "考试评阅", affix: false },
},
{
path: "analysis/:id",
component: () => import("@/views/teacherEnd/exam/analysis.vue"),
name: "TeacherExamAnalysis",
hidden: true,
meta: { title: "考试分析", affix: false },
},
],
},
];
export const platformAdminRoutes = [
{

@ -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,19 @@
<template>
<main class="result-page app-container" v-loading="loading">
<header><el-button text :icon="ArrowLeft" @click="$router.push('/examination/index')">返回考试列表</el-button><h1>{{ result.examName }}</h1><p>试卷成绩与作答详情</p></header>
<section class="score-band"><div class="total"><span>总成绩</span><strong>{{ result.totalScore || 0 }}</strong><small>分</small></div><div><span>客观题正确率</span><b>{{ result.objectiveAccuracy || 0 }}%</b></div><div><span>答对</span><b>{{ result.objectiveCorrect || 0 }} 题</b></div><div><span>答错</span><b>{{ result.objectiveWrong || 0 }} 题</b></div><div><span>主观题得分</span><b>{{ result.subjectiveScore || 0 }} 分</b></div></section>
<div class="result-layout"><aside><h2>答题卡</h2><div><button v-for="q in result.questions||[]" :key="q.id" :class="answerClass(q)" @click="currentId=q.id">{{ q.order }}</button></div></aside>
<article v-if="current" class="result-question"><header><el-tag :type="current.correct===false?'danger':'success'">{{ current.type==='SHORT_ANSWER'?'教师评分':current.correct?'回答正确':'回答错误' }}</el-tag><b>{{ current.awardedScore || 0 }} / {{ current.score }} 分</b></header><h2>{{ current.order }}. {{ current.stem }}</h2><p v-for="o in current.options" :key="o.label">{{ o.label }}. {{ o.content }}</p><dl><dt>你的答案</dt><dd>{{ display(current.studentAnswer) }}</dd><dt>正确答案</dt><dd>{{ display(current.answer) }}</dd><template v-if="current.analysis"><dt>答案解析</dt><dd>{{ current.analysis }}</dd></template></dl><a v-if="current.answerFileUrl" :href="current.answerFileUrl" target="_blank">作答附件:{{ current.answerFileName }}</a></article></div>
</main>
</template>
<script setup>
import{computed,onMounted,ref}from"vue";import{useRoute}from"vue-router";import{ArrowLeft}from"@element-plus/icons-vue";import{getExamResult}from"@/api/exam";const route=useRoute(),loading=ref(false),result=ref({questions:[]}),currentId=ref(null);const current=computed(()=>result.value.questions?.find(q=>q.id===currentId.value));onMounted(async()=>{loading.value=true;try{result.value=(await getExamResult(route.params.id)).data;currentId.value=result.value.questions?.[0]?.id}finally{loading.value=false}});function display(v){if(Array.isArray(v))return v.join("、");if(v===true)return"正确";if(v===false)return"错误";return v||"未作答"}function answerClass(q){return{current:q.id===currentId.value,wrong:q.correct===false,right:q.correct===true,subjective:q.type==="SHORT_ANSWER"}}
</script>
<style scoped lang="scss">
.result-page { max-width: 1180px; margin: auto; color: #20282d; > header h1 { font-size: 25px; margin: 12px 0 4px; } > header p { color: #6b7280; margin: 0; } }
.score-band { display: grid; grid-template-columns: 1.25fr repeat(4, 1fr); margin: 24px 0; border-block: 1px solid #dce3e5; > div { padding: 18px; border-right: 1px solid #e4e8ea; display: grid; gap: 6px; span { color: #6b7280; font-size: 13px; } b { font-size: 19px; color: #185e70; } } .total { grid-template-columns: auto auto 1fr; align-items: end; background: #edf7f4; span { grid-column: 1 / 4; } strong { font-size: 38px; color: #11657a; } small { padding-bottom: 7px; } } }
.result-layout { display: grid; grid-template-columns: 230px 1fr; gap: 24px; }
aside { border-right: 1px solid #dce3e5; padding-right: 20px; h2 { font-size: 16px; } div { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; } button { aspect-ratio: 1; border: 1px solid #ccd5d8; background: #fff; border-radius: 3px; &.right { background: #dff3ea; border-color: #61a88b; } &.wrong { background: #fde8e5; border-color: #d87a6e; } &.subjective { background: #e7eef7; border-color: #7696bc; } &.current { outline: 2px solid #e4a53c; outline-offset: 1px; } } }
.result-question { padding: 6px 12px; > header { display: flex; justify-content: space-between; } h2 { font-size: 18px; line-height: 1.7; margin: 20px 0; } p { color: #4b5563; } dl { display: grid; grid-template-columns: 90px 1fr; gap: 10px; background: #f6f8f8; padding: 16px; margin-top: 22px; dt { color: #6b7280; } dd { margin: 0; white-space: pre-wrap; line-height: 1.6; } } a { display: inline-block; color: #176b87; margin-top: 12px; } }
@media(max-width:760px){.score-band{grid-template-columns:1fr 1fr}.result-layout{grid-template-columns:1fr}aside{border-right:0;border-bottom:1px solid #dce3e5;padding-bottom:18px}}
</style>

@ -0,0 +1,55 @@
<template>
<main class="take-page" v-loading="loading">
<header class="take-head"><div><h1>{{ paper.name }}</h1><p>考试结束时间 {{ formatTime(paper.endTime) }}</p></div><div class="timer" :class="{ urgent: remaining < 300 }"><el-icon><Timer /></el-icon><span>剩余时间</span><strong>{{ timerText }}</strong></div><el-button type="danger" plain @click="confirmSubmit">交卷</el-button></header>
<div class="exam-body">
<aside class="answer-sheet"><h2>答题卡</h2><div v-for="group in grouped" :key="group.type" class="sheet-group"><h3>{{ typeName[group.type] }}</h3><div><button v-for="question in group.items" :key="question.id" :class="{current:current?.id===question.id,answered:isAnswered(question)}" @click="select(question.id)">{{ question.order }}</button></div></div><footer><span><i class="answered-dot" />已答</span><span><i />未答</span></footer></aside>
<section v-if="current" class="question-panel">
<header><span>{{ typeName[current.type] }}</span><b>{{ current.score }} 分</b></header><h2>{{ current.order }}. {{ current.stem }}</h2>
<a v-if="current.attachmentUrl" :href="current.attachmentUrl" target="_blank" class="material"><el-icon><Download /></el-icon>下载题目资料 {{ current.attachmentName }}</a>
<el-radio-group v-if="current.type==='SINGLE'" v-model="answers[current.id]" class="choice-list" @change="persist(current)"><el-radio v-for="option in current.options" :key="option.label" :label="option.label"><b>{{ option.label }}</b>{{ option.content }}</el-radio></el-radio-group>
<el-checkbox-group v-else-if="current.type==='MULTIPLE'" v-model="answers[current.id]" class="choice-list" @change="persist(current)"><el-checkbox v-for="option in current.options" :key="option.label" :label="option.label"><b>{{ option.label }}</b>{{ option.content }}</el-checkbox></el-checkbox-group>
<el-radio-group v-else-if="current.type==='TRUE_FALSE'" v-model="answers[current.id]" class="choice-list judgement" @change="persist(current)"><el-radio :label="true"><b>✓</b>正确</el-radio><el-radio :label="false"><b>×</b>错误</el-radio></el-radio-group>
<template v-else><el-input v-model="answers[current.id]" type="textarea" :rows="10" placeholder="请输入答案" @blur="persist(current)" /><div class="file-answer"><el-upload :show-file-list="false" :http-request="uploadAnswer" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"><el-button :icon="Upload">上传作答文件</el-button></el-upload><span v-if="files[current.id]">{{ files[current.id].name }}</span></div></template>
<footer class="question-actions"><el-button :icon="ArrowLeft" :disabled="currentIndex===0" @click="move(-1)">上一题</el-button><el-button type="primary" :disabled="currentIndex===paper.questions.length-1" @click="move(1)">下一题<el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button></footer>
</section>
</div>
</main>
</template>
<script setup>
import { computed,onBeforeUnmount,onMounted,reactive,ref } from "vue";import { useRoute,useRouter } from "vue-router";import { ElMessage,ElMessageBox } from "element-plus";import { ArrowLeft,ArrowRight,Download,Timer,Upload } from "@element-plus/icons-vue";import * as examApi from "@/api/exam";
const route=useRoute(),router=useRouter(),examId=route.params.id;const loading=ref(false),paper=ref({questions:[]}),currentId=ref(null),remaining=ref(0);const answers=reactive({}),files=reactive({});let ticker;
const typeName={SINGLE:"单选题",MULTIPLE:"多选题",TRUE_FALSE:"判断题",SHORT_ANSWER:"简答题"};const current=computed(()=>paper.value.questions.find(q=>q.id===currentId.value));const currentIndex=computed(()=>paper.value.questions.findIndex(q=>q.id===currentId.value));const grouped=computed(()=>Object.keys(typeName).map(type=>({type,items:paper.value.questions.filter(q=>q.type===type)})).filter(g=>g.items.length));const timerText=computed(()=>`${String(Math.floor(remaining.value/3600)).padStart(2,"0")}:${String(Math.floor(remaining.value%3600/60)).padStart(2,"0")}:${String(remaining.value%60).padStart(2,"0")}`);
onMounted(load);onBeforeUnmount(()=>clearInterval(ticker));async function load(){loading.value=true;try{const token=deviceToken();paper.value=(await examApi.getActivePaper(examId,token)).data;paper.value.questions.forEach(q=>{answers[q.id]=q.studentAnswer??(q.type==="MULTIPLE"?[]:"");if(q.answerFileUrl)files[q.id]={name:q.answerFileName,url:q.answerFileUrl}});currentId.value=paper.value.questions[0]?.id;remaining.value=Math.max(0,Math.floor((new Date(paper.value.endTime)-Date.now())/1000));ticker=setInterval(()=>{remaining.value=Math.max(0,remaining.value-1);if(remaining.value===0){clearInterval(ticker);autoSubmit()}},1000)}finally{loading.value=false}}
function deviceToken(){return localStorage.getItem(`exam-device-${examId}`)||""}function select(id){currentId.value=id}function move(step){currentId.value=paper.value.questions[currentIndex.value+step]?.id}
function isAnswered(q){const answer=answers[q.id];return(Array.isArray(answer)?answer.length:Boolean(String(answer??"").trim()))||Boolean(files[q.id])}
async function persist(q){await examApi.saveExamAnswer(examId,q.id,{deviceToken:deviceToken(),answer:answers[q.id]})}
async function uploadAnswer({file}){const form=new FormData();form.append("file",file);const data=(await examApi.uploadExamAnswerFile(examId,current.value.id,deviceToken(),form)).data;files[current.value.id]={name:data.name,url:data.url};ElMessage.success("作答文件已上传")}
async function confirmSubmit(){const unanswered=paper.value.questions.filter(q=>!isAnswered(q)).length;await ElMessageBox.confirm(unanswered?`还有 ${unanswered} 道题未作答,确认交卷?`:"确认提交试卷?交卷后需由教师回退才能再次进入。","确认交卷",{type:"warning",confirmButtonText:"确认交卷"});await doSubmit()}
async function doSubmit(){await examApi.submitExam(examId,deviceToken());ElMessage.success("交卷成功");router.replace("/examination/index")}
async function autoSubmit(){try{await doSubmit()}catch{router.replace("/examination/index")}}
function formatTime(v){return v?new Date(v).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-"):"-"}
</script>
<style scoped lang="scss">
.take-page { min-height: calc(100vh - 84px); background: #f3f6f6; color: #20282d; }
.take-head { height: 78px; background: #fff; border-bottom: 1px solid #dce3e5; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 28px;
h1 { font-size: 20px; margin: 0; } p { margin: 5px 0 0; color: #6b7280; font-size: 13px; } > button { justify-self: end; }
.timer { display: flex; align-items: center; gap: 8px; color: #526067; strong { font-size: 22px; font-variant-numeric: tabular-nums; color: #165f71; } &.urgent strong { color: #c24138; } }
}
.exam-body { display: grid; grid-template-columns: 250px minmax(0, 880px); gap: 22px; justify-content: center; padding: 22px; }
.answer-sheet { background: white; border: 1px solid #dce3e5; padding: 18px; align-self: start; position: sticky; top: 16px;
h2 { font-size: 17px; margin: 0 0 18px; } footer { display: flex; gap: 18px; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb; padding-top: 12px; }
footer i { display: inline-block; width: 10px; height: 10px; border: 1px solid #cbd5d8; margin-right: 5px; } footer .answered-dot { background: #dcefeb; border-color: #68a99b; }
}
.sheet-group { margin-bottom: 16px; h3 { font-size: 13px; color: #66737a; margin: 0 0 8px; } div { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; }
button { aspect-ratio: 1; border: 1px solid #ccd5d8; background: white; border-radius: 3px; cursor: pointer; &.answered { background: #dcefeb; border-color: #68a99b; color: #185e70; } &.current { outline: 2px solid #e6a23c; outline-offset: 1px; } }
}
.question-panel { background: #fff; border: 1px solid #dce3e5; min-height: 610px; padding: 26px 34px 84px; position: relative;
> header { display: flex; justify-content: space-between; color: #176b87; border-bottom: 1px solid #edf0f1; padding-bottom: 12px; } > h2 { font-size: 18px; line-height: 1.7; margin: 22px 0; }
.material { display: inline-flex; align-items: center; gap: 6px; color: #176b87; margin-bottom: 16px; } .choice-list { display: grid; gap: 12px; width: 100%; }
:deep(.el-radio), :deep(.el-checkbox) { margin: 0; border: 1px solid #dce3e5; border-radius: 4px; padding: 14px 16px; height: auto; white-space: normal; }
:deep(.is-checked) { background: #eef8f5; border-color: #68a99b; } :deep(.el-radio__label), :deep(.el-checkbox__label) { display: flex; gap: 12px; line-height: 1.5; }
.judgement { grid-template-columns: 1fr 1fr; } .file-answer { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: #176b87; }
.question-actions { position: absolute; bottom: 24px; left: 34px; right: 34px; display: flex; justify-content: space-between; }
}
@media (max-width: 760px) { .take-head { grid-template-columns: 1fr auto; padding: 10px 14px; height: auto; .timer { grid-row: 2; grid-column: 1 / 3; margin-top: 8px; } > button { grid-column: 2; grid-row: 1; } } .exam-body { grid-template-columns: 1fr; padding: 10px; } .answer-sheet { position: static; } .question-panel { padding: 20px 18px 78px; .question-actions { left: 18px; right: 18px; } } }
</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,129 @@
<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>

@ -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…
Cancel
Save