中职/高职组以「综合大题」替代原 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 随后端仓库提交。
1480 lines
48 KiB
Vue
1480 lines
48 KiB
Vue
<template>
|
||
<div class="competition-page">
|
||
<!-- ==================== 顶部倒计时栏 ==================== -->
|
||
<div class="exam-top-bar" v-if="examStore.examStarted" :class="{ 'time-critical': remainingSeconds < 300 && remainingSeconds > 0 }">
|
||
<div class="exam-top-inner">
|
||
<span class="exam-title">{{ isUploadMode ? '在线提交' : '在线答题' }}</span>
|
||
<div class="exam-timer">
|
||
<el-icon :size="20"><Timer /></el-icon>
|
||
<span class="timer-display" :class="{ 'timer-danger': remainingSeconds < 300 }">
|
||
{{ examStore.countdownDisplay }}
|
||
</span>
|
||
</div>
|
||
<div class="exam-progress" v-if="!isUploadMode">
|
||
<span>已答 {{ answeredCount }} / {{ totalCount }} 题</span>
|
||
<el-progress :percentage="progressPercent" :stroke-width="8" :color="progressColor" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 本科/教师组:答案文件上传模式(无客观题) ==================== -->
|
||
<div class="exam-upload-mode" v-if="isUploadMode" v-loading="uploadPanelLoading">
|
||
<el-card class="upload-card" shadow="never">
|
||
<template #header>
|
||
<span class="upload-title">答案文件上传({{ groupLabel }})</span>
|
||
</template>
|
||
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
|
||
<template #title>本组别无客观题:先选择文件并确认无误,再点击「上传所选文件」提交</template>
|
||
支持 docx / pdf / ppt / zip。规则:① zip 须单独上传(与任何其他文件互斥,自动覆盖);② pdf 与 docx 相互覆盖,只能保留其一;③ 若需同时提交文档与演示稿,请选「pdf + ppt」或「docx + ppt」组合。zip 上限 200MB,其余格式上限 100MB。
|
||
</el-alert>
|
||
<el-upload
|
||
ref="uploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:accept="'.doc,.docx,.pdf,.ppt,.pptx,.zip'"
|
||
:file-list="uploadFileList"
|
||
:on-change="onUploadFileChange"
|
||
multiple
|
||
drag
|
||
class="upload-drag"
|
||
>
|
||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
||
<template #tip>
|
||
<div class="el-upload__tip">docx / pdf / ppt / zip;zip 单独上传且自动覆盖其他;pdf 与 docx 互斥;仅允许 zip / (pdf+ppt) / (docx+ppt) 三种组合</div>
|
||
</template>
|
||
</el-upload>
|
||
|
||
<!-- 待上传文件(已选择,尚未提交) -->
|
||
<div v-if="uploadFileList.length" class="pending-list">
|
||
<div class="pending-list-title">待上传文件(确认无误后点击「上传所选文件」):</div>
|
||
<div v-for="f in uploadFileList" :key="f.uid" class="uploaded-item pending-item" :class="{ 'file-over-limit': isOverLimit(f) }">
|
||
<span class="uploaded-name">{{ (f.raw || f).name }}</span>
|
||
<span class="uploaded-size" :class="{ 'size-over': isOverLimit(f) }">{{ formatFileSize((f.raw || f).size) }}</span>
|
||
<el-tag size="small" :type="categoryTagType(categoryOf((f.raw || f).name))">{{ categoryLabel(categoryOf((f.raw || f).name)) }}</el-tag>
|
||
<el-tag v-if="isOverLimit(f)" size="small" type="danger">超出限制</el-tag>
|
||
<el-button size="small" type="danger" plain @click="removePendingFile(f)">删除</el-button>
|
||
</div>
|
||
<el-alert
|
||
v-if="hasOverLimitFile"
|
||
type="error"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top:10px"
|
||
title="存在超出大小限制的文件"
|
||
description="标红的文件已超过上传上限(zip 200MB / 其他 100MB),请删除后重新选择符合要求的文件再提交。"
|
||
/>
|
||
</div>
|
||
|
||
<div class="upload-actions">
|
||
<el-button type="primary" :loading="uploading" :disabled="pendingUploadFiles.length === 0 || !uploadComboValid || hasOverLimitFile" v-guard="() => submitUploadFiles()">上传所选文件</el-button>
|
||
<el-button @click="clearUploadFiles" :disabled="uploadFileList.length === 0">清空待上传</el-button>
|
||
</div>
|
||
|
||
<el-divider v-if="uploadedWorks.length" />
|
||
<div v-if="uploadedWorks.length" class="uploaded-list">
|
||
<div class="pending-list-title">已上传文件:</div>
|
||
<div v-for="w in uploadedWorks" :key="w.id" class="uploaded-item">
|
||
<span class="uploaded-name">{{ w.fileName }}</span>
|
||
<el-tag size="small" :type="categoryTagType(w.category)">{{ categoryLabel(w.category) }}</el-tag>
|
||
<el-tag size="small" type="success">已上传</el-tag>
|
||
<el-button size="small" type="danger" plain :loading="w.deleting" v-guard="() => deleteUploadedWork(w)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- ==================== 主布局(客观题考试) ==================== -->
|
||
<div class="exam-body" v-if="!isUploadMode">
|
||
<!-- 左侧题目导航 -->
|
||
<div class="exam-sidebar">
|
||
<h4 class="sidebar-title">题目导航</h4>
|
||
|
||
<!-- 选择题 -->
|
||
<div class="nav-section" v-if="examStore.choiceQuestions.length > 0">
|
||
<div class="nav-section-title">选择题 ({{ examStore.choiceQuestions.length }}题)</div>
|
||
<div class="question-nav">
|
||
<div
|
||
v-for="(q, idx) in examStore.choiceQuestions"
|
||
:key="q.question_uid"
|
||
class="nav-item"
|
||
:class="{
|
||
'nav-answered': isAnswered(q.question_uid),
|
||
'nav-current': currentQuestionIndex === findQuestionIndex(q.question_uid),
|
||
}"
|
||
@click="goToQuestion(findQuestionIndex(q.question_uid))"
|
||
>
|
||
{{ findQuestionIndex(q.question_uid) + 1 }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 判断题 -->
|
||
<div class="nav-section" v-if="examStore.judgeQuestions.length > 0">
|
||
<div class="nav-section-title">判断题 ({{ examStore.judgeQuestions.length }}题)</div>
|
||
<div class="question-nav">
|
||
<div
|
||
v-for="(q, idx) in examStore.judgeQuestions"
|
||
:key="q.question_uid"
|
||
class="nav-item"
|
||
:class="{
|
||
'nav-answered': isAnswered(q.question_uid),
|
||
'nav-current': currentQuestionIndex === findQuestionIndex(q.question_uid),
|
||
}"
|
||
@click="goToQuestion(findQuestionIndex(q.question_uid))"
|
||
>
|
||
{{ findQuestionIndex(q.question_uid) + 1 }}
|
||
</div>
|
||
</div>
|
||
</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': examStore.bigAllAnswered,
|
||
'nav-current': currentQuestionIndex === bigQuestionIndex,
|
||
}"
|
||
@click="currentQuestionIndex = bigQuestionIndex"
|
||
>
|
||
{{ bigQuestionIndex + 1 }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-legend">
|
||
<span class="legend-item">
|
||
<span class="legend-dot answered"></span> 已答
|
||
</span>
|
||
<span class="legend-item">
|
||
<span class="legend-dot current"></span> 当前
|
||
</span>
|
||
<span class="legend-item">
|
||
<span class="legend-dot unanswered"></span> 未答
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧题目区域 -->
|
||
<div class="exam-main" v-if="hasExamContent">
|
||
<!-- 选择题 -->
|
||
<div v-if="isCurrentChoice && currentQuestion.question_type === 'choice'" class="question-card">
|
||
<div class="question-header">
|
||
<span class="question-number">第 {{ currentQuestionIndex + 1 }} 题</span>
|
||
<el-tag type="primary" size="small">选择题</el-tag>
|
||
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
|
||
</div>
|
||
<div class="question-body">
|
||
<p class="question-text">{{ currentQuestion.question }}</p>
|
||
<el-radio-group
|
||
v-model="currentAnswer"
|
||
class="options-group"
|
||
@change="onAnswerChange"
|
||
>
|
||
<div
|
||
v-for="(opt, oi) in currentOptions"
|
||
:key="oi"
|
||
class="option-item"
|
||
>
|
||
<el-radio :value="getOptionKey(opt, oi)">
|
||
<span class="option-label">{{ optionLabels[oi] }}.</span>
|
||
{{ getOptionText(opt, oi) }}
|
||
</el-radio>
|
||
</div>
|
||
</el-radio-group>
|
||
</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">
|
||
下一题
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 判断题 -->
|
||
<div v-if="isCurrentChoice && currentQuestion.question_type === 'judge'" class="question-card">
|
||
<div class="question-header">
|
||
<span class="question-number">第 {{ currentQuestionIndex + 1 }} 题</span>
|
||
<el-tag type="success" size="small">判断题</el-tag>
|
||
<span class="question-score">({{ currentQuestion.score || 0 }} 分)</span>
|
||
</div>
|
||
<div class="question-body">
|
||
<p class="question-text">{{ currentQuestion.question }}</p>
|
||
<el-radio-group
|
||
v-model="currentAnswer"
|
||
class="options-group"
|
||
@change="onAnswerChange"
|
||
>
|
||
<div class="option-item">
|
||
<el-radio value="true">正确</el-radio>
|
||
</div>
|
||
<div class="option-item">
|
||
<el-radio value="false">错误</el-radio>
|
||
</div>
|
||
</el-radio-group>
|
||
</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">
|
||
下一题
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 综合大题(中职/高职):题干富文本 + 三小问作答 -->
|
||
<div v-if="isCurrentBig" class="question-card big-card">
|
||
<div class="question-header">
|
||
<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" v-if="examStore.bigLoading">
|
||
<el-skeleton :rows="5" animated />
|
||
</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">
|
||
下一题
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div class="exam-main" v-else v-loading="loading">
|
||
<el-empty description="暂无题目,请确认考试时间是否已开始" :image-size="120" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 底部提交栏 ==================== -->
|
||
<div class="exam-bottom-bar" v-if="!isUploadMode">
|
||
<div class="exam-bottom-inner">
|
||
<span class="submit-hint">
|
||
{{ examStore.bigQuestionEnabled
|
||
? `请确保已检查所有题目和综合大题(已答 ${examStore.bigAnsweredCount}/${examStore.bigSubCount} 小问)后提交`
|
||
: '请确保已检查所有题目后提交' }}
|
||
</span>
|
||
<el-button
|
||
type="danger"
|
||
size="large"
|
||
v-guard="() => handleSubmitAll()"
|
||
:loading="submitting"
|
||
>
|
||
提交全部
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* Competition.vue - 在线答题页面
|
||
*
|
||
* 安全特性:
|
||
* - 禁止鼠标选中文字(selectstart + CSS user-select)
|
||
* - 禁止复制(copy 事件)
|
||
* - 禁止右键菜单(contextmenu 事件)
|
||
* - 禁止键盘快捷键(Ctrl+C/F12/Ctrl+Shift+I 等)
|
||
* - 浏览器刷新/关闭警告
|
||
* - 路由离开确认
|
||
* - 倒计时归零自动提交
|
||
*/
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||
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,
|
||
categoryLabel,
|
||
categoryTagType,
|
||
validateSingleFile,
|
||
finalComboCheck,
|
||
reconcileFiles,
|
||
} from '@/utils/uploadCombo'
|
||
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
const examStore = useExamStore()
|
||
|
||
// ==================== 本科/教师组:答案文件上传模式 ====================
|
||
const isUploadMode = computed(() => !['zhongzhi', 'gaozhi'].includes(authStore.userGroup))
|
||
const groupLabel = computed(() => ({ zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }[authStore.userGroup] || authStore.userGroup))
|
||
const uploadPanelLoading = ref(false)
|
||
const uploading = ref(false)
|
||
const uploadFileList = ref([]) // el-upload 绑定列表
|
||
const uploadedWorks = ref([])
|
||
const uploadRef = ref(null)
|
||
|
||
const ZIP_MAX = 200 * 1024 * 1024
|
||
const OTHER_MAX = 100 * 1024 * 1024
|
||
|
||
function fileTypeOf(name) {
|
||
const e = '.' + fileExt(name)
|
||
if (['.doc', '.docx', '.pdf'].includes(e)) return 'word'
|
||
if (['.ppt', '.pptx'].includes(e)) return 'ppt'
|
||
if (e === '.zip') return 'word' // zip 作为答案包,归入 word 评分项
|
||
return 'word'
|
||
}
|
||
|
||
const uploadComboValid = computed(() => {
|
||
const items = uploadFileList.value
|
||
.filter(f => !isOverLimit(f))
|
||
.map(f => ({ name: (f.raw || f).name, size: (f.raw || f).size }))
|
||
return finalComboCheck(items).ok
|
||
})
|
||
|
||
const pendingUploadFiles = computed(() => uploadFileList.value.filter(f => !f.uploaded && !isOverLimit(f)))
|
||
const hasOverLimitFile = computed(() => uploadFileList.value.some(f => isOverLimit(f)))
|
||
|
||
// 单文件是否超过上传大小限制(zip 200MB / 其他 100MB,来自 uploadCombo.validateSingleFile)
|
||
function isOverLimit(file) {
|
||
const f = file.raw || file
|
||
return validateSingleFile(f.name, f.size) !== null
|
||
}
|
||
|
||
// 选择文件时自动覆盖/互斥校验:新加入的文件覆盖冲突的旧文件(zip 独占;pdf↔docx 互斥)
|
||
function onUploadFileChange(file, fileList) {
|
||
const files = fileList.map(f => ({ uid: f.uid, name: (f.raw || f).name, size: (f.raw || f).size }))
|
||
const added = { uid: file.uid, name: (file.raw || file).name, size: (file.raw || file).size }
|
||
const r = reconcileFiles(files, added)
|
||
// 保留全部已选文件(含超限文件,便于以红色高亮提示);仅剔除被组合规则覆盖的旧文件
|
||
const keepUids = new Set(r.list.map(x => x.uid))
|
||
uploadFileList.value = fileList.filter(f => keepUids.has(f.uid) || isOverLimit(f))
|
||
// 超限文件明确高亮提示(不静默丢弃,符合"超限要有明显样式提醒")
|
||
const over = uploadFileList.value.filter(f => isOverLimit(f))
|
||
if (over.length) {
|
||
const names = over.map(f => (f.raw || f).name).join('、')
|
||
ElMessage.error(`文件「${names}」已超过大小限制(zip 200MB / 其他 100MB),已标红且无法提交`)
|
||
} else if (r.dropped.length) {
|
||
ElMessage.info('已按规则自动覆盖冲突文件(zip 独占 / pdf 与 docx 互斥)')
|
||
}
|
||
if (!r.valid) ElMessage.error(r.error)
|
||
}
|
||
|
||
// 从待上传列表中删除单个文件(Issue ⑨:传错可手动删除并重传)
|
||
function removePendingFile(f) {
|
||
uploadFileList.value = uploadFileList.value.filter(x => x.uid !== f.uid)
|
||
}
|
||
|
||
function clearUploadFiles() {
|
||
uploadFileList.value = []
|
||
uploadRef.value?.clearFiles?.()
|
||
}
|
||
|
||
async function submitUploadFiles() {
|
||
const files = pendingUploadFiles.value
|
||
if (!files.length) return
|
||
const items = files.map(f => ({ name: (f.raw || f).name, size: (f.raw || f).size }))
|
||
const check = finalComboCheck(items)
|
||
if (!check.ok) { ElMessage.error(check.error); return }
|
||
uploading.value = true
|
||
try {
|
||
// 处理与已上传文件的覆盖关系(zip 独占等,Issue ⑫)
|
||
const pendingCats = files.map(f => categoryOf((f.raw || f).name))
|
||
const pendingHasZip = pendingCats.includes('zip')
|
||
if (pendingHasZip) {
|
||
// zip 覆盖一切:先删除服务端所有已上传文件
|
||
for (const w of uploadedWorks.value.slice()) await deleteUploadedWork(w, true)
|
||
} else {
|
||
// 本次不含 zip,但若服务端有 zip,则删除服务端 zip(被新文件覆盖)
|
||
const serverZip = uploadedWorks.value.find(w => w.category === 'zip')
|
||
if (serverZip) await deleteUploadedWork(serverZip, true)
|
||
}
|
||
for (const f of files) {
|
||
const raw = f.raw || f
|
||
const formData = new FormData()
|
||
// Egg stream 模式要求「文本字段在文件字段之前」,否则 file_type 会被丢弃,
|
||
// 导致后端把作品类型默认当成 word(影响本科/教师组的文件类型判定)
|
||
formData.append('file_type', fileTypeOf(raw.name))
|
||
formData.append('file', raw)
|
||
await uploadApi.uploadWork(authStore.userGroup, formData)
|
||
}
|
||
ElMessage.success('上传成功,作品已提交')
|
||
uploadFileList.value = []
|
||
uploadRef.value?.clearFiles?.()
|
||
await fetchUploadedWorks()
|
||
examStore.stopExam()
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || '上传失败')
|
||
return
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
// 成功提示与返回(点击 × 关闭弹窗不视为上传失败)
|
||
try {
|
||
await ElMessageBox.alert('您的作品已成功提交,感谢您的参与!', '提交成功', {
|
||
confirmButtonText: '返回工作台',
|
||
type: 'success',
|
||
})
|
||
} catch (e) {
|
||
/* 用户关闭弹窗,忽略 */
|
||
}
|
||
router.push('/dashboard')
|
||
}
|
||
|
||
// 删除已上传到服务端的作品文件(Issue ⑨)
|
||
async function deleteUploadedWork(w, silent = false) {
|
||
if (w.deleting) return
|
||
w.deleting = true
|
||
try {
|
||
await uploadApi.deleteWork(authStore.userGroup, w.id)
|
||
uploadedWorks.value = uploadedWorks.value.filter(x => x.id !== w.id)
|
||
if (!silent) ElMessage.success('已删除')
|
||
} catch (e) {
|
||
ElMessage.error(e?.message || '删除失败')
|
||
} finally {
|
||
w.deleting = false
|
||
}
|
||
}
|
||
|
||
async function fetchUploadedWorks() {
|
||
try {
|
||
const res = await uploadApi.getSubmissions(authStore.userGroup)
|
||
const list = res?.list || []
|
||
uploadedWorks.value = list.map(s => ({ id: s.id, fileName: s.file_name, category: categoryOf(s.file_name) }))
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// ==================== 状态 ====================
|
||
|
||
const loading = ref(true)
|
||
const submitting = ref(false)
|
||
const currentQuestionIndex = ref(0)
|
||
|
||
// ==================== 计算属性 ====================
|
||
|
||
// 综合大题作为最后一道「题」参与导航与题量统计(中职/高职启用,本科/教师不启用)
|
||
const totalCount = computed(() => {
|
||
const base = examStore.questions.length
|
||
return examStore.bigQuestionEnabled ? base + 1 : base
|
||
})
|
||
|
||
// 综合大题的虚拟索引(最后一个)
|
||
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
|
||
// 综合大题按「三小问全部作答」计为 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(() => {
|
||
if (totalCount.value === 0) return 0
|
||
return Math.round((answeredCount.value / totalCount.value) * 100)
|
||
})
|
||
|
||
const progressColor = computed(() => {
|
||
if (progressPercent.value >= 80) return '#67c23a'
|
||
if (progressPercent.value >= 50) return '#409eff'
|
||
return '#e6a23c'
|
||
})
|
||
|
||
const currentQuestion = computed(() => {
|
||
return examStore.questions[currentQuestionIndex.value] || {}
|
||
})
|
||
|
||
const isCurrentChoice = computed(() => {
|
||
return currentQuestionIndex.value < examStore.questions.length
|
||
})
|
||
|
||
const currentOptions = computed(() => {
|
||
const q = currentQuestion.value
|
||
return q.options || []
|
||
})
|
||
|
||
const currentAnswer = computed({
|
||
get() {
|
||
const qId = currentQuestion.value.question_uid
|
||
return examStore.answers[qId] || ''
|
||
},
|
||
set(val) {
|
||
const qId = currentQuestion.value.question_uid
|
||
examStore.submitAnswer(qId, val)
|
||
},
|
||
})
|
||
|
||
const optionLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
|
||
|
||
// ==================== 选项辅助函数 ====================
|
||
|
||
function getOptionKey(opt, index) {
|
||
if (typeof opt === 'object' && opt !== null) {
|
||
return opt.key || opt.value || String(index)
|
||
}
|
||
return String(opt)
|
||
}
|
||
|
||
function getOptionText(opt, index) {
|
||
if (typeof opt === 'object' && opt !== null) {
|
||
return opt.text || opt.label || opt.key || String(opt)
|
||
}
|
||
return String(opt)
|
||
}
|
||
|
||
// ==================== 导航方法 ====================
|
||
|
||
function findQuestionIndex(questionUid) {
|
||
return examStore.questions.findIndex(q => q.question_uid === questionUid)
|
||
}
|
||
|
||
function goToQuestion(index) {
|
||
if (index >= 0 && index < totalCount.value) {
|
||
currentQuestionIndex.value = index
|
||
}
|
||
}
|
||
|
||
function isAnswered(qId) {
|
||
return examStore.answers[qId] !== undefined && examStore.answers[qId] !== ''
|
||
}
|
||
|
||
// ==================== 答题方法 ====================
|
||
|
||
function onAnswerChange(val) {
|
||
const qId = currentQuestion.value.question_uid
|
||
examStore.submitAnswer(qId, val)
|
||
}
|
||
|
||
function prevQuestion() {
|
||
if (currentQuestionIndex.value > 0) {
|
||
currentQuestionIndex.value--
|
||
}
|
||
}
|
||
|
||
function nextQuestion() {
|
||
if (currentQuestionIndex.value < totalCount.value - 1) {
|
||
currentQuestionIndex.value++
|
||
}
|
||
}
|
||
|
||
// ==================== 综合大题(中职/高职) ====================
|
||
|
||
/** 取第 index 小问的当前答案(空值统一为 '',避免 el-input 出现 undefined 告警) */
|
||
function bigAnswerOf(index) {
|
||
const v = examStore.bigAnswers[`answer_${index}`]
|
||
return v == null ? '' : v
|
||
}
|
||
|
||
/** 输入即写本地 + 防抖自动保存(落库由 store 统一处理) */
|
||
function onBigAnswerInput(index, value) {
|
||
examStore.setBigAnswer(index, value)
|
||
}
|
||
|
||
/** 抽题/加载失败后的手动重试 */
|
||
async function retryBigQuestion() {
|
||
const ok = await examStore.loadBigQuestion(examStore.currentGroup)
|
||
if (ok) {
|
||
ElMessage.success('综合大题已加载')
|
||
} else {
|
||
ElMessage.error(examStore.bigError || '综合大题加载失败')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 交卷前确保综合大题答案已落库
|
||
* @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' }
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
try {
|
||
// 先把综合大题答案落库(submit-all 只处理客观题与提交时间),失败即中止交卷
|
||
const saved = await ensureBigAnswersSaved()
|
||
if (!saved) return
|
||
|
||
await examStore.submitAll({})
|
||
ElMessage.success('提交成功!')
|
||
} catch (err) {
|
||
if (err?.message) ElMessage.error(err.message)
|
||
return
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
// 成功提示与返回(点击 × 关闭弹窗不视为失败)
|
||
try {
|
||
await ElMessageBox.alert('您的答案已成功提交,感谢您的参与!', '提交成功', {
|
||
confirmButtonText: '返回工作台',
|
||
type: 'success',
|
||
})
|
||
} catch (e) {
|
||
/* 用户关闭弹窗,忽略 */
|
||
}
|
||
router.push('/dashboard')
|
||
}
|
||
|
||
/** 自动提交(倒计时归零):考试组自动保存并交卷;提交组封锁上传并返回工作台 */
|
||
const autoSubmitted = ref(false)
|
||
async function autoSubmit() {
|
||
if (autoSubmitted.value) return
|
||
autoSubmitted.value = true
|
||
if (isUploadMode.value) {
|
||
// 提交组(本科/教师):作品已在每次上传时保存,时间到直接封锁并返回
|
||
ElMessage.warning('提交时间已到,系统将自动保存您的作品并返回')
|
||
examStore.stopExam()
|
||
router.push('/dashboard')
|
||
return
|
||
}
|
||
ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案')
|
||
try {
|
||
// forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门,
|
||
// 确保最后输入的大题答案与 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) {
|
||
autoSubmitted.value = false
|
||
console.error('自动提交失败', err)
|
||
ElMessage.error('自动提交失败,请手动点击「提交全部」')
|
||
}
|
||
}
|
||
|
||
// ==================== 监听倒计时 ====================
|
||
|
||
watch(
|
||
() => examStore.isTimeUp,
|
||
(isTimeUp) => {
|
||
if (isTimeUp) {
|
||
autoSubmit()
|
||
}
|
||
}
|
||
)
|
||
|
||
// 倒计时临近结束提醒(仅提示一次,避免频繁弹窗打扰)
|
||
// 考试组与提交组均生效;新增 10 分钟档;文案按组别使用「考试 / 提交」
|
||
const warnedEarlyTime = ref(false)
|
||
const warnedLowTime = ref(false)
|
||
const warnedCriticalTime = ref(false)
|
||
watch(
|
||
() => remainingSeconds.value,
|
||
(sec) => {
|
||
if (!examStore.examStarted) return
|
||
const verb = isUploadMode.value ? '提交' : '考试'
|
||
if (!warnedEarlyTime.value && sec > 0 && sec <= 600) {
|
||
warnedEarlyTime.value = true
|
||
ElNotification({
|
||
title: `${verb}时间提醒`,
|
||
message: `据${verb}结束还有 10 分钟,请及时核对并保存好您的答案`,
|
||
type: 'info',
|
||
duration: 8000,
|
||
position: 'top-right',
|
||
})
|
||
}
|
||
if (!warnedLowTime.value && sec > 0 && sec <= 300) {
|
||
warnedLowTime.value = true
|
||
ElNotification({
|
||
title: `${verb}时间提醒`,
|
||
message: `据${verb}结束还有 5 分钟,请及时核对并保存好您的答案`,
|
||
type: 'warning',
|
||
duration: 8000,
|
||
position: 'top-right',
|
||
})
|
||
}
|
||
if (!warnedCriticalTime.value && sec > 0 && sec <= 60) {
|
||
warnedCriticalTime.value = true
|
||
ElNotification({
|
||
title: `${verb}时间提醒`,
|
||
message: `据${verb}结束仅剩 1 分钟,请及时核对并保存好您的答案`,
|
||
type: 'error',
|
||
duration: 8000,
|
||
position: 'top-right',
|
||
})
|
||
}
|
||
}
|
||
)
|
||
|
||
// ==================== 安全防护:禁止选中、复制、右键、键盘快捷键 ====================
|
||
|
||
// 浏览器刷新/关闭警告
|
||
function handleBeforeUnload(e) {
|
||
if (examStore.examStarted) {
|
||
e.preventDefault()
|
||
e.returnValue = ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 事件目标是否为「可编辑元素」(综合大题的作答输入框)
|
||
*
|
||
* 考试页为保证题目不被外传,全局禁用了选中/复制/右键。但综合大题要求考生
|
||
* 在输入框内自行作答,若沿用同一套拦截会导致无法选中、修改、粘贴自己写的内容。
|
||
* 因此对可编辑元素做最小豁免:只放行「编辑自身答案」所需的操作,
|
||
* 题目区域(非可编辑)的防护行为完全不变。
|
||
*/
|
||
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(开发者工具)—— 任何位置均拦截
|
||
if (e.key === 'F12' || e.keyCode === 123) {
|
||
e.preventDefault()
|
||
return false
|
||
}
|
||
|
||
// 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' ||
|
||
e.key === 'J' || e.key === 'j') {
|
||
e.preventDefault()
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 以下剪贴/保存/打印类快捷键:仅在可编辑元素内放行(考生编辑自身答案所需)
|
||
if (isEditableTarget(e)) return
|
||
|
||
// Ctrl+C(复制)
|
||
if (e.ctrlKey && (e.key === 'C' || e.key === 'c' || e.keyCode === 67)) {
|
||
e.preventDefault()
|
||
return false
|
||
}
|
||
|
||
// Ctrl+U(查看源代码)
|
||
if (e.ctrlKey && (e.key === 'U' || e.key === 'u' || e.keyCode === 85)) {
|
||
e.preventDefault()
|
||
return false
|
||
}
|
||
|
||
// Ctrl+S(保存页面)
|
||
if (e.ctrlKey && (e.key === 'S' || e.key === 's' || e.keyCode === 83)) {
|
||
e.preventDefault()
|
||
return false
|
||
}
|
||
|
||
// Ctrl+P(打印)
|
||
if (e.ctrlKey && (e.key === 'P' || e.key === 'p' || e.keyCode === 80)) {
|
||
e.preventDefault()
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 路由离开警告
|
||
onBeforeRouteLeave((to, from, next) => {
|
||
if (examStore.examStarted && !examStore.isTimeUp) {
|
||
const verb = isUploadMode.value ? '提交' : '答题'
|
||
ElMessageBox.confirm(
|
||
`离开页面将丢失当前${verb}进度,确定要离开吗?`,
|
||
'警告',
|
||
{ confirmButtonText: '确定离开', cancelButtonText: `继续${verb}`, type: 'warning' }
|
||
).then(() => {
|
||
examStore.resetExam()
|
||
next()
|
||
}).catch(() => {
|
||
next(false)
|
||
})
|
||
} else {
|
||
next()
|
||
}
|
||
})
|
||
|
||
// ==================== 生命周期 ====================
|
||
|
||
function attachSecurityListeners() {
|
||
document.addEventListener('copy', preventCopy)
|
||
document.addEventListener('contextmenu', preventCopy)
|
||
document.addEventListener('selectstart', preventSelect)
|
||
document.addEventListener('dragstart', preventCopy)
|
||
document.addEventListener('keydown', preventKeyboard)
|
||
}
|
||
|
||
function removeSecurityListeners() {
|
||
document.removeEventListener('copy', preventCopy)
|
||
document.removeEventListener('contextmenu', preventCopy)
|
||
document.removeEventListener('selectstart', preventSelect)
|
||
document.removeEventListener('dragstart', preventCopy)
|
||
document.removeEventListener('keydown', preventKeyboard)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// 附加安全防护
|
||
attachSecurityListeners()
|
||
|
||
const group = authStore.userGroup
|
||
const isExam = ['zhongzhi', 'gaozhi'].includes(group)
|
||
|
||
loading.value = true
|
||
try {
|
||
// 统一入口校验:考试组(中职/高职)与提交组(本科/教师)一致——
|
||
// 仅「截止后(status==='ended')」禁止进入;考试时间内(含已提交 / 意外退出)均可重新进入,
|
||
// 应对断网、断电、误操作导致的意外退出(重进后由下方逻辑恢复已答题目)。
|
||
const status = await examApi.getExamStatus(group)
|
||
if (status.status === 'ended') {
|
||
ElMessage.warning(isExam ? '考试已结束,无法再次进入' : '作品提交已截止,无法再次进入')
|
||
router.push('/dashboard')
|
||
return
|
||
}
|
||
|
||
if (isExam) {
|
||
// 截止前可随意重新进入(含已提交 / 意外退出后重进),与顶部注释一致;
|
||
// 仅「截止后(status==='ended')」禁止进入(见上方判断)。考试组仍受团队锁约束,
|
||
// 同一时刻仅一名成员在考,避免重复进入互相干扰。
|
||
// 考试组(中职/高职):检查团队锁与活跃状态后进入考试
|
||
if (status.teamLocked) {
|
||
ElMessage.warning(`您的小组已由 ${status.teamLockedBy || '队友'} 进入考试,不能重复进入`)
|
||
router.push('/dashboard')
|
||
return
|
||
}
|
||
if (!status.isActive) {
|
||
ElMessage.warning(status.message || '考试未在进行中,请等待考试开始')
|
||
router.push('/dashboard')
|
||
return
|
||
}
|
||
|
||
// 检查 sessionStorage 中是否有考试 session
|
||
const savedSession = sessionStorage.getItem('exam_session')
|
||
if (savedSession) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'检测到您已有正在进行的考试,是否继续之前的考试?(点击"取消"将返回首页)',
|
||
'考试提示',
|
||
{ confirmButtonText: '继续考试', cancelButtonText: '返回首页', type: 'warning' }
|
||
)
|
||
} catch {
|
||
sessionStorage.removeItem('exam_session')
|
||
router.push('/dashboard')
|
||
return
|
||
}
|
||
}
|
||
|
||
await examStore.startExam(group)
|
||
|
||
// 客观题与综合大题都为空才算加载失败;只抽到大题(客观题库为空)时仍可正常作答
|
||
if (examStore.questions.length === 0 && !examStore.bigLoaded) {
|
||
ElMessage.error(examStore.bigError || '题目加载失败,请刷新页面重试')
|
||
router.push('/dashboard')
|
||
return
|
||
}
|
||
|
||
// 恢复已有答案:考试组在「已提交 / 意外退出后重进」时始终尝试恢复,
|
||
// 避免断网、断电或误操作退出导致答题进度丢失(仅重载答案,不返回评分与对错)。
|
||
try {
|
||
const answersData = await examApi.getAnswers(group)
|
||
if (answersData && answersData.answers && answersData.answers.length > 0) {
|
||
const restored = {}
|
||
answersData.answers.forEach(a => {
|
||
if (a.answer_data) {
|
||
restored[a.question_uid] = a.answer_data
|
||
}
|
||
})
|
||
if (Object.keys(restored).length > 0) {
|
||
examStore.restoreAnswers(restored)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('恢复已有答案失败', e)
|
||
}
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
} else {
|
||
// 提交组(本科/教师):无客观题,进入「答案文件上传」模式并启动提交倒计时
|
||
uploadPanelLoading.value = true
|
||
try {
|
||
await fetchUploadedWorks()
|
||
} finally {
|
||
uploadPanelLoading.value = false
|
||
}
|
||
examStore.startUploadCountdown(group)
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
}
|
||
} catch (err) {
|
||
ElMessage.error('加载考试信息失败')
|
||
router.push('/dashboard')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
removeSecurityListeners()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.competition-page {
|
||
min-height: 100vh;
|
||
background: #f0f2f5;
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* 考试页面禁止选中和复制文字 */
|
||
-webkit-user-select: none;
|
||
-moz-user-select: none;
|
||
-ms-user-select: none;
|
||
user-select: none;
|
||
}
|
||
|
||
/* ==================== 顶部栏 ==================== */
|
||
.exam-top-bar {
|
||
background: #fff;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
padding: 0.75rem 1.5rem;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
.exam-top-bar.time-critical {
|
||
background: #fef0f0;
|
||
border-bottom-color: #f56c6c;
|
||
}
|
||
.exam-top-inner {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2rem;
|
||
}
|
||
.exam-title {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
.exam-timer {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
color: #606266;
|
||
}
|
||
.timer-display {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
color: #303133;
|
||
}
|
||
.timer-danger {
|
||
color: #f56c6c;
|
||
animation: pulse 1s infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
.exam-progress {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
.exam-progress span {
|
||
font-size: 0.9rem;
|
||
color: #909399;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ==================== 主布局 ==================== */
|
||
.exam-body {
|
||
flex: 1;
|
||
display: flex;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
width: 100%;
|
||
padding: 1rem 1.5rem;
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
/* ==================== 左侧导航 ==================== */
|
||
.exam-sidebar {
|
||
width: 200px;
|
||
flex-shrink: 0;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
border: 1px solid #e4e7ed;
|
||
max-height: calc(100vh - 160px);
|
||
overflow-y: auto;
|
||
}
|
||
.sidebar-title {
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
margin: 0 0 0.75rem 0;
|
||
}
|
||
.nav-section {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.nav-section-title {
|
||
font-size: 0.75rem;
|
||
color: #909399;
|
||
margin-bottom: 0.5rem;
|
||
padding-left: 2px;
|
||
}
|
||
.question-nav {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.nav-item {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 6px;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
background: #f5f7fa;
|
||
color: #909399;
|
||
border: 1px solid #e4e7ed;
|
||
transition: all 0.2s;
|
||
user-select: none;
|
||
}
|
||
.nav-item:hover {
|
||
border-color: #409eff;
|
||
color: #409eff;
|
||
}
|
||
.nav-answered {
|
||
background: #ecf5ff;
|
||
color: #409eff;
|
||
border-color: #b3d8ff;
|
||
}
|
||
.nav-current {
|
||
background: #67c23a;
|
||
color: #fff;
|
||
border-color: #67c23a;
|
||
}
|
||
.sidebar-legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
margin-top: 0.75rem;
|
||
padding-top: 0.75rem;
|
||
border-top: 1px solid #ebeef5;
|
||
}
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 0.7rem;
|
||
color: #909399;
|
||
}
|
||
.legend-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
.legend-dot.answered { background: #409eff; }
|
||
.legend-dot.current { background: #67c23a; }
|
||
.legend-dot.unanswered { background: #e4e7ed; }
|
||
|
||
/* ==================== 右侧题目区 ==================== */
|
||
.exam-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.question-card {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 1.5rem 2rem;
|
||
border: 1px solid #e4e7ed;
|
||
}
|
||
.question-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1.25rem;
|
||
padding-bottom: 0.75rem;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
.question-number {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
.question-score {
|
||
font-size: 0.85rem;
|
||
color: #909399;
|
||
}
|
||
.question-text {
|
||
font-size: 1rem;
|
||
line-height: 1.8;
|
||
color: #303133;
|
||
margin: 0 0 1.5rem 0;
|
||
}
|
||
.options-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
width: 100%;
|
||
}
|
||
.option-item {
|
||
padding: 0.6rem 1rem;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 6px;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.option-item:hover {
|
||
border-color: #409eff;
|
||
}
|
||
.option-label {
|
||
font-weight: 600;
|
||
margin-right: 0.25rem;
|
||
color: #409eff;
|
||
}
|
||
.question-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-top: 1.5rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #ebeef5;
|
||
}
|
||
|
||
/* ==================== 综合大题卡片 ==================== */
|
||
.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;
|
||
}
|
||
.big-sub-score {
|
||
font-size: 0.85rem;
|
||
color: #909399;
|
||
}
|
||
.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;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-top: 0.75rem;
|
||
padding: 0.5rem 0.75rem;
|
||
background: #f0f9eb;
|
||
border-radius: 6px;
|
||
font-size: 0.85rem;
|
||
}
|
||
.uploaded-name {
|
||
color: #303133;
|
||
font-weight: 500;
|
||
}
|
||
.uploaded-size {
|
||
color: #909399;
|
||
}
|
||
.pending-list {
|
||
margin-top: 1rem;
|
||
}
|
||
.pending-list-title {
|
||
font-size: 0.85rem;
|
||
color: #606266;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.pending-item {
|
||
justify-content: space-between;
|
||
}
|
||
|
||
/* 超出上传大小限制:明显的高亮警告样式(Issue ②) */
|
||
.file-over-limit {
|
||
background: #fef0f0;
|
||
border: 1px solid #f56c6c;
|
||
border-radius: 6px;
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
.file-over-limit .uploaded-name {
|
||
color: #f56c6c;
|
||
font-weight: 600;
|
||
}
|
||
.size-over {
|
||
color: #f56c6c !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ==================== 底部提交栏 ==================== */
|
||
.exam-bottom-bar {
|
||
background: #fff;
|
||
border-top: 1px solid #e4e7ed;
|
||
padding: 0.75rem 1.5rem;
|
||
position: sticky;
|
||
bottom: 0;
|
||
z-index: 100;
|
||
}
|
||
.exam-bottom-inner {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.submit-hint {
|
||
font-size: 0.85rem;
|
||
color: #909399;
|
||
}
|
||
|
||
/* ==================== 响应式 ==================== */
|
||
@media (max-width: 768px) {
|
||
.exam-body {
|
||
flex-direction: column;
|
||
padding: 0.75rem;
|
||
}
|
||
.exam-sidebar {
|
||
width: 100%;
|
||
max-height: none;
|
||
}
|
||
.question-card {
|
||
padding: 1rem;
|
||
}
|
||
.exam-top-inner {
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
}
|
||
</style> |