feat: complete exam and teacher page updates

fix/exam-question-access-material-excel-delete-draft
chenyuan 2 weeks ago
parent 684b337501
commit f8174e5a0c

@ -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/result/${examId}`)} 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;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("/","-"):"—"}
</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}}

@ -4,7 +4,7 @@
<section class="metrics"><div v-for="item in metrics" :key="item.label"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div></section> <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"> <section class="analysis-grid">
<div class="chart-block"><h2>成绩分布</h2><div ref="chartEl" class="chart" /></div> <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><div v-if="!data.hardestQuestions?.length" class="table-empty"><el-icon class="empty-icon"><FolderOpened /></el-icon><p class="empty-title">暂无客观题错题数据</p><p class="empty-tip">考试结束后将自动统计答错率最高的题目</p></div></div> <div class="hardest"><h2>得分率最低题目</h2><ol><li v-for="item in data.hardestQuestions || []" :key="item.id"><div>{{ item.stem }}</div><span>得分率 {{ item.scoreRate ?? 0 }}% · {{ item.wrongCount }} 人失分</span></li></ol><div v-if="!data.hardestQuestions?.length" class="table-empty"><el-icon class="empty-icon"><FolderOpened /></el-icon><p class="empty-title">暂无题目得分数据</p><p class="empty-tip">考试结束后将自动统计得分率最低的题目</p></div></div>
</section> </section>
</main> </main>
</template> </template>

@ -8,22 +8,14 @@
</div> </div>
</header> </header>
<nav v-if="!inProgress" class="prototype-nav" aria-label="考试编辑步骤"> <section class="editor-panel prototype-base-panel">
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 0 }" @click="activeStep = 0"><span class="step-num">01</span>考试设置</button>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 1 }" @click="activeStep = 1"><span class="step-num">02</span>添加试题</button>
<el-icon class="step-arrow"><ArrowRight /></el-icon>
<button type="button" class="step-node" :class="{ 'is-active': activeStep === 2 }" @click="activeStep = 2"><span class="step-num">03</span>试卷预览</button>
</nav>
<section v-if="activeStep === 0 || activeStep === 1" class="editor-panel prototype-base-panel">
<div class="section-card"> <div class="section-card">
<header class="card-head"><div><h2>基础设置</h2><p>填写考试名称、时间与班级,分值矩阵自动汇总</p></div></header> <header class="card-head"><div><h2>基础设置</h2><p>填写考试名称、时间与班级,分值矩阵自动汇总</p></div></header>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="base-form"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" 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="name"><el-input v-model="form.name" maxlength="20" show-word-limit :disabled="inProgress" /></el-form-item>
<div class="form-row"> <div class="form-row">
<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="开始时间" 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="结束时间" 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="endTime"><el-date-picker v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" @change="syncDurationFromEnd" /></el-form-item>
</div> </div>
<div class="form-row"> <div class="form-row">
<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="考试时长"><el-input-number v-model="duration" :min="1" :max="1440" @change="syncEndTime" /><span class="unit">分钟</span></el-form-item>
@ -32,23 +24,37 @@
</el-form-item> </el-form-item>
</div> </div>
</el-form> </el-form>
<div class="score-cards"> <div class="score-table-wrap">
<div v-for="item in typeOptions" :key="item.value" class="score-card"> <table class="score-table">
<div class="score-card-bar"></div> <thead><tr><th>题型</th><th>题库题目数量</th><th>选题数量</th><th>每题分值</th><th>小计</th></tr></thead>
<div class="score-card-body"> <tbody>
<div class="score-card-label">{{ item.label }}</div> <tr v-for="item in typeOptions" :key="item.value">
<div class="score-card-nums"><span class="num-picked">{{ selectedByType(item.value) }}</span><span class="num-sep">/</span><span class="num-total">{{ questionCount(item.value) }}</span></div> <td>{{ item.label }}</td>
<div class="score-card-sub">{{ scoreByType(item.value) }} 分</div> <td>{{ questionCount(item.value) }}</td>
</div> <td>{{ selectedByType(item.value) }}</td>
</div> <td>
<div class="score-card score-card-total"> <el-input-number
<div class="score-card-bar bar-total"></div> class="type-score-input"
<div class="score-card-body"> :model-value="scorePerQuestion(item.value)"
<div class="score-card-label">总分</div> :min="0.5"
<div class="score-card-nums"><span class="num-picked">{{ totalScore }}</span></div> :precision="1"
<div class="score-card-sub">自动合计</div> :step="0.5"
</div> controls-position="right"
</div> size="small"
@change="setTypeScore(item.value, $event)"
/>
</td>
<td>{{ scoreByType(item.value) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!inProgress" class="fixed-step-actions" aria-label="考试编辑步骤">
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 1 }" @click="activeStep = 1">添加试题</button>
<span class="fixed-step-arrow">&gt;&gt;&gt;</span>
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 2 }" @click="activeStep = 2">试卷概览</button>
<span class="fixed-step-arrow">&gt;&gt;&gt;</span>
<button type="button" class="fixed-step-button" :class="{ 'is-active': activeStep === 0 }" @click="activeStep = 0">考试设置</button>
</div> </div>
</div> </div>
</section> </section>
@ -150,7 +156,7 @@
<section v-if="!inProgress && activeStep === 2" class="editor-panel preview-stage"> <section v-if="!inProgress && activeStep === 2" class="editor-panel preview-stage">
<div class="section-card preview-summary-card"> <div class="section-card preview-summary-card">
<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> <header class="section-head"><div><h2>试卷概览</h2><p>{{ form.questions.length }} 道题,共 {{ totalScore }} 分</p></div><div class="preview-actions"><el-upload :show-file-list="false" accept=".docx" :http-request="importPaperQuestions"><el-button :icon="Upload">导入 Word 试卷</el-button></el-upload><el-button :icon="Download" @click="exportPaper" :disabled="isNew">导出 Word 试卷</el-button></div></header>
<div class="score-cards preview-score-cards"> <div class="score-cards preview-score-cards">
<div v-for="item in typeOptions" :key="item.value" class="score-card" :class="{ 'score-card-zero': !selectedByType(item.value) }"> <div v-for="item in typeOptions" :key="item.value" class="score-card" :class="{ 'score-card-zero': !selectedByType(item.value) }">
<div class="score-card-bar"></div> <div class="score-card-bar"></div>
@ -190,6 +196,7 @@
</div> </div>
</div> </div>
<span class="pq-score">{{ selectedScore(question.id) }} 分</span> <span class="pq-score">{{ selectedScore(question.id) }} 分</span>
<el-button class="pq-remove" text type="danger" @click.stop="removeQuestionFromPreview(question)">取消选择</el-button>
</article> </article>
</div> </div>
</section> </section>
@ -214,7 +221,8 @@ import { saveAs } from "file-saver";
import * as examApi from "@/api/exam"; 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 route = useRoute(); const router = useRouter(); const id = computed(() => route.params.id); const isNew = computed(() => id.value === "new");
const loading = ref(false); const activeStep = ref(1); const activeType = ref("SINGLE"); const status = ref("DRAFT"); const classes = ref([]); const bank = ref([]); const formRef = ref(); const duration = ref(60); 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 typeScores = reactive({ SINGLE: 1, MULTIPLE: 1, TRUE_FALSE: 1, SHORT_ANSWER: 1 });
const typeOptions = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }]; 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 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: [], questionOrderMode: "FIXED", optionOrderMode: "FIXED", allowCopyQuestion: false, allowPasteAnswer: false, allowRollback: true, multipleScoreMode: "ALL_OR_NOTHING", questions: [] }); const form = reactive({ name: "", startTime: "", endTime: "", classIds: [], questionOrderMode: "FIXED", optionOrderMode: "FIXED", allowCopyQuestion: false, allowPasteAnswer: false, allowRollback: true, multipleScoreMode: "ALL_OR_NOTHING", questions: [] });
@ -225,19 +233,23 @@ const totalScore = computed(() => form.questions.reduce((sum, item) => sum + Num
const selectedTypes = computed(() => typeOptions.filter((item) => selectedByType(item.value))); const selectedTypes = computed(() => typeOptions.filter((item) => selectedByType(item.value)));
onMounted(load); 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), questionOrderMode: data.questionOrderMode || "FIXED", optionOrderMode: data.optionOrderMode || "FIXED", allowCopyQuestion: toBoolean(data.allowCopyQuestion, false), allowPasteAnswer: toBoolean(data.allowPasteAnswer, false), allowRollback: toBoolean(data.allowRollback, true), multipleScoreMode: data.multipleScoreMode || "ALL_OR_NOTHING", 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; } } 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), questionOrderMode: data.questionOrderMode || "FIXED", optionOrderMode: data.optionOrderMode || "FIXED", allowCopyQuestion: toBoolean(data.allowCopyQuestion, false), allowPasteAnswer: toBoolean(data.allowPasteAnswer, false), allowRollback: toBoolean(data.allowRollback, true), multipleScoreMode: data.multipleScoreMode || "ALL_OR_NOTHING", questions: data.questions.map((item) => ({ questionId: item.id, score: Number(item.score) })) }); for (const type of typeOptions.map((item) => item.value)) { const first = data.questions.find((item) => item.type === type); if (first) typeScores[type] = Number(first.score) || 1; } 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 syncEndTime() { if (!form.startTime) return; form.endTime = formatValue(new Date(new Date(form.startTime.replace(" ", "T")).getTime() + duration.value * 60000)); }
function syncDurationFromEnd() { if (!form.startTime || !form.endTime) return; const start = new Date(form.startTime.replace(" ", "T")); const end = new Date(form.endTime.replace(" ", "T")); if (end > start) duration.value = Math.round((end - start) / 60000); }
function isSelected(questionId) { return form.questions.some((item) => item.questionId === questionId); } 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 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 toggleQuestion(question, checked) { if (checked) form.questions.push({ questionId: question.id, score: typeScores[question.type] || 1 }); else form.questions = form.questions.filter((item) => item.questionId !== question.id); }
function removeQuestionFromPreview(question) { toggleQuestion(question, false); }
function setScore(questionId, score) { const item = form.questions.find((row) => row.questionId === questionId); if (item) item.score = Number(score); } 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 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 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 scoreByType(type) { return selectedQuestions(type).reduce((sum, item) => sum + Number(selectedScore(item.id)), 0); }
function scorePerQuestion(type) { return Number(typeScores[type] || 1); }
function setTypeScore(type, score) { const value = Number(score); if (!Number.isFinite(value) || value < 0.5) return; typeScores[type] = value; const ids = new Set(bank.value.filter((item) => item.type === type).map((item) => item.id)); for (const item of form.questions) if (ids.has(item.questionId)) item.score = value; }
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)); } 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 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 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 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: typeScores[q.type] || 1 }); ElMessage.success(`导入并选中 ${data.length} 道题`); }
async function downloadTemplate() { saveAs(await examApi.downloadQuestionTemplate(), "考试题库导入模板.docx"); } async function downloadTemplate() { saveAs(await examApi.downloadQuestionTemplate(), "考试题库导入模板.docx"); }
async function exportPaper() { saveAs(await examApi.exportExamPaper(id.value), `${form.name}试卷.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 scrollToType(type) { document.getElementById(`paper-${type}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
@ -346,6 +358,85 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
:deep(.el-input-number .el-input__wrapper) { padding-right: 58px; } :deep(.el-input-number .el-input__wrapper) { padding-right: 58px; }
} }
/* 原型中的固定信息区:基础字段、题型汇总和步骤入口始终保持在页面顶部。 */
.score-table-wrap {
overflow-x: auto;
margin-top: var(--sp-5);
border: 1px solid var(--border-color);
border-radius: var(--r-control);
}
.score-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
table-layout: fixed;
background: #ffffff;
th,
td {
height: 42px;
padding: 0 var(--sp-3);
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
color: var(--text-body);
font-size: var(--fs-body);
text-align: center;
vertical-align: middle;
}
th {
color: var(--text-strong);
background: var(--edu-primary-soft);
font-weight: 600;
}
th:last-child,
td:last-child { border-right: 0; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: #fafcff; }
}
.type-score-input { width: 112px; }
.fixed-step-actions {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-4);
margin-top: var(--sp-5);
}
.fixed-step-button {
min-width: 164px;
height: 40px;
padding: 0 var(--sp-5);
border: 0;
border-radius: var(--r-control);
background: var(--edu-primary);
color: #ffffff;
font-size: var(--fs-body);
font-weight: 600;
cursor: pointer;
transition: background 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
&:hover,
&.is-active {
background: var(--edu-primary-deep);
box-shadow: 0 4px 10px rgba(25, 137, 204, 0.18);
transform: translateY(-1px);
}
&:focus-visible { outline: 2px solid var(--edu-primary); outline-offset: 2px; }
}
.fixed-step-arrow {
color: var(--text-strong);
font-size: 20px;
font-weight: 700;
letter-spacing: -3px;
}
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -441,6 +532,12 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
p { margin: var(--sp-1) 0 0; color: var(--text-body); font-size: var(--fs-body); } p { margin: var(--sp-1) 0 0; color: var(--text-body); font-size: var(--fs-body); }
} }
.preview-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.import-tools { display: grid; justify-items: end; gap: var(--sp-2); } .import-tools { display: grid; justify-items: end; gap: var(--sp-2); }
/* 下载模板/导入试题按钮外观归 teacher-light.scss 全局接管(ghost) */ /* 下载模板/导入试题按钮外观归 teacher-light.scss 全局接管(ghost) */
@ -794,6 +891,11 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
margin-top: var(--sp-1); margin-top: var(--sp-1);
} }
.pq-remove {
flex: none;
margin-left: var(--sp-2);
}
/* ===== 自定义空状态 ===== */ /* ===== 自定义空状态 ===== */
.table-empty { .table-empty {
padding: var(--sp-5) 0; padding: var(--sp-5) 0;
@ -961,6 +1063,9 @@ function toBoolean(value, fallback) { if (value === undefined || value === null
.base-form { :deep(.el-form-item) { margin-bottom: var(--sp-3); } } .base-form { :deep(.el-form-item) { margin-bottom: var(--sp-3); } }
.form-row { grid-template-columns: 1fr; gap: var(--sp-3); } .form-row { grid-template-columns: 1fr; gap: var(--sp-3); }
.score-cards { grid-template-columns: repeat(2, 1fr); } .score-cards { grid-template-columns: repeat(2, 1fr); }
.fixed-step-actions { flex-wrap: wrap; gap: var(--sp-2); }
.fixed-step-arrow { display: none; }
.fixed-step-button { min-width: 138px; }
.settings-grid { grid-template-columns: 1fr; } .settings-grid { grid-template-columns: 1fr; }
.type-tabs { flex-wrap: wrap; } .type-tabs { flex-wrap: wrap; }
.pq-card { grid-template-columns: 28px 1fr; } .pq-card { grid-template-columns: 28px 1fr; }

@ -758,7 +758,8 @@ async function restoreAllTasksDefaults() {
async function openStudentDemo() { async function openStudentDemo() {
if (!selectedTeachingClass.value) return if (!selectedTeachingClass.value) return
const child = window.open("/student-demo", "_blank") // 项目使用 hash 路由;显式带上 hash,避免新窗口先落到默认的教师首页。
const child = window.open("/#/student-demo", "_blank")
if (!child) { if (!child) {
proxy?.$modal?.msgError?.("浏览器拦截了演示窗口,请允许弹窗后重试") proxy?.$modal?.msgError?.("浏览器拦截了演示窗口,请允许弹窗后重试")
return return

Loading…
Cancel
Save