1458 lines
47 KiB
Vue
1458 lines
47 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.wordTemplate">
|
|||
|
|
<div class="nav-section-title">Word 答题</div>
|
|||
|
|
<div class="question-nav">
|
|||
|
|
<div
|
|||
|
|
class="nav-item"
|
|||
|
|
:class="{
|
|||
|
|
'nav-answered': wordUploaded || uploadedWordFile,
|
|||
|
|
'nav-current': currentQuestionIndex === wordQuestionIndex,
|
|||
|
|
}"
|
|||
|
|
@click="currentQuestionIndex = wordQuestionIndex"
|
|||
|
|
>
|
|||
|
|
{{ wordQuestionIndex + 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="examStore.questions.length > 0">
|
|||
|
|
<!-- 选择题 -->
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<!-- Word 答题 -->
|
|||
|
|
<div v-if="currentQuestionIndex === wordQuestionIndex" class="question-card word-card">
|
|||
|
|
<div class="question-header">
|
|||
|
|
<span class="question-number">第 {{ wordQuestionIndex + 1 }} 题</span>
|
|||
|
|
<el-tag type="danger" size="small">综合题</el-tag>
|
|||
|
|
</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 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>
|
|||
|
|
</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">
|
|||
|
|
请确保已检查所有题目和 Word 文件后提交
|
|||
|
|
</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, Download, 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 {
|
|||
|
|
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
|
|||
|
|
const WORD_MAX = 50 * 1024 * 1024 // 与后端 uploadWord 服务端 50MB 上限一致
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
// 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
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Word 题目的虚拟索引(最后一个)
|
|||
|
|
const wordQuestionIndex = computed(() => examStore.questions.length)
|
|||
|
|
|
|||
|
|
const answeredCount = computed(() => {
|
|||
|
|
let count = Object.keys(examStore.answers).length
|
|||
|
|
if (wordUploaded.value || uploadedWordFile.value) count++
|
|||
|
|
return count
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
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++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 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 || '')}`
|
|||
|
|
|
|||
|
|
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') || ''
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 提交全部 ====================
|
|||
|
|
|
|||
|
|
async function handleSubmitAll() {
|
|||
|
|
try {
|
|||
|
|
await ElMessageBox.confirm(
|
|||
|
|
'确定要提交全部答案吗?提交后将无法修改,请确保所有题目已作答完成。',
|
|||
|
|
'确认提交',
|
|||
|
|
{ confirmButtonText: '确认提交', cancelButtonText: '再检查一下', type: 'warning' }
|
|||
|
|
)
|
|||
|
|
} catch {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
submitting.value = true
|
|||
|
|
try {
|
|||
|
|
if (wordFileList.value.length > 0 && !uploadedWordFile.value) {
|
|||
|
|
await uploadWordFile()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const additionalData = {}
|
|||
|
|
if (uploadedWordFile.value) {
|
|||
|
|
additionalData.wordFile = uploadedWordFile.value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await examStore.submitAll(additionalData)
|
|||
|
|
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 {
|
|||
|
|
// 若尚有未上传的 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)
|
|||
|
|
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 preventCopy(e) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 禁止鼠标选中文字
|
|||
|
|
function preventSelect(e) {
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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) {
|
|||
|
|
ElMessage.error('题目加载失败,请刷新页面重试')
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 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;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin: 0 0 0.75rem 0;
|
|||
|
|
color: #303133;
|
|||
|
|
}
|
|||
|
|
.word-upload {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
.word-progress {
|
|||
|
|
margin-top: 0.75rem;
|
|||
|
|
}
|
|||
|
|
.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>
|