feat: 综合大题改造(替代 Word 上传)+ MockExam 按组分支
中职/高职组以「综合大题」替代原 Word 文件上传:管理员在后台录入题干 (wangEditor 富文本,含表格),系统按组随机抽题,考生在 3 个小问内作答, 每小题 10 分、大题合计 30 分。 - api/admin.js / api/exam.js / stores/exam.js:大题题库 CRUD、抽题、作答与评分链路 - Competition.vue:Word 上传区块替换为大题作答区(3 小问 + 字数上限 + 富文本题干渲染) - Admin.vue:大题题库管理、考生作答查看与人工评分 - MockExam.vue:按组分支——中职/高职显示综合大题,其余组保持 Word 答题;侧栏与提交提示同步 - RichEditor.vue + utils/richtext.js:题干富文本渲染前做白名单消毒(零依赖,纵深防御) 说明:big_questions / big_question_answers 建表迁移 023/024 随后端仓库提交。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -128,19 +128,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<div class="nav-section" v-if="examStore.wordTemplate">
|
||||
<div class="nav-section-title">Word 答题</div>
|
||||
<!-- 综合大题(中职/高职,替代原 Word 上传题) -->
|
||||
<div class="nav-section" v-if="examStore.bigQuestionEnabled">
|
||||
<div class="nav-section-title">
|
||||
综合大题
|
||||
<span v-if="examStore.bigLoaded" class="nav-subcount">
|
||||
(已答 {{ examStore.bigAnsweredCount }}/{{ examStore.bigSubCount }} 小问)
|
||||
</span>
|
||||
</div>
|
||||
<div class="question-nav">
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{
|
||||
'nav-answered': wordUploaded || uploadedWordFile,
|
||||
'nav-current': currentQuestionIndex === wordQuestionIndex,
|
||||
'nav-answered': examStore.bigAllAnswered,
|
||||
'nav-current': currentQuestionIndex === bigQuestionIndex,
|
||||
}"
|
||||
@click="currentQuestionIndex = wordQuestionIndex"
|
||||
@click="currentQuestionIndex = bigQuestionIndex"
|
||||
>
|
||||
{{ wordQuestionIndex + 1 }}
|
||||
{{ bigQuestionIndex + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +164,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧题目区域 -->
|
||||
<div class="exam-main" v-if="examStore.questions.length > 0">
|
||||
<div class="exam-main" v-if="hasExamContent">
|
||||
<!-- 选择题 -->
|
||||
<div v-if="isCurrentChoice && currentQuestion.question_type === 'choice'" class="question-card">
|
||||
<div class="question-header">
|
||||
@@ -224,75 +229,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<div v-if="currentQuestionIndex === wordQuestionIndex" class="question-card word-card">
|
||||
<!-- 综合大题(中职/高职):题干富文本 + 三小问作答 -->
|
||||
<div v-if="isCurrentBig" class="question-card big-card">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第 {{ wordQuestionIndex + 1 }} 题</span>
|
||||
<el-tag type="danger" size="small">综合题</el-tag>
|
||||
<span class="question-number">第 {{ bigQuestionIndex + 1 }} 题</span>
|
||||
<el-tag type="danger" size="small">综合大题</el-tag>
|
||||
<span class="question-score" v-if="examStore.bigLoaded">
|
||||
(每小题 {{ examStore.bigScorePerSub }} 分,共 {{ examStore.bigTotalScore }} 分)
|
||||
</span>
|
||||
</div>
|
||||
<div class="question-body">
|
||||
<p class="question-text">请下载 Word 答题模板,在 Word 中完成答题后上传。</p>
|
||||
|
||||
<div class="word-sections">
|
||||
<!-- 下载区域 -->
|
||||
<div class="word-section">
|
||||
<h4>第 1 步:下载模板</h4>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Download"
|
||||
v-guard="() => downloadTemplate()"
|
||||
:loading="wordDownloading"
|
||||
>
|
||||
下载 Word 答题模板
|
||||
</el-button>
|
||||
<!-- 加载中 -->
|
||||
<div class="question-body" v-if="examStore.bigLoading">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
|
||||
<div v-if="wordDownloading" class="word-progress">
|
||||
<el-progress :percentage="wordDownloadPercent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="word-section">
|
||||
<h4>第 2 步:上传答卷</h4>
|
||||
<el-upload
|
||||
class="word-upload"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="onWordFileChange"
|
||||
:file-list="wordFileList"
|
||||
accept=".doc,.docx"
|
||||
>
|
||||
<el-icon class="el-icon--upload" :size="36"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
拖拽或 <em>点击上传</em> Word 文件
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">仅支持 .doc / .docx 文件</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="wordUploading" class="word-progress">
|
||||
<el-progress :percentage="wordUploadPercent" />
|
||||
</div>
|
||||
|
||||
<div v-if="uploadedWordFile" class="uploaded-info">
|
||||
<el-tag type="success" size="small">已上传</el-tag>
|
||||
<span class="uploaded-name">{{ uploadedWordFile.fileName }}</span>
|
||||
<span class="uploaded-size">{{ uploadedWordFile.fileSize }}</span>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="wordDeleting"
|
||||
v-guard="() => deleteWordFile()"
|
||||
>删除并重新上传</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 抽题失败:给出明确原因与重试入口(不静默留白) -->
|
||||
<div class="question-body" v-else-if="!examStore.bigLoaded">
|
||||
<el-alert
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="examStore.bigError || '综合大题加载失败'"
|
||||
description="若提示「该组别暂无大题」,请联系监考老师;若为网络问题,可点击下方按钮重试。"
|
||||
/>
|
||||
<div class="big-retry">
|
||||
<el-button type="primary" :loading="examStore.bigLoading" v-guard="() => retryBigQuestion()">
|
||||
重新加载题目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题干 + 三小问作答 -->
|
||||
<div class="question-body" v-else>
|
||||
<h3 class="big-title">{{ examStore.bigQuestion.title }}</h3>
|
||||
|
||||
<!-- 富文本题干(含表格):经白名单消毒后渲染 -->
|
||||
<div class="big-description" v-html="bigDescriptionHtml"></div>
|
||||
|
||||
<el-alert
|
||||
v-if="!examStore.bigAllAnswered"
|
||||
class="big-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="请在下方三个输入框中分别作答,每个输入框最多 2000 字,作答内容会实时自动保存。"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="i in examStore.bigSubCount"
|
||||
:key="i"
|
||||
class="big-sub-item"
|
||||
>
|
||||
<div class="big-sub-header">
|
||||
<span class="big-sub-no">第 {{ i }} 小问</span>
|
||||
<span class="big-sub-score">({{ examStore.bigScorePerSub }} 分)</span>
|
||||
</div>
|
||||
<div v-if="examStore.bigSubPrompts[i - 1]" class="big-sub-prompt">{{ examStore.bigSubPrompts[i - 1] }}</div>
|
||||
<el-input
|
||||
class="big-answer-input"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
resize="vertical"
|
||||
:maxlength="examStore.bigMaxChars"
|
||||
show-word-limit
|
||||
:placeholder="`请输入第 ${i} 小问的答案(最多 ${examStore.bigMaxChars} 字)`"
|
||||
:model-value="bigAnswerOf(i)"
|
||||
@update:model-value="val => onBigAnswerInput(i, val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 保存状态:让考生对「是否已存上」有明确反馈 -->
|
||||
<div class="big-save-status">
|
||||
<el-tag v-if="examStore.bigSaving" size="small" type="warning" effect="plain">正在保存…</el-tag>
|
||||
<el-tag v-else-if="bigSaveErrorShown" size="small" type="danger" effect="plain">
|
||||
保存失败:{{ examStore.bigError }}
|
||||
</el-tag>
|
||||
<el-tag v-else size="small" type="success" effect="plain">已自动保存</el-tag>
|
||||
<span class="big-save-note">已答 {{ examStore.bigAnsweredCount }}/{{ examStore.bigSubCount }} 小问</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-footer">
|
||||
<el-button :disabled="currentQuestionIndex === 0" @click="prevQuestion">上一题</el-button>
|
||||
<el-button v-if="currentQuestionIndex < totalCount - 1" type="primary" @click="nextQuestion">
|
||||
@@ -312,7 +329,9 @@
|
||||
<div class="exam-bottom-bar" v-if="!isUploadMode">
|
||||
<div class="exam-bottom-inner">
|
||||
<span class="submit-hint">
|
||||
请确保已检查所有题目和 Word 文件后提交
|
||||
{{ examStore.bigQuestionEnabled
|
||||
? `请确保已检查所有题目和综合大题(已答 ${examStore.bigAnsweredCount}/${examStore.bigSubCount} 小问)后提交`
|
||||
: '请确保已检查所有题目后提交' }}
|
||||
</span>
|
||||
<el-button
|
||||
type="danger"
|
||||
@@ -343,12 +362,13 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||
import { Timer, Download, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { Timer, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useExamStore } from '@/stores/exam'
|
||||
import * as examApi from '@/api/exam'
|
||||
import * as uploadApi from '@/api/upload'
|
||||
import { formatFileSize } from '@/utils/upload'
|
||||
import { sanitizeHtml } from '@/utils/richtext'
|
||||
import {
|
||||
fileExt,
|
||||
categoryOf,
|
||||
@@ -374,7 +394,6 @@ const uploadRef = ref(null)
|
||||
|
||||
const ZIP_MAX = 200 * 1024 * 1024
|
||||
const OTHER_MAX = 100 * 1024 * 1024
|
||||
const WORD_MAX = 50 * 1024 * 1024 // 与后端 uploadWord 服务端 50MB 上限一致
|
||||
|
||||
function fileTypeOf(name) {
|
||||
const e = '.' + fileExt(name)
|
||||
@@ -509,32 +528,45 @@ const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const currentQuestionIndex = ref(0)
|
||||
|
||||
// Word 上传相关
|
||||
const wordFileList = ref([])
|
||||
const wordUploading = ref(false)
|
||||
const wordUploadPercent = ref(0)
|
||||
const wordDeleting = ref(false)
|
||||
const uploadedWordFile = ref(null)
|
||||
const wordDownloading = ref(false)
|
||||
const wordDownloadPercent = ref(0)
|
||||
const wordUploaded = ref(false)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
// 综合大题作为最后一道「题」参与导航与题量统计(中职/高职启用,本科/教师不启用)
|
||||
const totalCount = computed(() => {
|
||||
const base = examStore.questions.length
|
||||
return examStore.wordTemplate ? base + 1 : base
|
||||
return examStore.bigQuestionEnabled ? base + 1 : base
|
||||
})
|
||||
|
||||
// Word 题目的虚拟索引(最后一个)
|
||||
const wordQuestionIndex = computed(() => examStore.questions.length)
|
||||
// 综合大题的虚拟索引(最后一个)
|
||||
const bigQuestionIndex = computed(() => examStore.questions.length)
|
||||
|
||||
// 当前是否停在综合大题
|
||||
const isCurrentBig = computed(() => {
|
||||
return examStore.bigQuestionEnabled && currentQuestionIndex.value === bigQuestionIndex.value
|
||||
})
|
||||
|
||||
// 右侧题目区是否有内容可渲染(客观题与综合大题任一可用即渲染,避免只抽到大题时误显示空状态)
|
||||
const hasExamContent = computed(() => {
|
||||
return examStore.questions.length > 0 || (examStore.bigQuestionEnabled && examStore.bigLoaded)
|
||||
})
|
||||
|
||||
const answeredCount = computed(() => {
|
||||
let count = Object.keys(examStore.answers).length
|
||||
if (wordUploaded.value || uploadedWordFile.value) count++
|
||||
// 综合大题按「三小问全部作答」计为 1 题已答(部分作答可在侧栏看到 x/3 明细)
|
||||
if (examStore.bigQuestionEnabled && examStore.bigAllAnswered) count++
|
||||
return count
|
||||
})
|
||||
|
||||
/** 题干富文本:白名单消毒后再交给 v-html 渲染 */
|
||||
const bigDescriptionHtml = computed(() => {
|
||||
const q = examStore.bigQuestion
|
||||
return sanitizeHtml((q && q.description) || '')
|
||||
})
|
||||
|
||||
/** 是否处于「保存失败」状态(保存中优先展示加载态,失败时给出明确提示) */
|
||||
const bigSaveErrorShown = computed(() => {
|
||||
return !examStore.bigSaving && !!examStore.bigError && examStore.bigLoaded
|
||||
})
|
||||
|
||||
const remainingSeconds = computed(() => examStore.countdown)
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
@@ -625,170 +657,56 @@ function nextQuestion() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Word 相关 ====================
|
||||
// ==================== 综合大题(中职/高职) ====================
|
||||
|
||||
/** 下载 Word 模板 */
|
||||
async function downloadTemplate() {
|
||||
if (!examStore.wordTemplate?.group) return
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/exam/${examStore.wordTemplate.group}/word-template?token=${encodeURIComponent(token || '')}`
|
||||
/** 取第 index 小问的当前答案(空值统一为 '',避免 el-input 出现 undefined 告警) */
|
||||
function bigAnswerOf(index) {
|
||||
const v = examStore.bigAnswers[`answer_${index}`]
|
||||
return v == null ? '' : v
|
||||
}
|
||||
|
||||
wordDownloading.value = true
|
||||
wordDownloadPercent.value = 0
|
||||
try {
|
||||
// 同时带上 Authorization 请求头(与上传一致),确保 auth 校验通过
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : undefined
|
||||
const response = await fetch(url, headers ? { headers } : undefined)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
/** 输入即写本地 + 防抖自动保存(落库由 store 统一处理) */
|
||||
function onBigAnswerInput(index, value) {
|
||||
examStore.setBigAnswer(index, value)
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json()
|
||||
if (data.code !== 0) {
|
||||
ElMessage.warning(data.message || '模板下载失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
ElMessage.error('模板下载失败')
|
||||
return
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('content-disposition')
|
||||
let filename = 'template.docx'
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename\*?=(?:UTF-8''|"?)([^";]+)/i)
|
||||
if (match) {
|
||||
filename = decodeURIComponent(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 流式读取,实时更新下载进度(1M 带宽下大文件较慢,必须有反馈,
|
||||
// 消除「点击后无反应」的错觉,与上传进度体验一致)。
|
||||
const reader = response.body.getReader()
|
||||
const contentLength = parseInt(response.headers.get('content-length') || '0', 10)
|
||||
let received = 0
|
||||
const chunks = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
received += value.length
|
||||
if (contentLength > 0) {
|
||||
wordDownloadPercent.value = Math.min(99, Math.floor((received / contentLength) * 100))
|
||||
} else {
|
||||
// 无 content-length(被分块/压缩)时给一个随已下载量缓增的估算进度,封顶 95
|
||||
wordDownloadPercent.value = Math.min(95, Math.floor((received / (5 * 1024 * 1024)) * 100))
|
||||
}
|
||||
}
|
||||
const blob = new Blob(chunks)
|
||||
|
||||
// 🔴 同源守卫(2026-08-13 推进 #12 / 同源扩大化测试):本函数走裸 fetch,
|
||||
// 不经过 safeDownload/fetchBlobGuarded 的 assertRealFile。若 nginx /internal-downloads/
|
||||
// 内部 location 未生效而回退吐 index.html(text/html),原逻辑会把 HTML 当 .docx/.pptx
|
||||
// 存盘 → 打开「已损坏」。这里补同样的 HTML 嗅探 + X-File-Size 大小比对,命中即拒存盘。
|
||||
const head = await blob.slice(0, 256).text().catch(() => '');
|
||||
if (/^\s*(<!doctype\s+html|<html[\s>])/i.test(head)) {
|
||||
ElMessage.error('服务器返回的是网页而非模板文件,下载通道配置异常(请联系管理员检查 nginx 内部下载 location)');
|
||||
return;
|
||||
}
|
||||
const declared = parseInt(response.headers.get('x-file-size') || response.headers.get('content-length') || '0', 10);
|
||||
if (Number.isFinite(declared) && declared > 0 && received !== declared) {
|
||||
ElMessage.error(`模板传输不完整:应为 ${declared} 字节,实际 ${received} 字节,请重试`);
|
||||
return;
|
||||
}
|
||||
|
||||
wordDownloadPercent.value = 100
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
} catch (err) {
|
||||
ElMessage.error('模板下载失败')
|
||||
} finally {
|
||||
wordDownloading.value = false
|
||||
/** 抽题/加载失败后的手动重试 */
|
||||
async function retryBigQuestion() {
|
||||
const ok = await examStore.loadBigQuestion(examStore.currentGroup)
|
||||
if (ok) {
|
||||
ElMessage.success('综合大题已加载')
|
||||
} else {
|
||||
ElMessage.error(examStore.bigError || '综合大题加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** Word 文件选择变化 */
|
||||
function onWordFileChange(file) {
|
||||
const raw = file.raw || file
|
||||
// 客户端先行校验大小(与后端 uploadWord 50MB 上限一致),超限直接拦截并给出带文件大小的明确提示
|
||||
if (raw.size > WORD_MAX) {
|
||||
ElMessage.error(`Word 文件「${raw.name}」大小为 ${formatFileSize(raw.size)},超过 50MB 限制,无法上传`)
|
||||
wordFileList.value = []
|
||||
return
|
||||
}
|
||||
wordFileList.value = [file]
|
||||
uploadWordFile()
|
||||
}
|
||||
|
||||
/** 上传 Word 文件 */
|
||||
async function uploadWordFile() {
|
||||
if (wordFileList.value.length === 0) return null
|
||||
const rawFile = wordFileList.value[0]?.raw || wordFileList.value[0]
|
||||
if (!rawFile) return null
|
||||
|
||||
wordUploading.value = true
|
||||
wordUploadPercent.value = 0
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', rawFile)
|
||||
|
||||
const result = await examApi.uploadWord(examStore.currentGroup, formData)
|
||||
wordUploadPercent.value = 100
|
||||
wordUploaded.value = true
|
||||
|
||||
uploadedWordFile.value = {
|
||||
fileName: rawFile.name,
|
||||
fileSize: formatFileSize(rawFile.size),
|
||||
md5: result?.md5 || '',
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
ElMessage.error('Word 文件上传失败')
|
||||
return null
|
||||
} finally {
|
||||
wordUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除已上传的 Word 文件(支持重新上传替换) */
|
||||
async function deleteWordFile() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除已上传的 Word 文件吗?删除后可重新上传进行替换。',
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
wordDeleting.value = true
|
||||
try {
|
||||
await examApi.deleteWord(examStore.currentGroup)
|
||||
uploadedWordFile.value = null
|
||||
wordFileList.value = []
|
||||
wordUploaded.value = false
|
||||
ElMessage.success('Word 文件已删除,可重新上传')
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.response?.data?.message || '删除失败,请重试')
|
||||
} finally {
|
||||
wordDeleting.value = false
|
||||
/**
|
||||
* 交卷前确保综合大题答案已落库
|
||||
* @param {{force?: boolean}} [opts]
|
||||
* @returns {Promise<boolean>} 是否落库成功
|
||||
*/
|
||||
async function ensureBigAnswersSaved(opts = {}) {
|
||||
if (!examStore.bigQuestionEnabled || !examStore.bigLoaded) return true
|
||||
const r = await examStore.flushBigAnswers(opts)
|
||||
if (!r.ok) {
|
||||
ElMessage.error(`综合大题答案保存失败:${r.message || '请检查网络后重试'}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== 提交全部 ====================
|
||||
|
||||
async function handleSubmitAll() {
|
||||
// 未答满时给出明确的「还差什么」,避免考生误交卷
|
||||
let confirmText = '确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。'
|
||||
if (examStore.bigQuestionEnabled && examStore.bigLoaded && !examStore.bigAllAnswered) {
|
||||
const lack = examStore.bigSubCount - examStore.bigAnsweredCount
|
||||
confirmText = `综合大题还有 ${lack} 个小问未作答,提交后将无法修改。确定要提交全部答案吗?`
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。',
|
||||
confirmText,
|
||||
'确认提交',
|
||||
{ confirmButtonText: '确认提交', cancelButtonText: '再检查一下', type: 'warning' }
|
||||
)
|
||||
@@ -798,16 +716,11 @@ async function handleSubmitAll() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
|
||||
await uploadWordFile()
|
||||
}
|
||||
// 先把综合大题答案落库(submit-all 只处理客观题与提交时间),失败即中止交卷
|
||||
const saved = await ensureBigAnswersSaved()
|
||||
if (!saved) return
|
||||
|
||||
const additionalData = {}
|
||||
if (uploadedWordFile.value) {
|
||||
additionalData.wordFile = uploadedWordFile.value
|
||||
}
|
||||
|
||||
await examStore.submitAll(additionalData)
|
||||
await examStore.submitAll({})
|
||||
ElMessage.success('提交成功!')
|
||||
} catch (err) {
|
||||
if (err?.message) ElMessage.error(err.message)
|
||||
@@ -841,19 +754,17 @@ async function autoSubmit() {
|
||||
}
|
||||
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案')
|
||||
try {
|
||||
// 若尚有未上传的 Word 答题文件,先上传,避免作品被遗漏
|
||||
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
|
||||
await uploadWordFile()
|
||||
}
|
||||
const additionalData = {}
|
||||
if (uploadedWordFile.value) {
|
||||
additionalData.wordFile = uploadedWordFile.value
|
||||
}
|
||||
// forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门,
|
||||
// 确保 submit_time 落库(管理后台提交时间显示为真实交卷时间,而非进入考试时间)。
|
||||
additionalData.forceSubmit = true
|
||||
// submitAll 内部会逐题保存答案并写入提交时间,完成「自动保存 + 自动提交」
|
||||
await examStore.submitAll(additionalData)
|
||||
// 确保最后输入的大题答案与 submit_time 都能落库(管理后台显示真实交卷时间)。
|
||||
const saved = await ensureBigAnswersSaved({ force: true })
|
||||
if (!saved) {
|
||||
// 落库失败不退化为静默丢答案:提示考生手动重试
|
||||
ElMessage.error('综合大题答案未能保存,请点击「提交全部」重试')
|
||||
autoSubmitted.value = false
|
||||
return
|
||||
}
|
||||
// submitAll 内部会逐题保存客观题答案并写入提交时间(大题已在上面落库)
|
||||
await examStore.submitAll({ forceSubmit: true })
|
||||
ElMessage.success('已自动保存并提交')
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
@@ -927,27 +838,44 @@ function handleBeforeUnload(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止复制
|
||||
/**
|
||||
* 事件目标是否为「可编辑元素」(综合大题的作答输入框)
|
||||
*
|
||||
* 考试页为保证题目不被外传,全局禁用了选中/复制/右键。但综合大题要求考生
|
||||
* 在输入框内自行作答,若沿用同一套拦截会导致无法选中、修改、粘贴自己写的内容。
|
||||
* 因此对可编辑元素做最小豁免:只放行「编辑自身答案」所需的操作,
|
||||
* 题目区域(非可编辑)的防护行为完全不变。
|
||||
*/
|
||||
function isEditableTarget(e) {
|
||||
const el = e && e.target
|
||||
if (!el) return false
|
||||
const tag = (el.tagName || '').toLowerCase()
|
||||
return tag === 'textarea' || tag === 'input' || el.isContentEditable === true
|
||||
}
|
||||
|
||||
// 禁止复制(可编辑元素内放行,否则考生无法复制/粘贴自己的作答)
|
||||
function preventCopy(e) {
|
||||
if (isEditableTarget(e)) return
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁止鼠标选中文字
|
||||
// 禁止鼠标选中文字(可编辑元素内放行,否则输入框内无法选中文字)
|
||||
function preventSelect(e) {
|
||||
if (isEditableTarget(e)) return
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁止键盘快捷键:Ctrl+C、Ctrl+U、F12、Ctrl+Shift+I、Ctrl+Shift+C、Ctrl+Shift+J
|
||||
function preventKeyboard(e) {
|
||||
// F12(开发者工具)
|
||||
// F12(开发者工具)—— 任何位置均拦截
|
||||
if (e.key === 'F12' || e.keyCode === 123) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
// Ctrl+Shift+I / Ctrl+Shift+C / Ctrl+Shift+J(开发者工具)
|
||||
// Ctrl+Shift+I / Ctrl+Shift+C / Ctrl+Shift+J(开发者工具)—— 任何位置均拦截
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
if (e.key === 'I' || e.key === 'i' ||
|
||||
e.key === 'C' || e.key === 'c' ||
|
||||
@@ -957,6 +885,9 @@ function preventKeyboard(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 以下剪贴/保存/打印类快捷键:仅在可编辑元素内放行(考生编辑自身答案所需)
|
||||
if (isEditableTarget(e)) return
|
||||
|
||||
// Ctrl+C(复制)
|
||||
if (e.ctrlKey && (e.key === 'C' || e.key === 'c' || e.keyCode === 67)) {
|
||||
e.preventDefault()
|
||||
@@ -1072,8 +1003,9 @@ onMounted(async () => {
|
||||
|
||||
await examStore.startExam(group)
|
||||
|
||||
if (examStore.questions.length === 0) {
|
||||
ElMessage.error('题目加载失败,请刷新页面重试')
|
||||
// 客观题与综合大题都为空才算加载失败;只抽到大题(客观题库为空)时仍可正常作答
|
||||
if (examStore.questions.length === 0 && !examStore.bigLoaded) {
|
||||
ElMessage.error(examStore.bigError || '题目加载失败,请刷新页面重试')
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
@@ -1350,26 +1282,116 @@ onUnmounted(() => {
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
/* ==================== Word 答题卡片 ==================== */
|
||||
.word-card .word-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.word-section {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.word-section h4 {
|
||||
font-size: 0.95rem;
|
||||
/* ==================== 综合大题卡片 ==================== */
|
||||
.big-card .big-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 富文本题干:scoped CSS 不作用于 v-html 子节点,须用 :deep() 兜底可读性 */
|
||||
.big-description {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.8;
|
||||
color: #303133;
|
||||
margin-bottom: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.big-description :deep(p) {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.big-description :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.big-description :deep(th),
|
||||
.big-description :deep(td) {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 0.5rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.big-description :deep(th) {
|
||||
background: #f5f7fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.big-description :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.big-hint {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.big-sub-item {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed #ebeef5;
|
||||
}
|
||||
.big-sub-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.big-sub-no {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.word-upload {
|
||||
width: 100%;
|
||||
.big-sub-score {
|
||||
font-size: 0.85rem;
|
||||
color: #909399;
|
||||
}
|
||||
.word-progress {
|
||||
margin-top: 0.75rem;
|
||||
.big-sub-prompt {
|
||||
font-size: 0.9rem;
|
||||
color: #606266;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 0.5rem;
|
||||
/* 小题提示为纯文本,保留换行与缩进 */
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 作答输入框:考试页全局禁用选中,这里必须恢复,否则考生无法编辑自己的答案 */
|
||||
.big-answer-input :deep(.el-textarea__inner) {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
/* 字数统计(el-input show-word-limit 默认渲染在右下角) */
|
||||
.big-answer-input :deep(.el-input__count) {
|
||||
background: transparent;
|
||||
color: #909399;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.big-save-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.big-save-note {
|
||||
font-size: 0.85rem;
|
||||
color: #909399;
|
||||
}
|
||||
.big-retry {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* 侧栏综合大题小问计数 */
|
||||
.nav-subcount {
|
||||
font-size: 0.8rem;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
.uploaded-info {
|
||||
display: flex;
|
||||
|
||||
@@ -72,14 +72,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<!-- 最后一题:中职/高职 = 综合大题;本科/教师 = Word 答题(与正式考试一致) -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-title">Word 答题</div>
|
||||
<div class="nav-section-title">{{ isBigQuestionGroup ? '综合大题' : 'Word 答题' }}</div>
|
||||
<div class="question-nav">
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{
|
||||
'nav-answered': wordCompleted,
|
||||
'nav-answered': lastSectionCompleted,
|
||||
'nav-current': currentIndex === wordIndex,
|
||||
}"
|
||||
@click="currentIndex = wordIndex"
|
||||
@@ -166,8 +166,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题 -->
|
||||
<div v-if="currentIndex === wordIndex" class="question-card word-card">
|
||||
<!-- 综合大题(中职/高职组)—— 正式考试已取消 Word 上传题,改为在线作答 -->
|
||||
<div v-if="currentIndex === wordIndex && isBigQuestionGroup" class="question-card word-card">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
|
||||
<el-tag type="primary" size="small">综合大题</el-tag>
|
||||
<span class="question-score">(30 分)</span>
|
||||
</div>
|
||||
<div class="question-body">
|
||||
<p class="question-text">
|
||||
模拟体验:正式考试中本题由系统从题库随机抽取(题干含文字描述与表格),
|
||||
共 {{ mockBigSubPrompts.length }} 小问,每小问 10 分、作答上限 {{ MOCK_BIG_MAX_CHARS }} 字,
|
||||
请直接在本页输入框中作答(模拟考试不会真实提交)。
|
||||
</p>
|
||||
|
||||
<div class="big-sections">
|
||||
<div v-for="(prompt, si) in mockBigSubPrompts" :key="'big-' + si" class="big-section">
|
||||
<h4>第 {{ si + 1 }} 小问(10 分)</h4>
|
||||
<p class="big-prompt">{{ prompt }}</p>
|
||||
<el-input
|
||||
v-model="bigAnswers[si]"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:maxlength="MOCK_BIG_MAX_CHARS"
|
||||
show-word-limit
|
||||
placeholder="请输入作答内容"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="question-footer">
|
||||
<el-button :disabled="currentIndex === 0" @click="prevQuestion">上一题</el-button>
|
||||
<el-button type="primary" @click="nextQuestion">
|
||||
下一题
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word 答题(本科/教师组;模拟考试,文件不会真实上传) -->
|
||||
<div v-if="currentIndex === wordIndex && !isBigQuestionGroup" class="question-card word-card">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第 {{ wordIndex + 1 }} 题</span>
|
||||
<el-tag type="danger" size="small">综合题</el-tag>
|
||||
@@ -226,7 +263,7 @@
|
||||
<div class="exam-bottom-bar">
|
||||
<div class="exam-bottom-inner">
|
||||
<span class="submit-hint">
|
||||
请确保已检查所有题目和 Word 文件后提交
|
||||
{{ isBigQuestionGroup ? '请确保已检查所有题目和综合大题后提交' : '请确保已检查所有题目和 Word 文件后提交' }}
|
||||
</span>
|
||||
<el-button
|
||||
type="danger"
|
||||
@@ -267,7 +304,8 @@ const optionLabels = ['A', 'B', 'C', 'D']
|
||||
|
||||
// ==================== 模拟题目数据 ====================
|
||||
// 设计意图(与用户明确要求一致):
|
||||
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道(+ 1 道 Word 综合题)。
|
||||
// 1) 模拟考试的题量必须与在线正式考试一致:选择题 50 道 + 判断题 20 道
|
||||
// (+ 1 道最后一题:中职/高职为「综合大题」在线作答,本科/教师为 Word 上传)。
|
||||
// 2) 为了让学生「明显感觉这只是体验考试形式」,选择题全部使用同一道样题(第一题),
|
||||
// 判断题也全部使用同一道样题(第一题),后面每一题内容都与第一题相同。
|
||||
// 3) 关键:即便内容相同,每一题也必须拥有全局唯一的 id(c1..c50 / j1..j20)。
|
||||
@@ -316,6 +354,24 @@ const mockJudgeQuestions = Array.from({ length: MOCK_JUDGE_COUNT }, (_, i) => ({
|
||||
// 合并所有题目(选择题在前、判断题在后,与在线考试一致)
|
||||
const allQuestions = [...mockChoiceQuestions, ...mockJudgeQuestions]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 最后一题的形态随组别变化(与正式考试保持一致,2026-09-21 改造):
|
||||
// · 中职组 / 高职组:正式考试已取消 Word 文件上传题,改为「综合大题」在线作答
|
||||
// (题干含文字描述与表格,3 小问、每小问 10 分、作答上限 2000 字)。
|
||||
// 模拟页必须同步呈现该形态,否则考生会在模拟里看到一道正式考试并不存在的题。
|
||||
// · 本科组 / 教师组:正式考试仍为 Word 文档上传,模拟页保持原样。
|
||||
// 注意:模拟页始终为**纯前端**演示,不对正式考试的题库/上传通道发起任何请求。
|
||||
// ---------------------------------------------------------------------------
|
||||
const BIG_QUESTION_GROUPS = ['zhongzhi', 'gaozhi']
|
||||
const MOCK_BIG_MAX_CHARS = 2000
|
||||
|
||||
// 综合大题样题(3 小问,仅用于模拟演示,与正式题库无关)
|
||||
const mockBigSubPrompts = [
|
||||
'请简述你对题干所给方案的总体思路。',
|
||||
'请说明方案实施的关键步骤与所需条件。',
|
||||
'请分析该方案可能存在的问题并给出改进建议。',
|
||||
]
|
||||
|
||||
// ==================== 状态 ====================
|
||||
const currentIndex = ref(0)
|
||||
const answers = ref({})
|
||||
@@ -324,13 +380,29 @@ const remainingSeconds = ref(3600) // 60分钟模拟
|
||||
const wordCompleted = ref(false)
|
||||
const wordFileName = ref('')
|
||||
const wordFileList = ref([])
|
||||
// 综合大题(中职/高职)模拟作答:与正式考试的 3 个输入框同口径
|
||||
const bigAnswers = ref(mockBigSubPrompts.map(() => ''))
|
||||
let timerInterval = null
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const totalCount = computed(() => allQuestions.length + 1) // +1 for Word
|
||||
// 当前用户组是否走「综合大题」形态
|
||||
const isBigQuestionGroup = computed(() => BIG_QUESTION_GROUPS.includes(authStore.userGroup))
|
||||
|
||||
const totalCount = computed(() => allQuestions.length + 1) // +1 为最后一题
|
||||
|
||||
const wordIndex = computed(() => allQuestions.length)
|
||||
|
||||
// 综合大题是否答满(3 小问均有非空内容,与后端 isCompleted 同口径)
|
||||
const bigCompleted = computed(() =>
|
||||
mockBigSubPrompts.length > 0 &&
|
||||
bigAnswers.value.slice(0, mockBigSubPrompts.length).every(v => String(v || '').trim().length > 0)
|
||||
)
|
||||
|
||||
// 最后一题是否已完成(按组别取对应口径,供导航着色与进度统计统一使用)
|
||||
const lastSectionCompleted = computed(() =>
|
||||
isBigQuestionGroup.value ? bigCompleted.value : wordCompleted.value
|
||||
)
|
||||
|
||||
const isCurrentQuestion = computed(() => currentIndex.value < allQuestions.length)
|
||||
|
||||
const currentQuestion = computed(() => {
|
||||
@@ -342,7 +414,7 @@ const currentQuestion = computed(() => {
|
||||
|
||||
const answeredCount = computed(() => {
|
||||
let count = Object.keys(answers.value).filter(k => answers.value[k] !== undefined && answers.value[k] !== '').length
|
||||
if (wordCompleted.value) count++
|
||||
if (lastSectionCompleted.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
@@ -789,6 +861,24 @@ onUnmounted(() => {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* 综合大题(中职/高职组) */
|
||||
.big-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.big-section h4 {
|
||||
font-size: 15px;
|
||||
color: #1a3a5c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.big-prompt {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ==================== 底部提交栏 ==================== */
|
||||
.exam-bottom-bar {
|
||||
background: #fff;
|
||||
|
||||
Reference in New Issue
Block a user