Compare commits

...

4 Commits

Author SHA1 Message Date
chenyuan a8425d74bd merge: sync dev-QQq into master 5 days ago
qinzhenpen 623771ec6b Merge remote-tracking branch 'origin/fix/exam-question-access-material-excel-delete-draft' into dev-QQq 5 days ago
qinzhenpen 2b640c32f5 feat(考试系统): 增加题目来源筛选、侧边栏变量、题目排序及分页功能 5 days ago
chenyuan 5b16fc5be6 feat: 简答题资料支持上传Excel;未发布考试增加删除按钮
- 教师端/平台管理端题目资料上传的 accept 增加 .xls/.xlsx
- 考试列表"未发布"状态增加删除按钮,确认文案区分未发布与已结束
5 days ago

@ -1013,8 +1013,9 @@
* 同时隐藏教师端唯一的分组标题"教学管理"(标识),仅保留菜单项 * 同时隐藏教师端唯一的分组标题"教学管理"(标识),仅保留菜单项
*/ */
#app .teacherLayout { #app .teacherLayout {
--teacher-sidebar-width: 180px;
.main-container { .main-container {
margin-left: 180px !important; margin-left: var(--teacher-sidebar-width) !important;
} }
// 收起后侧边栏仅保留 64px 图标轨道,主内容同步释放展开态的空间。 // 收起后侧边栏仅保留 64px 图标轨道,主内容同步释放展开态的空间。
@ -1023,7 +1024,7 @@
} }
.sidebar-container { .sidebar-container {
width: 180px !important; width: var(--teacher-sidebar-width) !important;
} }
// 隐藏教师端顶部分组标题("教学管理"标识),不影响学生端多分组标题 // 隐藏教师端顶部分组标题("教学管理"标识),不影响学生端多分组标题
.student-course-nav > .student-course-heading { .student-course-nav > .student-course-heading {

@ -6,8 +6,8 @@
</main> </main>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { Clock, Refresh } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam"; import { computed, onActivated, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { Clock, Refresh } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam";
const router=useRouter(),loading=ref(false),exams=ref([]),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((item)=>item.status===filter.value)); onMounted(load); const router=useRouter(),loading=ref(false),exams=ref([]),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((item)=>item.status===filter.value)); let examsMounted=false; onMounted(load); onActivated(()=>{if(examsMounted) load(); examsMounted=true;});
async function load(){loading.value=true;try{exams.value=(await examApi.getStudentExams()).data||[]}finally{loading.value=false}} async function enter(exam){try{const token=deviceToken(exam.id);await examApi.enterExam(exam.id,token);router.push(`/examination/take/${exam.id}`)}catch(error){ElMessage.error(error?.message||"进入考试失败")}} 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((item)=>item.status===value).length} function day(value){return value?String(new Date(value).getDate()).padStart(2,"0"):"--"} function month(value){return value?`${new Date(value).getMonth()+1}月`:"—"} function formatTime(value){return value?new Date(value).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-"):"—"} function durationBetween(row){return row.startTime&&row.endTime?Math.max(0,Math.round((new Date(row.endTime)-new Date(row.startTime))/60000)):0} function countdown(value){const minutes=Math.max(0,Math.ceil((new Date(value)-Date.now())/60000));return minutes<60?`${minutes} 分钟后开始`:`${Math.ceil(minutes/60)} 小时后开始`} async function load(){loading.value=true;try{exams.value=(await examApi.getStudentExams()).data||[]}finally{loading.value=false}} async function enter(exam){try{const token=deviceToken(exam.id);await examApi.enterExam(exam.id,token);router.push(`/examination/take/${exam.id}`)}catch(error){ElMessage.error(error?.message||"进入考试失败")}} 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((item)=>item.status===value).length} function day(value){return value?String(new Date(value).getDate()).padStart(2,"0"):"--"} function month(value){return value?`${new Date(value).getMonth()+1}月`:"—"} function formatTime(value){return value?new Date(value).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-"):"—"} function durationBetween(row){return row.startTime&&row.endTime?Math.max(0,Math.round((new Date(row.endTime)-new Date(row.startTime))/60000)):0} function countdown(value){const minutes=Math.max(0,Math.ceil((new Date(value)-Date.now())/60000));return minutes<60?`${minutes} 分钟后开始`:`${Math.ceil(minutes/60)} 小时后开始`}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

@ -7,7 +7,7 @@
<script setup> <script setup>
import { computed,onBeforeUnmount,onMounted,reactive,ref } from "vue"; import { useRoute,useRouter } from "vue-router"; import { ElMessage,ElMessageBox } from "element-plus"; import { Download,Upload } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam"; import { computed,onBeforeUnmount,onMounted,reactive,ref } from "vue"; import { useRoute,useRouter } from "vue-router"; import { ElMessage,ElMessageBox } from "element-plus"; import { Download,Upload } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam";
const route=useRoute(),router=useRouter(),examId=route.params.id,loading=ref(false),paper=ref({questions:[]}),currentId=ref(null),remaining=ref(0),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/60)).padStart(2,"0")} 分钟 ${String(remaining.value%60).padStart(2,"0")} 秒`); onMounted(load); onBeforeUnmount(()=>clearInterval(ticker)); const route=useRoute(),router=useRouter(),examId=route.params.id,loading=ref(false),paper=ref({questions:[]}),currentId=ref(null),remaining=ref(0),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/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])} function guardCopy(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault();ElMessage.warning("该考试不允许复制试题")} function guardContextMenu(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault()} function guardPaste(event){if(!paper.value.allowPasteAnswer){event.preventDefault();ElMessage.warning("该考试不允许粘贴答案")}} 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("/","-"):"—"} async function load(){loading.value=true;try{const token=deviceToken();paper.value=(await examApi.getActivePaper(examId,token)).data;const typeOrder={SINGLE:0,MULTIPLE:1,TRUE_FALSE:2,SHORT_ANSWER:3};paper.value.questions.sort((a,b)=>(typeOrder[a.type]??9)-(typeOrder[b.type]??9));paper.value.questions.forEach((q,i)=>{q.order=i+1;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])} function guardCopy(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault();ElMessage.warning("该考试不允许复制试题")} function guardContextMenu(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault()} function guardPaste(event){if(!paper.value.allowPasteAnswer){event.preventDefault();ElMessage.warning("该考试不允许粘贴答案")}} 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.take-page{--accent:#2563eb;--border:#e5e7eb;--muted:#6b7280;min-height:calc(100vh - 84px);background:#ffffff;color:#1f2937}.take-head{height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 28px;border-bottom:1px solid var(--border);background:#fff}.take-head h1{margin:0;font-size:17px}.take-head p{margin:4px 0 0;color:var(--muted);font-size:12px}.take-head>button{justify-self:end}.timer{display:grid;gap:2px;text-align:center;color:var(--muted);font-size:12px}.timer strong{color:var(--accent);font-size:18px;font-variant-numeric:tabular-nums}.timer.urgent strong{color:#c2413b}.exam-body{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;padding:20px 22px}.answer-sheet,.question-panel{border:1px solid var(--border);border-radius:14px;background:#fff}.answer-sheet{align-self:start;padding:16px;position:sticky;top:16px}.answer-sheet h2{margin:0 0 14px;font-size:14px}.sheet-group{margin-bottom:18px}.sheet-group h3{margin:0 0 10px;color:var(--muted);font-size:12px}.sheet-group>div{display:grid;grid-template-columns:repeat(5,1fr);gap:7px}.sheet-group button{aspect-ratio:1;border:1px solid var(--border);border-radius:6px;background:#fff;color:var(--muted);cursor:pointer}.sheet-group button.answered{border-color:#2563eb;color:#2563eb;background:#eff6ff}.sheet-group button.current{outline:2px solid var(--accent);outline-offset:2px}.answer-sheet footer{display:flex;gap:14px;color:var(--muted);font-size:11px}.answer-sheet i{display:inline-block;width:8px;height:8px;margin-right:4px;border:1px solid #d1d5db;border-radius:50%}.answer-sheet i.answered-dot{border-color:#2563eb;background:#2563eb}.question-panel{min-height:610px;padding:22px 28px 80px;position:relative}.question-panel>header{display:flex;justify-content:space-between;color:var(--muted);font-size:13px}.question-panel>header b{color:var(--accent)}.question-panel h2{margin:18px 0;color:#1f2937;font-size:17px;line-height:1.65}.material{display:inline-flex;gap:6px;margin-bottom:16px;color:var(--accent);font-size:12px;text-decoration:none}.choice-list{display:grid;gap:10px;width:100%}.question-panel:deep(.el-radio),.question-panel:deep(.el-checkbox){height:auto;margin:0;padding:13px 16px;border:1px solid var(--border);border-radius:8px;background:#fff}.question-panel:deep(.el-radio:hover),.question-panel:deep(.el-checkbox:hover),.question-panel:deep(.is-checked){border-color:var(--accent);background:#eff6ff}.question-panel:deep(.el-radio__label),.question-panel:deep(.el-checkbox__label){display:flex;gap:12px;color:#1f2937;font-size:13px;line-height:1.55}.judgement{grid-template-columns:1fr 1fr}.question-panel:deep(.el-textarea__inner){border-color:var(--border);border-radius:8px;box-shadow:none}.file-answer{display:flex;align-items:center;gap:12px;margin-top:12px;color:var(--muted);font-size:12px}.file-answer a{color:var(--accent)}.question-actions{position:absolute;right:28px;bottom:24px;left:28px;display:flex;justify-content:space-between}.take-page:deep(.el-button){height:40px;border-radius:10px;font-weight:700}.take-page:deep(.el-button--primary){border-color:var(--accent);background:var(--accent)}.take-page:deep(.el-button--danger.is-plain){border-color:rgba(217,85,76,.45);background:#fff;color:#d9554c}@media(max-width:760px){.take-head{grid-template-columns:1fr auto;height:auto;padding:10px 14px}.timer{grid-column:1/3;margin-top:8px}.exam-body{grid-template-columns:1fr;padding:12px}.answer-sheet{position:static}.question-panel{padding:20px 18px 78px}.question-actions{right:18px;left:18px}} .take-page{--accent:#2563eb;--border:#e5e7eb;--muted:#6b7280;min-height:calc(100vh - 84px);background:#ffffff;color:#1f2937}.take-head{height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 28px;border-bottom:1px solid var(--border);background:#fff}.take-head h1{margin:0;font-size:17px}.take-head p{margin:4px 0 0;color:var(--muted);font-size:12px}.take-head>button{justify-self:end}.timer{display:grid;gap:2px;text-align:center;color:var(--muted);font-size:12px}.timer strong{color:var(--accent);font-size:18px;font-variant-numeric:tabular-nums}.timer.urgent strong{color:#c2413b}.exam-body{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;padding:20px 22px}.answer-sheet,.question-panel{border:1px solid var(--border);border-radius:14px;background:#fff}.answer-sheet{align-self:start;padding:16px;position:sticky;top:16px}.answer-sheet h2{margin:0 0 14px;font-size:14px}.sheet-group{margin-bottom:18px}.sheet-group h3{margin:0 0 10px;color:var(--muted);font-size:12px}.sheet-group>div{display:grid;grid-template-columns:repeat(5,1fr);gap:7px}.sheet-group button{aspect-ratio:1;border:1px solid var(--border);border-radius:6px;background:#fff;color:var(--muted);cursor:pointer}.sheet-group button.answered{border-color:#2563eb;color:#2563eb;background:#eff6ff}.sheet-group button.current{outline:2px solid var(--accent);outline-offset:2px}.answer-sheet footer{display:flex;gap:14px;color:var(--muted);font-size:11px}.answer-sheet i{display:inline-block;width:8px;height:8px;margin-right:4px;border:1px solid #d1d5db;border-radius:50%}.answer-sheet i.answered-dot{border-color:#2563eb;background:#2563eb}.question-panel{min-height:610px;padding:22px 28px 80px;position:relative}.question-panel>header{display:flex;justify-content:space-between;color:var(--muted);font-size:13px}.question-panel>header b{color:var(--accent)}.question-panel h2{margin:18px 0;color:#1f2937;font-size:17px;line-height:1.65}.material{display:inline-flex;gap:6px;margin-bottom:16px;color:var(--accent);font-size:12px;text-decoration:none}.choice-list{display:grid;gap:10px;width:100%}.question-panel:deep(.el-radio),.question-panel:deep(.el-checkbox){height:auto;margin:0;padding:13px 16px;border:1px solid var(--border);border-radius:8px;background:#fff}.question-panel:deep(.el-radio:hover),.question-panel:deep(.el-checkbox:hover),.question-panel:deep(.is-checked){border-color:var(--accent);background:#eff6ff}.question-panel:deep(.el-radio__label),.question-panel:deep(.el-checkbox__label){display:flex;gap:12px;color:#1f2937;font-size:13px;line-height:1.55}.judgement{grid-template-columns:1fr 1fr}.question-panel:deep(.el-textarea__inner){border-color:var(--border);border-radius:8px;box-shadow:none}.file-answer{display:flex;align-items:center;gap:12px;margin-top:12px;color:var(--muted);font-size:12px}.file-answer a{color:var(--accent)}.question-actions{position:absolute;right:28px;bottom:24px;left:28px;display:flex;justify-content:space-between}.take-page:deep(.el-button){height:40px;border-radius:10px;font-weight:700}.take-page:deep(.el-button--primary){border-color:var(--accent);background:var(--accent)}.take-page:deep(.el-button--danger.is-plain){border-color:rgba(217,85,76,.45);background:#fff;color:#d9554c}@media(max-width:760px){.take-head{grid-template-columns:1fr auto;height:auto;padding:10px 14px}.timer{grid-column:1/3;margin-top:8px}.exam-body{grid-template-columns:1fr;padding:12px}.answer-sheet{position:static}.question-panel{padding:20px 18px 78px}.question-actions{right:18px;left:18px}}

@ -2,7 +2,8 @@
<main class="question-bank app-container"> <main class="question-bank app-container">
<header class="page-head"><div><h1>题库管理</h1><p>维护平台公共题库,供教师组卷使用</p></div><div class="actions"><el-button type="primary" :icon="Plus" @click="openEditor()">添加题目</el-button><el-button type="success" :icon="Upload" :loading="loading" @click="pickImport">批量导入</el-button><el-dropdown trigger="click" @command="downloadTemplate"><el-button type="success" :icon="Download">下载模板<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="word">Word 导入模板</el-dropdown-item><el-dropdown-item command="excel">Excel 导入模板</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></header> <header class="page-head"><div><h1>题库管理</h1><p>维护平台公共题库,供教师组卷使用</p></div><div class="actions"><el-button type="primary" :icon="Plus" @click="openEditor()">添加题目</el-button><el-button type="success" :icon="Upload" :loading="loading" @click="pickImport">批量导入</el-button><el-dropdown trigger="click" @command="downloadTemplate"><el-button type="success" :icon="Download">下载模板<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="word">Word 导入模板</el-dropdown-item><el-dropdown-item command="excel">Excel 导入模板</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></header>
<section class="toolbar"><el-select v-model="query.type" clearable placeholder="题型:请选择" @change="load"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.keyword" placeholder="请输入题干关键词" clearable @keyup.enter="load" /><el-button type="primary" :icon="Search" @click="load">搜索</el-button><input ref="fileInput" type="file" accept=".docx,.xlsx,.xls" hidden @change="importFile" /></section> <section class="toolbar"><el-select v-model="query.type" clearable placeholder="题型:请选择" @change="load"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.keyword" placeholder="请输入题干关键词" clearable @keyup.enter="load" /><el-button type="primary" :icon="Search" @click="load">搜索</el-button><input ref="fileInput" type="file" accept=".docx,.xlsx,.xls" hidden @change="importFile" /></section>
<el-table v-loading="loading" :data="questions" class="data-table"><el-table-column type="index" label="序号" width="78" /><el-table-column label="题干" min-width="420" show-overflow-tooltip><template #default="{ row }"><span>{{ plainHtml(row.stem) }}</span></template></el-table-column><el-table-column label="题型" width="120"><template #default="{ row }"><el-tag>{{ typeName[row.type] }}</el-tag></template></el-table-column><el-table-column label="题目来源" width="140"><template #default><el-tag type="success">平台题库</el-tag></template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row)">编辑</el-button><el-button link type="primary" @click="openEditor(row)">查看</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty description="暂无题目" /></template></el-table> <el-table v-loading="loading" :data="pagedQuestions" class="data-table"><el-table-column type="index" label="序号" width="78" /><el-table-column label="题干" min-width="420" show-overflow-tooltip><template #default="{ row }"><span>{{ plainHtml(row.stem) }}</span></template></el-table-column><el-table-column label="题型" width="120"><template #default="{ row }"><el-tag>{{ typeName[row.type] }}</el-tag></template></el-table-column><el-table-column label="题目来源" width="140"><template #default><el-tag type="success">平台题库</el-tag></template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row)">编辑</el-button><el-button link type="primary" @click="openEditor(row)">查看</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty description="暂无题目" /></template></el-table>
<div class="pagination-wrap"><el-pagination v-model:current-page="pagination.currentPage" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" background /></div>
<p class="bank-note">说明:平台题库为系统默认题库,所有教师可见;教师仅可查看平台题目,编辑删除自己新增的题目。</p> <p class="bank-note">说明:平台题库为系统默认题库,所有教师可见;教师仅可查看平台题目,编辑删除自己新增的题目。</p>
<el-dialog v-model="editorVisible" :title="form.id ? '编辑题目' : '新增题目'" width="min(1180px, 94vw)" top="4vh" class="question-editor-dialog" destroy-on-close> <el-dialog v-model="editorVisible" :title="form.id ? '编辑题目' : '新增题目'" width="min(1180px, 94vw)" top="4vh" class="question-editor-dialog" destroy-on-close>
@ -11,7 +12,7 @@
<div class="rich-row"><label>题干</label><Editor v-model="form.stem" :min-height="154" /></div> <div class="rich-row"><label>题干</label><Editor v-model="form.stem" :min-height="154" /></div>
<template v-if="isChoice"><div class="option-block"><label>选项</label><div class="option-list"><div v-for="(option, index) in form.options" :key="index" class="option-row"><div class="option-letter"><el-radio v-if="form.type === 'SINGLE'" v-model="singleAnswer" :label="optionLabel(index)" class="answer-control" /><el-checkbox v-else v-model="answers" :label="optionLabel(index)" class="answer-control" /><span class="letter-tag">{{ optionLabel(index) }}</span></div><Editor v-model="option.content" :min-height="92" /><el-button v-if="form.options.length > 2" link type="danger" :icon="Delete" @click="removeOption(index)" class="remove-btn" /></div><el-button type="primary" plain :icon="Plus" @click="addOption" class="add-option-btn">新增选项</el-button></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template> <template v-if="isChoice"><div class="option-block"><label>选项</label><div class="option-list"><div v-for="(option, index) in form.options" :key="index" class="option-row"><div class="option-letter"><el-radio v-if="form.type === 'SINGLE'" v-model="singleAnswer" :label="optionLabel(index)" class="answer-control" /><el-checkbox v-else v-model="answers" :label="optionLabel(index)" class="answer-control" /><span class="letter-tag">{{ optionLabel(index) }}</span></div><Editor v-model="option.content" :min-height="92" /><el-button v-if="form.options.length > 2" link type="danger" :icon="Delete" @click="removeOption(index)" class="remove-btn" /></div><el-button type="primary" plain :icon="Plus" @click="addOption" class="add-option-btn">新增选项</el-button></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-else-if="form.type === 'TRUE_FALSE'"><div class="tf-block"><label>选项</label><div class="tf-options"><el-radio-group v-model="form.answer" class="tf-radio-group"><el-radio-button :label="true" class="tf-radio">对</el-radio-button><el-radio-button :label="false" class="tf-radio">错</el-radio-button></el-radio-group></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template> <template v-else-if="form.type === 'TRUE_FALSE'"><div class="tf-block"><label>选项</label><div class="tf-options"><el-radio-group v-model="form.answer" class="tf-radio-group"><el-radio-button :label="true" class="tf-radio">对</el-radio-button><el-radio-button :label="false" class="tf-radio">错</el-radio-button></el-radio-group></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-else><div class="material-row"><el-upload :show-file-list="false" :http-request="uploadMaterial" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"><el-button type="primary" plain :loading="uploading">上传简答题资料</el-button></el-upload><span v-if="form.attachmentName" class="material-name">{{ form.attachmentName }}</span></div><div class="rich-row"><label>参考答案</label><Editor v-model="form.answer" :min-height="180" /></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template> <template v-else><div class="material-row"><el-upload :show-file-list="false" :http-request="uploadMaterial" accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"><el-button type="primary" plain :loading="uploading">上传简答题资料</el-button></el-upload><span v-if="form.attachmentName" class="material-name">{{ form.attachmentName }}</span></div><div class="rich-row"><label>参考答案</label><Editor v-model="form.answer" :min-height="180" /></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
</div><template #footer><el-button @click="editorVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></template> </div><template #footer><el-button @click="editorVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></template>
</el-dialog> </el-dialog>
@ -29,11 +30,11 @@ import * as api from "@/api/exam";
const types = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }]; const types = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
const typeName = Object.fromEntries(types.map((item) => [item.value, item.label])); const typeName = Object.fromEntries(types.map((item) => [item.value, item.label]));
const loading = ref(false); const saving = ref(false); const uploading = ref(false); const aiLoading = ref(false); const questions = ref([]); const editorVisible = ref(false); const aiVisible = ref(false); const preview = ref([]); const fileInput = ref(); const loading = ref(false); const saving = ref(false); const uploading = ref(false); const aiLoading = ref(false); const questions = ref([]); const editorVisible = ref(false); const aiVisible = ref(false); const preview = ref([]); const fileInput = ref();
const query = reactive({ type: "", taskKey: "", keyword: "" }); const form = reactive(emptyQuestion()); const aiForm = reactive({ taskKey: "", type: "SINGLE", count: 5, requirement: "" }); const query = reactive({ type: "", taskKey: "", keyword: "" }); const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 }); const form = reactive(emptyQuestion()); const aiForm = reactive({ taskKey: "", type: "SINGLE", count: 5, requirement: "" });
const isChoice = computed(() => ["SINGLE", "MULTIPLE"].includes(form.type)); const answers = computed({ get: () => Array.isArray(form.answer) ? form.answer : [], set: (value) => { form.answer = value; } }); const singleAnswer = computed({ get: () => Array.isArray(form.answer) ? form.answer[0] || "" : "", set: (value) => { form.answer = value ? [value] : []; } }); const isChoice = computed(() => ["SINGLE", "MULTIPLE"].includes(form.type)); const pagedQuestions = computed(() => { const start = (pagination.currentPage - 1) * pagination.pageSize; return questions.value.slice(start, start + pagination.pageSize); }); const answers = computed({ get: () => Array.isArray(form.answer) ? form.answer : [], set: (value) => { form.answer = value; } }); const singleAnswer = computed({ get: () => Array.isArray(form.answer) ? form.answer[0] || "" : "", set: (value) => { form.answer = value ? [value] : []; } });
function emptyQuestion() { return { id: null, taskKey: "", type: "SINGLE", stem: "<p></p>", options: ["A", "B", "C", "D"].map((label) => ({ label, content: "<p></p>" })), answer: [], analysis: "<p></p>", attachmentName: "", attachmentUrl: "" }; } function emptyQuestion() { return { id: null, taskKey: "", type: "SINGLE", stem: "<p></p>", options: ["A", "B", "C", "D"].map((label) => ({ label, content: "<p></p>" })), answer: [], analysis: "<p></p>", attachmentName: "", attachmentUrl: "" }; }
function plainHtml(value) { return String(value || "").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " "); } function plainHtml(value) { return String(value || "").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " "); }
async function load() { loading.value = true; try { questions.value = (await api.getPlatformQuestions(query)).data || []; } finally { loading.value = false; } } async function init() { await load(); } onMounted(init); async function load() { loading.value = true; try { questions.value = (await api.getPlatformQuestions(query)).data || []; pagination.total = questions.value.length; pagination.currentPage = 1; } finally { loading.value = false; } } async function init() { await load(); } onMounted(init);
function openEditor(row) { Object.assign(form, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(form.answer) && isChoice.value) form.answer = []; editorVisible.value = true; } function openAi() { aiForm.taskKey = ""; preview.value = []; aiVisible.value = true; } function openEditor(row) { Object.assign(form, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(form.answer) && isChoice.value) form.answer = []; editorVisible.value = true; } function openAi() { aiForm.taskKey = ""; preview.value = []; aiVisible.value = true; }
function optionLabel(index) { return String.fromCharCode(65 + index); } function optionLabel(index) { return String.fromCharCode(65 + index); }
function addOption() { if (form.options.length >= 26) return ElMessage.warning("最多支持 26 个选项"); form.options.push({ label: optionLabel(form.options.length), content: "<p></p>" }); } function addOption() { if (form.options.length >= 26) return ElMessage.warning("最多支持 26 个选项"); form.options.push({ label: optionLabel(form.options.length), content: "<p></p>" }); }
@ -53,6 +54,6 @@ function displayAnswer(value) { return Array.isArray(value) ? value.join("、")
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 18px; } .page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 18px; }
.page-head h1 { margin: 0; font-size: 22px; font-weight: 700; color: #1f2937; }.page-head p { margin: 7px 0 0; color: #6b7280; font-size: 13px; }.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; } .page-head h1 { margin: 0; font-size: 22px; font-weight: 700; color: #1f2937; }.page-head p { margin: 7px 0 0; color: #6b7280; font-size: 13px; }.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 16px; border: 1px solid #e5e7eb; border-radius: 10px; background: #ffffff; }.toolbar .el-select { width: 210px; }.toolbar .el-input { width: min(300px, 30vw); } .toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 16px; border: 1px solid #e5e7eb; border-radius: 10px; background: #ffffff; }.toolbar .el-select { width: 210px; }.toolbar .el-input { width: min(300px, 30vw); }
.data-table { background: #ffffff; border-radius: 10px; }.bank-note { margin: 14px auto 0; max-width: 1180px; color: #6b7280; font-size: 13px; line-height: 1.8; text-align: center; } .data-table { background: #ffffff; border-radius: 10px; }.pagination-wrap { display: flex; justify-content: flex-end; margin-top: 16px; }.bank-note { margin: 14px auto 0; max-width: 1180px; color: #6b7280; font-size: 13px; line-height: 1.8; text-align: center; }
.editor-shell { padding: 4px 8px 12px; color: #1f2937; }.editor-project { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.editor-project > span { width: 62px; color: #4b5563; }.editor-project .el-select { width: 420px; }.type-switch { display: flex; align-items: center; gap: 12px; margin: 4px 0 22px; }.type-switch > span { width: 62px; color: #4b5563; font-size: 15px; text-align: right; }.type-select { width: 200px; }.rich-row, .option-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin-bottom: 22px; align-items: start; }.rich-row > label, .option-block > label, .tf-block > label { padding-top: 10px; color: #4b5563; font-size: 15px; text-align: right; }.option-list { display: grid; gap: 12px; }.option-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 36px; align-items: start; gap: 10px; }.option-letter { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }.option-letter .answer-control { margin-right: 0; }.letter-tag { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; background: #eff6ff; color: #2563eb; font-size: 13px; font-weight: 600; border: 1px solid #bfdbfe; }.remove-btn { padding-top: 16px; color: #9ca3af; }.add-option-btn { justify-self: start; margin-top: 4px; }.tf-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin: 18px 0 30px; align-items: center; }.tf-options { display: flex; gap: 16px; }.tf-radio-group { display: flex; gap: 16px; }.tf-radio { padding: 10px 36px; font-size: 15px; border-radius: 8px; }.material-row { display: flex; align-items: center; gap: 14px; margin: 6px 0 22px 132px; }.material-name { color: #6b7280; }.preview-alert { margin-bottom: 12px; }.preview-item { padding: 10px 4px; border-bottom: 1px solid #e5e7eb; line-height: 1.7; color: #1f2937; }.editor-shell :deep(.ql-container) { min-height: 88px; font-size: 15px; color: #1f2937; }.editor-shell :deep(.ql-editor) { min-height: 74px; color: #1f2937; }.editor-shell :deep(.ql-toolbar) { background: #fff; border-color: #e5e7eb; }.editor-shell :deep(.ql-editor.ql-blank::before) { color: #9ca3af; font-style: normal; } .editor-shell { padding: 4px 8px 12px; color: #1f2937; }.editor-project { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.editor-project > span { width: 62px; color: #4b5563; }.editor-project .el-select { width: 420px; }.type-switch { display: flex; align-items: center; gap: 12px; margin: 4px 0 22px; }.type-switch > span { width: 62px; color: #4b5563; font-size: 15px; text-align: right; }.type-select { width: 200px; }.rich-row, .option-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin-bottom: 22px; align-items: start; }.rich-row > label, .option-block > label, .tf-block > label { padding-top: 10px; color: #4b5563; font-size: 15px; text-align: right; }.option-list { display: grid; gap: 12px; }.option-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 36px; align-items: start; gap: 10px; }.option-letter { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }.option-letter .answer-control { margin-right: 0; }.letter-tag { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; background: #eff6ff; color: #2563eb; font-size: 13px; font-weight: 600; border: 1px solid #bfdbfe; }.remove-btn { padding-top: 16px; color: #9ca3af; }.add-option-btn { justify-self: start; margin-top: 4px; }.tf-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin: 18px 0 30px; align-items: center; }.tf-options { display: flex; gap: 16px; }.tf-radio-group { display: flex; gap: 16px; }.tf-radio { padding: 10px 36px; font-size: 15px; border-radius: 8px; }.material-row { display: flex; align-items: center; gap: 14px; margin: 6px 0 22px 132px; }.material-name { color: #6b7280; }.preview-alert { margin-bottom: 12px; }.preview-item { padding: 10px 4px; border-bottom: 1px solid #e5e7eb; line-height: 1.7; color: #1f2937; }.editor-shell :deep(.ql-container) { min-height: 88px; font-size: 15px; color: #1f2937; }.editor-shell :deep(.ql-editor) { min-height: 74px; color: #1f2937; }.editor-shell :deep(.ql-toolbar) { background: #fff; border-color: #e5e7eb; }.editor-shell :deep(.ql-editor.ql-blank::before) { color: #9ca3af; font-style: normal; }
</style> </style>

@ -193,7 +193,7 @@
</div> </div>
</section> </section>
<footer v-if="!inProgress" class="editor-footer"><el-button :icon="Close" @click="$router.push('/exam/index')">取消</el-button><el-button :icon="Check" @click="save(false)">保存考试</el-button><el-button v-if="status === 'DRAFT'" type="primary" :icon="Promotion" @click="save(true)">立即发布</el-button></footer> <footer class="editor-footer"><el-button :icon="Close" @click="$router.push('/exam/index')">取消</el-button><el-button :icon="Check" @click="save(false)">保存考试</el-button><el-button v-if="status === 'DRAFT'" type="primary" :icon="Promotion" @click="save(true)">立即发布</el-button></footer>
</main> </main>
</template> </template>
@ -1048,7 +1048,7 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
</style> </style>
<style lang="scss"> <style lang="scss">
#app .teacherLayout .editor-footer { left: 304px; } #app .teacherLayout .editor-footer { left: calc(var(--teacher-sidebar-width, 180px) + 1px); }
#app .teacherLayout.hideSidebar .editor-footer { left: 64px; } #app .teacherLayout.hideSidebar .editor-footer { left: 64px; }
#app .mobile.teacherLayout .editor-footer { left: 0; } #app .mobile.teacherLayout .editor-footer { left: 0; }
</style> </style>

@ -15,6 +15,7 @@
<div class="assessment-actions"> <div class="assessment-actions">
<el-button v-if="row.status === 'NOT_STARTED'" text type="primary" :icon="RefreshLeft" @click="withdraw(row)">撤回</el-button> <el-button v-if="row.status === 'NOT_STARTED'" text type="primary" :icon="RefreshLeft" @click="withdraw(row)">撤回</el-button>
<el-button v-if="['DRAFT','IN_PROGRESS'].includes(row.status)" text type="primary" :icon="Edit" @click="$router.push(`/exam/manage/${row.id}`)">编辑</el-button> <el-button v-if="['DRAFT','IN_PROGRESS'].includes(row.status)" text type="primary" :icon="Edit" @click="$router.push(`/exam/manage/${row.id}`)">编辑</el-button>
<el-button v-if="row.status === 'DRAFT'" text type="danger" :icon="Delete" @click="removeExam(row)">删除</el-button>
<el-button v-if="row.status === 'IN_PROGRESS'" text type="primary" :icon="RefreshLeft" @click="$router.push(`/exam/review/${row.id}`)">指定人回退</el-button> <el-button v-if="row.status === 'IN_PROGRESS'" text type="primary" :icon="RefreshLeft" @click="$router.push(`/exam/review/${row.id}`)">指定人回退</el-button>
<template v-if="row.status === 'ENDED'"><el-button text type="primary" :icon="EditPen" @click="$router.push(`/exam/review/${row.id}`)">评阅</el-button><el-button text type="primary" :icon="DataAnalysis" @click="$router.push(`/exam/analysis/${row.id}`)">分析</el-button><el-dropdown @command="(command) => handleExport(command, row)"><el-button text type="primary" :icon="Download">导出<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="paper">空白试卷</el-dropdown-item><el-dropdown-item command="scores">班级成绩</el-dropdown-item><el-dropdown-item command="submissions">全部答卷</el-dropdown-item></el-dropdown-menu></template></el-dropdown><el-button text type="danger" :icon="Delete" @click="removeExam(row)">删除</el-button></template> <template v-if="row.status === 'ENDED'"><el-button text type="primary" :icon="EditPen" @click="$router.push(`/exam/review/${row.id}`)">评阅</el-button><el-button text type="primary" :icon="DataAnalysis" @click="$router.push(`/exam/analysis/${row.id}`)">分析</el-button><el-dropdown @command="(command) => handleExport(command, row)"><el-button text type="primary" :icon="Download">导出<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="paper">空白试卷</el-dropdown-item><el-dropdown-item command="scores">班级成绩</el-dropdown-item><el-dropdown-item command="submissions">全部答卷</el-dropdown-item></el-dropdown-menu></template></el-dropdown><el-button text type="danger" :icon="Delete" @click="removeExam(row)">删除</el-button></template>
</div> </div>
@ -28,6 +29,10 @@
<el-select v-model="questionQuery.type" placeholder="全部题型" clearable @change="loadQuestions"> <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-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
<el-select v-model="sourceFilter" placeholder="全部来源" clearable @change="resetQuestionPager" style="width: 140px">
<el-option label="平台题库" value="PLATFORM" />
<el-option label="教师自建" value="CUSTOM" />
</el-select>
<!-- 全部项目筛选暂时隐藏 --> <!-- 全部项目筛选暂时隐藏 -->
<el-select v-if="false" v-model="questionQuery.taskKey" placeholder="全部项目" clearable @change="loadQuestions" style="width: 190px"> <el-select v-if="false" v-model="questionQuery.taskKey" placeholder="全部项目" clearable @change="loadQuestions" style="width: 190px">
<el-option v-for="item in projectOptions" :key="item.taskKey" :label="`${item.projectName} / ${item.taskName}`" :value="item.taskKey" /> <el-option v-for="item in projectOptions" :key="item.taskKey" :label="`${item.projectName} / ${item.taskName}`" :value="item.taskKey" />
@ -79,10 +84,10 @@
</div> </div>
</template> </template>
</el-table> </el-table>
<div v-if="questions.length" class="question-pager"> <div v-if="filteredQuestions.length" class="question-pager">
<el-pagination <el-pagination
layout="total, prev, pager, next, sizes, jumper" layout="total, prev, pager, next, sizes, jumper"
:total="questions.length" :total="filteredQuestions.length"
v-model:current-page="questionPager.page" v-model:current-page="questionPager.page"
v-model:page-size="questionPager.pageSize" v-model:page-size="questionPager.pageSize"
:page-sizes="[10, 20, 50, 100]" :page-sizes="[10, 20, 50, 100]"
@ -112,7 +117,7 @@
<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-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-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-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> <el-upload :show-file-list="false" :http-request="uploadMaterial" accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"><el-button :icon="Paperclip">上传资料</el-button></el-upload>
<span v-if="questionForm.attachmentName" class="file-name">{{ questionForm.attachmentName }}</span> <span v-if="questionForm.attachmentName" class="file-name">{{ questionForm.attachmentName }}</span>
</el-form-item> </el-form-item>
<el-form-item label="答案解析"><el-input v-model="questionForm.analysis" type="textarea" :rows="3" /></el-form-item> <el-form-item label="答案解析"><el-input v-model="questionForm.analysis" type="textarea" :rows="3" /></el-form-item>
@ -141,11 +146,17 @@ const typeName = Object.fromEntries(typeOptions.map((item) => [item.value, item.
const typeTone = { SINGLE: "", MULTIPLE: "success", TRUE_FALSE: "warning", SHORT_ANSWER: "info" }; 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 statusMeta = { DRAFT: { label: "未发布", tone: "info" }, NOT_STARTED: { label: "未开始", tone: "" }, IN_PROGRESS: { label: "进行中", tone: "success" }, ENDED: { label: "已结束", tone: "warning" } };
const questionQuery = reactive({ type: "", taskKey: "", keyword: "" }); const questionQuery = reactive({ type: "", taskKey: "", keyword: "" });
const sourceFilter = ref(""); // 题目来源前端筛选:PLATFORM=平台题库 / CUSTOM=教师自建(非 PLATFORM)
// 题库列表使用前端分页:后端一次返回全部,前端按页切片 // 题库列表使用前端分页:后端一次返回全部,前端按页切片
const questionPager = reactive({ page: 1, pageSize: 10 }); const questionPager = reactive({ page: 1, pageSize: 10 });
const filteredQuestions = computed(() => {
const st = sourceFilter.value;
if (!st) return questions.value;
return questions.value.filter(q => st === "PLATFORM" ? q.sourceType === "PLATFORM" : q.sourceType !== "PLATFORM");
});
const pagedQuestions = computed(() => { const pagedQuestions = computed(() => {
const start = (questionPager.page - 1) * questionPager.pageSize; const start = (questionPager.page - 1) * questionPager.pageSize;
return questions.value.slice(start, start + questionPager.pageSize); return filteredQuestions.value.slice(start, start + questionPager.pageSize);
}); });
const emptyQuestion = () => ({ id: null, taskKey: "", type: "SINGLE", stem: "", options: [{ label: "A", content: "" }, { label: "B", content: "" }, { label: "C", content: "" }, { label: "D", content: "" }], answer: [], analysis: "", attachmentName: "", attachmentUrl: "" }); const emptyQuestion = () => ({ id: null, taskKey: "", 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 questionForm = reactive(emptyQuestion());
@ -158,6 +169,7 @@ let examsMounted = false; // KeepAlive 缓存页面:再次进入时刷新考
onActivated(() => { if (examsMounted) loadExams(); examsMounted = true; }); onActivated(() => { if (examsMounted) loadExams(); examsMounted = true; });
async function loadProjects() { try { projectOptions.value = (await examApi.getQuestionProjects()).data || []; } catch (e) { projectOptions.value = []; } } async function loadProjects() { try { projectOptions.value = (await examApi.getQuestionProjects()).data || []; } catch (e) { projectOptions.value = []; } }
async function loadQuestions() { loading.value = true; try { questionPager.page = 1; questions.value = (await examApi.getQuestions(questionQuery)).data || []; } finally { loading.value = false; } } async function loadQuestions() { loading.value = true; try { questionPager.page = 1; questions.value = (await examApi.getQuestions(questionQuery)).data || []; } finally { loading.value = false; } }
function resetQuestionPager() { questionPager.page = 1; }
async function loadExams() { loading.value = true; try { exams.value = (await examApi.getTeacherExams({ keyword: examKeyword.value })).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) { questionReadonly.value = row?.sourceType === 'PLATFORM'; Object.assign(questionForm, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(questionForm.answer) && isChoice.value) questionForm.answer = []; questionDialog.value = true; } function openQuestion(row) { questionReadonly.value = row?.sourceType === 'PLATFORM'; Object.assign(questionForm, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(questionForm.answer) && isChoice.value) questionForm.answer = []; questionDialog.value = true; }
function openAi() { router.push('/exam/ai-generate'); } function openAi() { router.push('/exam/ai-generate'); }
@ -175,7 +187,7 @@ async function doImport({ file }) { importing.value = true; try { const form = n
async function downloadTemplate(format) { const extension = format === "excel" ? "xlsx" : "docx"; saveAs(await examApi.downloadQuestionTemplate(format), `考试题库${format === "excel" ? "Excel" : "Word"}导入模板.${extension}`); } async function downloadTemplate(format) { const extension = format === "excel" ? "xlsx" : "docx"; saveAs(await examApi.downloadQuestionTemplate(format), `考试题库${format === "excel" ? "Excel" : "Word"}导入模板.${extension}`); }
async function publish(row) { await ElMessageBox.confirm(`确认发布“${row.name}”?发布后将在设定时间自动开始。`, "发布考试", { type: "warning" }); await examApi.publishExam(row.id); ElMessage.success("已发布"); loadExams(); } async function 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 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 removeExam(row) { const message = row.status === "DRAFT" ? `确认删除未发布的考试“${row.name}”?删除后无法恢复。` : `确认永久删除“${row.name}”?仅在已导出全部答卷后允许删除。`; await ElMessageBox.confirm(message, "删除考试", { 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(); } 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 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("/", "-") : "-"; } function formatTime(value) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }).replaceAll("/", "-") : "-"; }

Loading…
Cancel
Save