|
|
@@ -3,25 +3,25 @@
|
|
|
<el-aside width="280px" class="exam-aside">
|
|
|
<div class="aside-card timer-box">
|
|
|
<div class="timer-title"><i class="el-icon-time" /> 剩余时间</div>
|
|
|
- <div class="timer-display" :class="{ 'time-danger': remainTime < 300 }">
|
|
|
+ <div class="timer-display" :class="{ 'time-danger': !timeLoading && remainTime < 300 }">
|
|
|
{{ formatRemainTime }}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="aside-card answer-card">
|
|
|
<h4 class="card-title">答题卡</h4>
|
|
|
- <div v-for="(group, gIndex) in examData.questionGroups" :key="gIndex" class="card-group">
|
|
|
- <div class="group-label">{{ group.groupTitle }}</div>
|
|
|
+ <div v-for="(section, sIndex) in sectionsList" :key="sIndex" class="card-group">
|
|
|
+ <div class="group-label">{{ section.sectionName }}</div>
|
|
|
<div class="number-grid">
|
|
|
<span
|
|
|
- v-for="(q, qIndex) in group.questions"
|
|
|
- :key="q.id"
|
|
|
+ v-for="q in section.flatQuestions"
|
|
|
+ :key="q.questionId"
|
|
|
class="number-item"
|
|
|
:class="{
|
|
|
'is-answered': checkIsAnswered(q),
|
|
|
- 'is-active': currentActiveId === q.id
|
|
|
+ 'is-active': currentActiveId === q.questionId
|
|
|
}"
|
|
|
- @click="scrollToQuestion(q.id)"
|
|
|
+ @click="scrollToQuestion(q.questionId)"
|
|
|
>
|
|
|
{{ q.number }}
|
|
|
</span>
|
|
|
@@ -36,66 +36,134 @@
|
|
|
|
|
|
<el-main class="exam-main">
|
|
|
<div class="paper-header">
|
|
|
- <h2 class="paper-title">{{ examData.title }}</h2>
|
|
|
+ <h2 class="paper-title">{{ paperInfo.title }}</h2>
|
|
|
<div class="paper-meta">
|
|
|
- <span>总分:{{ examData.totalScore }}分</span>
|
|
|
- <span>及格线:{{ examData.passingScore }}分</span>
|
|
|
- <span>考试科目:{{ examData.subject }}</span>
|
|
|
+ <span>考试科目:{{ paperInfo.subjectName || '未指定' }}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div v-for="(group, gIndex) in examData.questionGroups" :key="gIndex" class="question-group-section">
|
|
|
+ <div v-for="(section, sIndex) in sectionsList" :key="sIndex" class="question-group-section">
|
|
|
<h3 class="group-header-title">
|
|
|
- {{ group.groupTitle }} <span class="group-desc">(每题 {{ group.scorePerQuestion }} 分)</span>
|
|
|
+ {{ section.sectionName }} <span class="group-desc">({{ section.sectionHint }})</span>
|
|
|
</h3>
|
|
|
|
|
|
<div
|
|
|
- v-for="(q, qIndex) in group.questions"
|
|
|
- :key="q.id"
|
|
|
- :id="'q-' + q.id"
|
|
|
+ v-for="q in section.questions"
|
|
|
+ :id="'q-' + q.questionId"
|
|
|
+ :key="q.questionId"
|
|
|
class="question-card"
|
|
|
- @mouseenter="currentActiveId = q.id"
|
|
|
+ @mouseenter="currentActiveId = q.questionId"
|
|
|
>
|
|
|
- <div class="question-stem">
|
|
|
- <span class="q-number">{{ q.number }}.</span>
|
|
|
- <span class="q-text">{{ q.stem }}</span>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="question-body">
|
|
|
- <template v-if="q.type === 'SINGLE_CHOICE'">
|
|
|
- <el-radio-group v-model="answers[q.id]">
|
|
|
- <el-radio v-for="opt in q.options" :key="opt.key" :label="opt.key" class="option-item">
|
|
|
- <span class="option-key">{{ opt.key }}.</span> {{ opt.value }}
|
|
|
+ <template v-if="q.typeCode === 1">
|
|
|
+ <div class="question-stem">
|
|
|
+ <span class="q-number">{{ q.number }}.</span>
|
|
|
+ <span class="q-text" v-html="formatStem(q.content, q.score)" />
|
|
|
+ </div>
|
|
|
+ <div class="question-body">
|
|
|
+ <el-radio-group v-model="answers[q.questionId]" @change="handleAnswerChange(q.questionId)">
|
|
|
+ <el-radio v-for="opt in q.options" :key="opt.pos" :label="opt.pos" class="option-item">
|
|
|
+ {{ opt.content }}
|
|
|
</el-radio>
|
|
|
</el-radio-group>
|
|
|
- </template>
|
|
|
-
|
|
|
- <template v-else-if="q.type === 'MULTIPLE_CHOICE'">
|
|
|
- <el-checkbox-group v-model="answers[q.id]">
|
|
|
- <el-checkbox v-for="opt in q.options" :key="opt.key" :label="opt.key" class="option-item">
|
|
|
- <span class="option-key">{{ opt.key }}.</span> {{ opt.value }}
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else-if="q.typeCode === 2">
|
|
|
+ <div class="question-stem">
|
|
|
+ <span class="q-number">{{ q.number }}.</span>
|
|
|
+ <span class="q-text" v-html="formatStem(q.content, q.score)" />
|
|
|
+ <el-tag type="warning" size="mini" class="type-tag">多选</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="question-body">
|
|
|
+ <el-checkbox-group v-model="answers[q.questionId]" @change="handleAnswerChange(q.questionId)">
|
|
|
+ <el-checkbox v-for="opt in q.options" :key="opt.pos" :label="opt.pos" class="option-item checkbox-item">
|
|
|
+ {{ opt.content }}
|
|
|
</el-checkbox>
|
|
|
</el-checkbox-group>
|
|
|
- </template>
|
|
|
-
|
|
|
- <template v-else-if="q.type === 'TRUE_FALSE'">
|
|
|
- <el-radio-group v-model="answers[q.id]">
|
|
|
- <el-radio label="T" class="option-item">正确</el-radio>
|
|
|
- <el-radio label="F" class="option-item">错误</el-radio>
|
|
|
- </el-radio-group>
|
|
|
- </template>
|
|
|
-
|
|
|
- <template v-else-if="q.type === 'SHORT_ANSWER'">
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else-if="q.typeCode === 4">
|
|
|
+ <div class="question-stem">
|
|
|
+ <span class="q-number">{{ q.number }}.</span>
|
|
|
+ <span class="q-text" v-html="formatStem(q.content, q.score)" />
|
|
|
+ </div>
|
|
|
+ <div class="question-body completion-body">
|
|
|
+ <div v-for="(item, index) in answers[q.questionId]" :key="index" class="completion-item">
|
|
|
+ <span class="completion-prefix">第 {{ index + 1 }} 空:</span>
|
|
|
+ <el-input
|
|
|
+ v-model="answers[q.questionId][index]"
|
|
|
+ size="small"
|
|
|
+ placeholder="请输入答案"
|
|
|
+ @change="handleAnswerChange(q.questionId)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else-if="q.typeCode === 5">
|
|
|
+ <div class="question-stem">
|
|
|
+ <span class="q-number">{{ q.number }}.</span>
|
|
|
+ <span class="q-text" v-html="formatStem(q.content, q.score)" />
|
|
|
+ </div>
|
|
|
+ <div class="question-body">
|
|
|
<el-input
|
|
|
- v-model="answers[q.id]"
|
|
|
+ v-model="answers[q.questionId]"
|
|
|
type="textarea"
|
|
|
- :rows="4"
|
|
|
- placeholder="请输入您的作答内容..."
|
|
|
- maxlength="500"
|
|
|
- show-word-limit
|
|
|
+ :rows="6"
|
|
|
+ placeholder="请输入作答内容..."
|
|
|
+ @change="handleAnswerChange(q.questionId)"
|
|
|
/>
|
|
|
- </template>
|
|
|
- </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else-if="q.typeCode === 6">
|
|
|
+ <div class="question-stem material-stem">
|
|
|
+ <span class="material-tag">【组合材料】</span>
|
|
|
+ <div class="q-text" style="white-space: pre-wrap;">{{ q.content }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="sub-questions-container">
|
|
|
+ <div v-for="subQ in q.children" :key="subQ.questionId" class="sub-question-item">
|
|
|
+ <div class="question-stem sub-stem">
|
|
|
+ <span class="q-number">{{ subQ.number }}.</span>
|
|
|
+ <span class="q-text">{{ subQ.content }}({{ subQ.score }}分)</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="question-body">
|
|
|
+ <template v-if="subQ.typeCode === 1">
|
|
|
+ <el-radio-group v-model="answers[subQ.questionId]" @change="handleAnswerChange(subQ.questionId)">
|
|
|
+ <el-radio v-for="opt in subQ.options" :key="opt.pos" :label="opt.pos" class="option-item">
|
|
|
+ {{ opt.content }}
|
|
|
+ </el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-if="subQ.typeCode === 2">
|
|
|
+ <el-checkbox-group v-model="answers[subQ.questionId]" @change="handleAnswerChange(subQ.questionId)">
|
|
|
+ <el-checkbox v-for="opt in subQ.options" :key="opt.pos" :label="opt.pos" class="option-item checkbox-item">
|
|
|
+ {{ opt.content }}
|
|
|
+ </el-checkbox>
|
|
|
+ </el-checkbox-group>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else-if="subQ.typeCode === 4">
|
|
|
+ <div class="completion-body">
|
|
|
+ <div v-for="(item, index) in answers[subQ.questionId]" :key="index" class="completion-item">
|
|
|
+ <span class="completion-prefix">第 {{ index + 1 }} 空:</span>
|
|
|
+ <el-input
|
|
|
+ v-model="answers[subQ.questionId][index]"
|
|
|
+ size="small"
|
|
|
+ placeholder="请输入答案"
|
|
|
+ @change="handleAnswerChange(subQ.questionId)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
</div>
|
|
|
</div>
|
|
|
</el-main>
|
|
|
@@ -103,150 +171,202 @@
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
+import { cacheUserAnswer, getCachedUserAnswer, getExamInfo, submitExam } from '@/api/exam'
|
|
|
+
|
|
|
export default {
|
|
|
name: 'ExamPaper',
|
|
|
data() {
|
|
|
return {
|
|
|
- remainTime: 7200, // 考试剩余时间(秒),示例为 2 小时
|
|
|
+ queryParams: {
|
|
|
+ examId: 0
|
|
|
+ },
|
|
|
+ remainTime: 0,
|
|
|
+ timeLoading: true,
|
|
|
timer: null,
|
|
|
- currentActiveId: null, // 当前正在视线聚焦的题目ID
|
|
|
-
|
|
|
- // 核心作答数据收集器模型,以 questionId 作为 key
|
|
|
+ currentActiveId: null,
|
|
|
+ // 🧠 核心思路:前端在组件内部依然使用 Object 方便 v-model 绑定和实时读写
|
|
|
+ // 在「读取缓存」和「打包提交」时,与你要求的数组结构进行相互转换
|
|
|
answers: {},
|
|
|
-
|
|
|
- // 试卷完整多维结构数据
|
|
|
- examData: {
|
|
|
- title: '2026年全栈工程师综合技术能力认证评估',
|
|
|
- subject: '计算机科学与专业综合',
|
|
|
- totalScore: 100,
|
|
|
- passingScore: 60,
|
|
|
- questionGroups: [
|
|
|
- {
|
|
|
- type: 'SINGLE_CHOICE',
|
|
|
- groupTitle: '一、单项选择题',
|
|
|
- scorePerQuestion: 2,
|
|
|
- questions: [
|
|
|
- {
|
|
|
- id: 1001,
|
|
|
- number: 1,
|
|
|
- type: 'SINGLE_CHOICE',
|
|
|
- stem: '在 Vue 2 中,利用哪个生命周期钩子最适合进行初始异步数据请求?',
|
|
|
- options: [
|
|
|
- { key: 'A', value: 'beforeCreate' },
|
|
|
- { key: 'B', value: 'created' },
|
|
|
- { key: 'C', value: 'beforeMount' },
|
|
|
- { key: 'D', value: 'destroyed' }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- id: 1002,
|
|
|
- number: 2,
|
|
|
- type: 'SINGLE_CHOICE',
|
|
|
- stem: '下列不属于 HTTP 状态码 5xx 系列(服务器错误)的是?',
|
|
|
- options: [
|
|
|
- { key: 'A', value: '500 Internal Server Error' },
|
|
|
- { key: 'B', value: '502 Bad Gateway' },
|
|
|
- { key: 'C', value: '403 Forbidden' },
|
|
|
- { key: 'D', value: '503 Service Unavailable' }
|
|
|
- ]
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- type: 'MULTIPLE_CHOICE',
|
|
|
- groupTitle: '二、多项选择题',
|
|
|
- scorePerQuestion: 4,
|
|
|
- questions: [
|
|
|
- {
|
|
|
- id: 2001,
|
|
|
- number: 3,
|
|
|
- type: 'MULTIPLE_CHOICE',
|
|
|
- stem: '关系型数据库(RDBMS)通常包含以下哪些核心特征?',
|
|
|
- options: [
|
|
|
- { key: 'A', value: '支持强一致性(ACID特质)' },
|
|
|
- { key: 'B', value: '基于行与列构成的二维表存储' },
|
|
|
- { key: 'C', value: '天然支持无模式(Schema-less)自由嵌套' },
|
|
|
- { key: 'D', value: '支持使用通用标准的 SQL 语句进行查询' }
|
|
|
- ]
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- type: 'TRUE_FALSE',
|
|
|
- groupTitle: '三、判断题',
|
|
|
- scorePerQuestion: 2,
|
|
|
- questions: [
|
|
|
- {
|
|
|
- id: 3001,
|
|
|
- number: 4,
|
|
|
- type: 'TRUE_FALSE',
|
|
|
- stem: 'JavaScript 中的 const 关键字定义的变量,其内部对象的属性内容也是绝对不可被修改的。'
|
|
|
- },
|
|
|
- {
|
|
|
- id: 3002,
|
|
|
- number: 5,
|
|
|
- type: 'TRUE_FALSE',
|
|
|
- stem: '使用游标(Cursor-based)分页可以有效避免高并发日志插入造成的跳页和重叠问题。'
|
|
|
- }
|
|
|
- ]
|
|
|
- },
|
|
|
- {
|
|
|
- type: 'SHORT_ANSWER',
|
|
|
- groupTitle: '四、简答题',
|
|
|
- scorePerQuestion: 10,
|
|
|
- questions: [
|
|
|
- {
|
|
|
- id: 4001,
|
|
|
- number: 6,
|
|
|
- type: 'SHORT_ANSWER',
|
|
|
- stem: '请简述现代前端在处理海量音视频流(如直播或连续视频审核)时,如何有效防止硬件解码器队列泄漏或卡死。'
|
|
|
- }
|
|
|
- ]
|
|
|
- }
|
|
|
- ]
|
|
|
- }
|
|
|
+ paperInfo: {},
|
|
|
+ sectionsList: []
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
- // 倒计时格式化器 (02:00:00)
|
|
|
formatRemainTime() {
|
|
|
+ if (this.timeLoading) {
|
|
|
+ return '正在获取时间...'
|
|
|
+ }
|
|
|
+
|
|
|
const hours = Math.floor(this.remainTime / 3600).toString().padStart(2, '0')
|
|
|
const mins = Math.floor((this.remainTime % 3600) / 60).toString().padStart(2, '0')
|
|
|
const secs = Math.floor(this.remainTime % 60).toString().padStart(2, '0')
|
|
|
return `${hours}:${mins}:${secs}`
|
|
|
+ },
|
|
|
+ // 🛠️ 新增计算属性:实时将 answers 对象转换为你要求的数组结构
|
|
|
+ // 这样当你想随时拿到符合后端要求的格式时,直接读取 this.formattedAnswersSubmit 即可
|
|
|
+ formattedAnswersSubmit() {
|
|
|
+ return Object.keys(this.answers).map(qId => {
|
|
|
+ const val = this.answers[qId]
|
|
|
+ let answerList = []
|
|
|
+
|
|
|
+ if (Array.isArray(val)) {
|
|
|
+ // 如果本身就是填空题数组,直接赋值(或过滤掉完全未填写的空,看后端需求,这里保持原样)
|
|
|
+ answerList = [...val]
|
|
|
+ } else {
|
|
|
+ // 如果是单选、简答等字符串,且不为空,则作为数组的唯一元素
|
|
|
+ // 如果完全没写,可以返回空数组 [] 或 [""]
|
|
|
+ answerList = val && String(val).trim() !== '' ? [val] : []
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ questionId: Number(qId), // 确保是数字类型的 ID
|
|
|
+ answerList: answerList
|
|
|
+ }
|
|
|
+ })
|
|
|
}
|
|
|
},
|
|
|
created() {
|
|
|
- this.initAnswersMap()
|
|
|
- this.startTimer()
|
|
|
+ const examId = this.$route.query.examId
|
|
|
+ if (!examId) {
|
|
|
+ this.$message.error('参数缺失,无法加载测评页面!')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ this.queryParams.examId = examId
|
|
|
+ this.loadPaperData()
|
|
|
},
|
|
|
beforeDestroy() {
|
|
|
if (this.timer) clearInterval(this.timer)
|
|
|
},
|
|
|
methods: {
|
|
|
- // 🌟 1. 初始化答案容器,防止多选题数组报错
|
|
|
- initAnswersMap() {
|
|
|
- const newAnswers = {}
|
|
|
- this.examData.questionGroups.forEach(group => {
|
|
|
- group.questions.forEach(q => {
|
|
|
- if (q.type === 'MULTIPLE_CHOICE') {
|
|
|
- newAnswers[q.id] = [] // 多选题绑定的响应式基础必须是数组
|
|
|
- } else {
|
|
|
- newAnswers[q.id] = '' // 其余单选、填空、判断初始化为字符串
|
|
|
- }
|
|
|
+ async loadPaperData() {
|
|
|
+ // 1. 🚀 优先拿到异步的后端缓存数据
|
|
|
+ const cachedAnswersArray = await this.getCacheAnswer()
|
|
|
+ const cachedAnswersMap = {}
|
|
|
+
|
|
|
+ if (cachedAnswersArray && Array.isArray(cachedAnswersArray)) {
|
|
|
+ cachedAnswersArray.forEach(item => {
|
|
|
+ cachedAnswersMap[item.questionId] = item.answerList
|
|
|
})
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 获取试卷结构并初始化答案
|
|
|
+ getExamInfo(this.queryParams).then(resp => {
|
|
|
+ if (resp.code === 0) {
|
|
|
+ const examData = resp.data
|
|
|
+ this.remainTime = examData.duration
|
|
|
+ this.timeLoading = false
|
|
|
+ this.startTimer()
|
|
|
+
|
|
|
+ const remoteData = examData.paperDetail
|
|
|
+ this.paperInfo = {
|
|
|
+ paperId: remoteData.paperId,
|
|
|
+ title: remoteData.title,
|
|
|
+ subjectId: remoteData.subjectId,
|
|
|
+ subjectName: remoteData.subjectName
|
|
|
+ }
|
|
|
+
|
|
|
+ let globalQuestionIndex = 1
|
|
|
+ const localAnswers = {}
|
|
|
+
|
|
|
+ this.sectionsList = remoteData.sections.map(sec => {
|
|
|
+ const flatQuestions = []
|
|
|
+
|
|
|
+ const ProcessedQuestions = sec.questions.map(q => {
|
|
|
+ if (q.answers && !q.options) {
|
|
|
+ q.options = q.answers
|
|
|
+ }
|
|
|
+
|
|
|
+ if (q.typeCode !== 6) {
|
|
|
+ q.number = globalQuestionIndex++
|
|
|
+ flatQuestions.push({ questionId: q.questionId, number: q.number, typeCode: q.typeCode })
|
|
|
+
|
|
|
+ // 🛠️ 此时 cachedAnswersMap 已经有完整数据了,可以安全还原
|
|
|
+ if (cachedAnswersMap && cachedAnswersMap[q.questionId] !== undefined) {
|
|
|
+ const savedList = cachedAnswersMap[q.questionId]
|
|
|
+ localAnswers[q.questionId] = q.typeCode === 4 ? savedList : (savedList[0] || '')
|
|
|
+ } else {
|
|
|
+ localAnswers[q.questionId] = q.typeCode === 4 ? new Array((q.options || []).length).fill('') : ''
|
|
|
+ }
|
|
|
+ } else if (q.typeCode === 6 && q.children) {
|
|
|
+ q.children.forEach(subQ => {
|
|
|
+ if (subQ.answers && !subQ.options) {
|
|
|
+ subQ.options = subQ.answers
|
|
|
+ }
|
|
|
+ subQ.number = globalQuestionIndex++
|
|
|
+ flatQuestions.push({ questionId: subQ.questionId, number: subQ.number, typeCode: subQ.typeCode })
|
|
|
+
|
|
|
+ if (cachedAnswersMap && cachedAnswersMap[subQ.questionId] !== undefined) {
|
|
|
+ const savedList = cachedAnswersMap[subQ.questionId]
|
|
|
+ localAnswers[subQ.questionId] = subQ.typeCode === 4 ? savedList : (savedList[0] || '')
|
|
|
+ } else {
|
|
|
+ localAnswers[subQ.questionId] = subQ.typeCode === 4 ? new Array((subQ.options || []).length).fill('') : ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return q
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ sectionId: sec.sectionId,
|
|
|
+ sectionName: sec.sectionName,
|
|
|
+ sectionHint: sec.sectionHint,
|
|
|
+ questions: ProcessedQuestions,
|
|
|
+ flatQuestions: flatQuestions
|
|
|
+ }
|
|
|
+ })
|
|
|
+ this.answers = localAnswers
|
|
|
+ } else {
|
|
|
+ this.$message.warning(resp.msg)
|
|
|
+ }
|
|
|
+ }).catch(error => {
|
|
|
+ this.$message.error(error.message)
|
|
|
})
|
|
|
- this.answers = newAnswers
|
|
|
},
|
|
|
|
|
|
- // 🌟 2. 启动考试倒计时
|
|
|
+ // 监听任意作答变更,实时写入本地缓存
|
|
|
+ handleAnswerChange(qId) {
|
|
|
+ // 🛠️ 调整:写入缓存时,直接写入符合新结构要求的数组
|
|
|
+ this.cacheAnswer(this.formattedAnswersSubmit)
|
|
|
+ },
|
|
|
+
|
|
|
+ // 缓存工具方法:写入
|
|
|
+ cacheAnswer(answersData) {
|
|
|
+ const cachedPaperAnswer = {
|
|
|
+ examId: this.queryParams.examId,
|
|
|
+ paperId: this.paperInfo.paperId,
|
|
|
+ answers: answersData
|
|
|
+ }
|
|
|
+ cacheUserAnswer(cachedPaperAnswer).then(resp => {
|
|
|
+ if (resp.code !== 0) {
|
|
|
+ console.log(resp.msg)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ // 缓存工具方法:读取
|
|
|
+ async getCacheAnswer() {
|
|
|
+ const queryParams = {
|
|
|
+ examId: this.queryParams.examId
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const resp = await getCachedUserAnswer(queryParams)
|
|
|
+ if (resp.code === 0 && resp.data) {
|
|
|
+ return resp.data.answers // 成功时返回答案数组
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取缓存答案失败:', error)
|
|
|
+ }
|
|
|
+ return null // 异常或无数据时返回 null
|
|
|
+ },
|
|
|
+
|
|
|
startTimer() {
|
|
|
this.timer = setInterval(() => {
|
|
|
if (this.remainTime > 0) {
|
|
|
this.remainTime--
|
|
|
} else {
|
|
|
clearInterval(this.timer)
|
|
|
- this.$alert('考试时间已到,系统已为您自动提交试卷!', '提示', {
|
|
|
+ this.$alert('考试时间已到,系统已自动提交!', '提示', {
|
|
|
confirmButtonText: '确定',
|
|
|
callback: () => { this.submitExam() }
|
|
|
})
|
|
|
@@ -254,36 +374,48 @@ export default {
|
|
|
}, 1000)
|
|
|
},
|
|
|
|
|
|
- // 🌟 3. 检测题目是否已被作答(驱动答题卡亮起)
|
|
|
+ // 智能检查是否已作答
|
|
|
checkIsAnswered(question) {
|
|
|
- const ans = this.answers[question.id]
|
|
|
- if (question.type === 'MULTIPLE_CHOICE') {
|
|
|
- return ans && ans.length > 0
|
|
|
+ const ans = this.answers[question.questionId]
|
|
|
+ if (ans === undefined || ans === null) return false
|
|
|
+
|
|
|
+ if (Array.isArray(ans)) {
|
|
|
+ return ans.some(item => item && String(item).trim() !== '')
|
|
|
}
|
|
|
- return ans !== undefined && ans !== null && ans.trim !== undefined ? ans.trim() !== '' : ans !== ''
|
|
|
+ return String(ans).trim() !== ''
|
|
|
},
|
|
|
|
|
|
- // 🌟 4. 点击答题卡,平滑锚点滚动到对应题目
|
|
|
scrollToQuestion(qId) {
|
|
|
this.currentActiveId = qId
|
|
|
const targetEl = document.getElementById(`q-${qId}`)
|
|
|
if (targetEl) {
|
|
|
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
+ } else {
|
|
|
+ const allCards = document.querySelectorAll('.question-card')
|
|
|
+ for (const card of allCards) {
|
|
|
+ if (card.querySelector(`.sub-questions-container`)) {
|
|
|
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
},
|
|
|
|
|
|
- // 🌟 5. 交卷确认弹窗
|
|
|
+ formatStem(content, score) {
|
|
|
+ if (!content) return ''
|
|
|
+ return content.replace(/(\s*\d+分\s*)/, `(${score}分)`)
|
|
|
+ },
|
|
|
+
|
|
|
handleConfirmSubmit() {
|
|
|
- // 统计未做题目数量
|
|
|
let unAnsweredCount = 0
|
|
|
- this.examData.questionGroups.forEach(group => {
|
|
|
- group.questions.forEach(q => {
|
|
|
+ this.sectionsList.forEach(sec => {
|
|
|
+ sec.flatQuestions.forEach(q => {
|
|
|
if (!this.checkIsAnswered(q)) unAnsweredCount++
|
|
|
})
|
|
|
})
|
|
|
|
|
|
const tipMessage = unAnsweredCount > 0
|
|
|
- ? `您当前还有 ${unAnsweredCount} 道题未作答,确定要现在交卷吗?`
|
|
|
+ ? `您当前还有 ${unAnsweredCount} 道题未完全作答,确定要现在交卷吗?`
|
|
|
: '您已完成所有题目,确定提交试卷吗?'
|
|
|
|
|
|
this.$confirm(tipMessage, '交卷确认', {
|
|
|
@@ -295,208 +427,81 @@ export default {
|
|
|
}).catch(() => {})
|
|
|
},
|
|
|
|
|
|
- // 🌟 6. 执行提交逻辑
|
|
|
submitExam() {
|
|
|
- console.log('最终提交的答题 JSON 数据矩阵:', this.answers)
|
|
|
- this.$message.success('试卷提交成功!正在为您生成评测报告...')
|
|
|
- // 可以在此处调用后端接口发送 this.answers
|
|
|
+ const jsonData = {}
|
|
|
+ jsonData.examId = this.queryParams.examId
|
|
|
+ jsonData.paperId = this.paperInfo.paperId
|
|
|
+ jsonData.answers = this.formattedAnswersSubmit
|
|
|
+ submitExam(jsonData).then(resp => {
|
|
|
+ if (resp.code === 0) {
|
|
|
+ this.$message.success('试卷提交成功!')
|
|
|
+ } else {
|
|
|
+ this.$message.warning(resp.msg)
|
|
|
+ }
|
|
|
+ }).catch(error => {
|
|
|
+ this.$message.error(error.message)
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
-.exam-container {
|
|
|
- height: 100vh;
|
|
|
- background-color: #f0f2f5;
|
|
|
-}
|
|
|
-
|
|
|
-/* ==================== ⏱️ 左侧面板层 ==================== */
|
|
|
-.exam-aside {
|
|
|
- padding: 20px;
|
|
|
+/* 原有基础样式保持不变 */
|
|
|
+.exam-container { height: 100vh; background-color: #f0f2f5; }
|
|
|
+.exam-aside { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
|
|
+.aside-card { background: #ffffff; border-radius: 8px; padding: 18px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
|
|
|
+.timer-box { text-align: center; background: #1e293b; color: #ffffff; }
|
|
|
+.timer-title { font-size: 13px; color: #94a3b8; margin-bottom: 6px; }
|
|
|
+.timer-display { font-size: 26px; font-family: monospace; font-weight: bold; color: #38bdf8; }
|
|
|
+.time-danger { color: #f43f5e !important; animation: blink 1s infinite; }
|
|
|
+.answer-card { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
|
|
|
+.card-title { margin: 0 0 14px 0; color: #1e293b; border-left: 3px solid #3b82f6; padding-left: 8px; }
|
|
|
+.card-group { margin-bottom: 16px; }
|
|
|
+.group-label { font-size: 12px; color: #64748b; margin-bottom: 8px; font-weight: bold;}
|
|
|
+.number-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
|
|
+.number-item { height: 36px; display: flex; align-items: center; justify-content: center; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; color: #475569; cursor: pointer; transition: all 0.2s; user-select: none; }
|
|
|
+.number-item:hover { border-color: #3b82f6; color: #3b82f6; }
|
|
|
+.number-item.is-answered { background-color: #e2f5ec; border-color: #10b981; color: #10b981; }
|
|
|
+.number-item.is-active { box-shadow: 0 0 0 2px #3b82f6; font-weight: bold; }
|
|
|
+.btn-submit { width: 100%; margin-top: auto; font-weight: bold; height: 40px; }
|
|
|
+.exam-main { padding: 20px 40px 40px 20px; height: 100%; overflow-y: auto; }
|
|
|
+.paper-header { background: #ffffff; padding: 24px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); text-align: center; }
|
|
|
+.paper-title { margin: 0 0 12px 0; color: #1e293b; }
|
|
|
+.paper-meta { display: flex; justify-content: center; gap: 24px; font-size: 13px; color: #64748b; }
|
|
|
+.question-group-section { margin-bottom: 24px; }
|
|
|
+.group-header-title { font-size: 16px; color: #334155; margin-bottom: 12px; }
|
|
|
+.group-desc { font-size: 13px; color: #94a3b8; font-weight: normal; }
|
|
|
+.question-card { background: #ffffff; border-radius: 8px; padding: 24px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.03); border-left: 4px solid transparent; transition: all 0.2s; }
|
|
|
+.question-card:hover { border-left-color: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.08); }
|
|
|
+.question-stem { font-size: 15px; color: #1e293b; font-weight: 500; line-height: 1.6; margin-bottom: 16px; }
|
|
|
+.q-number { margin-right: 6px; font-weight: bold; }
|
|
|
+.question-body { padding-left: 18px; }
|
|
|
+.option-item { display: block; margin-bottom: 14px !important; font-size: 14px; color: #334155; white-space: normal; line-height: 1.4; }
|
|
|
+.checkbox-item { margin-left: 0 !important; } /* 修复多选框默认左缩进不对齐问题 */
|
|
|
+@keyframes blink { 50% { opacity: 0.7; } }
|
|
|
+
|
|
|
+.material-stem { background-color: #f8fafc; padding: 16px; border-radius: 6px; border: 1px dashed #cbd5e1; color: #334155; }
|
|
|
+.material-tag { color: #3b82f6; font-weight: bold; display: block; margin-bottom: 6px; }
|
|
|
+.sub-questions-container { margin-top: 20px; padding-left: 16px; border-left: 2px solid #e2e8f0; }
|
|
|
+.sub-question-item { margin-bottom: 20px; padding-bottom: 10px; }
|
|
|
+.sub-stem { font-size: 14px; color: #475569; }
|
|
|
+
|
|
|
+/* 🛠️ 新增:填空题专用精美样式 */
|
|
|
+.completion-body {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 16px;
|
|
|
- background-color: transparent;
|
|
|
-}
|
|
|
-
|
|
|
-.aside-card {
|
|
|
- background: #ffffff;
|
|
|
- border-radius: 8px;
|
|
|
- padding: 18px;
|
|
|
- box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
|
-}
|
|
|
-
|
|
|
-.timer-box {
|
|
|
- text-align: center;
|
|
|
- background: #1e293b;
|
|
|
- color: #ffffff;
|
|
|
-}
|
|
|
-.timer-title {
|
|
|
- font-size: 13px;
|
|
|
- color: #94a3b8;
|
|
|
- margin-bottom: 6px;
|
|
|
-}
|
|
|
-.timer-display {
|
|
|
- font-size: 26px;
|
|
|
- font-family: monospace;
|
|
|
- font-weight: bold;
|
|
|
- color: #38bdf8;
|
|
|
+ gap: 12px;
|
|
|
+ max-width: 500px;
|
|
|
}
|
|
|
-.time-danger {
|
|
|
- color: #f43f5e !important;
|
|
|
- animation: blink 1s infinite;
|
|
|
-}
|
|
|
-
|
|
|
-/* 答题卡矩阵 */
|
|
|
-.answer-card {
|
|
|
- flex: 1;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- overflow-y: auto;
|
|
|
-}
|
|
|
-.card-title {
|
|
|
- margin: 0 0 14px 0;
|
|
|
- color: #1e293b;
|
|
|
- border-left: 3px solid #3b82f6;
|
|
|
- padding-left: 8px;
|
|
|
-}
|
|
|
-.card-group {
|
|
|
- margin-bottom: 16px;
|
|
|
-}
|
|
|
-.group-label {
|
|
|
- font-size: 12px;
|
|
|
- color: #64748b;
|
|
|
- margin-bottom: 8px;
|
|
|
-}
|
|
|
-.number-grid {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: repeat(4, 1fr);
|
|
|
- gap: 8px;
|
|
|
-}
|
|
|
-.number-item {
|
|
|
- height: 36px;
|
|
|
+.completion-item {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- justify-content: center;
|
|
|
- border: 1px solid #cbd5e1;
|
|
|
- border-radius: 4px;
|
|
|
- font-size: 13px;
|
|
|
- color: #475569;
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.2s;
|
|
|
- user-select: none;
|
|
|
-}
|
|
|
-.number-item:hover {
|
|
|
- border-color: #3b82f6;
|
|
|
- color: #3b82f6;
|
|
|
-}
|
|
|
-/* 已答、当前选中激活状态控制 */
|
|
|
-.number-item.is-answered {
|
|
|
- background-color: #e2f5ec;
|
|
|
- border-color: #10b981;
|
|
|
- color: #10b981;
|
|
|
}
|
|
|
-.number-item.is-active {
|
|
|
- box-shadow: 0 0 0 2px #3b82f6;
|
|
|
- font-weight: bold;
|
|
|
-}
|
|
|
-
|
|
|
-.btn-submit {
|
|
|
- width: 100%;
|
|
|
- margin-top: auto;
|
|
|
- font-weight: bold;
|
|
|
- height: 40px;
|
|
|
-}
|
|
|
-
|
|
|
-/* ==================== 📝 右侧试卷流面板 ==================== */
|
|
|
-.exam-main {
|
|
|
- padding: 20px 40px 40px 20px;
|
|
|
- height: 100%;
|
|
|
- overflow-y: auto;
|
|
|
-}
|
|
|
-
|
|
|
-.paper-header {
|
|
|
- background: #ffffff;
|
|
|
- padding: 24px;
|
|
|
- border-radius: 8px;
|
|
|
- margin-bottom: 20px;
|
|
|
- box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
|
- text-align: center;
|
|
|
-}
|
|
|
-.paper-title {
|
|
|
- margin: 0 0 12px 0;
|
|
|
- color: #1e293b;
|
|
|
-}
|
|
|
-.paper-meta {
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- gap: 24px;
|
|
|
+.completion-prefix {
|
|
|
font-size: 13px;
|
|
|
color: #64748b;
|
|
|
-}
|
|
|
-
|
|
|
-.question-group-section {
|
|
|
- margin-bottom: 24px;
|
|
|
-}
|
|
|
-.group-header-title {
|
|
|
- font-size: 16px;
|
|
|
- color: #334155;
|
|
|
- margin-bottom: 12px;
|
|
|
-}
|
|
|
-.group-desc {
|
|
|
- font-size: 13px;
|
|
|
- color: #94a3b8;
|
|
|
- font-weight: normal;
|
|
|
-}
|
|
|
-
|
|
|
-/* 核心题目卡片排版 */
|
|
|
-.question-card {
|
|
|
- background: #ffffff;
|
|
|
- border-radius: 8px;
|
|
|
- padding: 24px;
|
|
|
- margin-bottom: 16px;
|
|
|
- box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
|
|
- border-left: 4px solid transparent;
|
|
|
- transition: all 0.2s;
|
|
|
-}
|
|
|
-.question-card:hover {
|
|
|
- border-left-color: #3b82f6;
|
|
|
- box-shadow: 0 4px 12px rgba(59, 130, 246, 0.08);
|
|
|
-}
|
|
|
-
|
|
|
-.question-stem {
|
|
|
- font-size: 15px;
|
|
|
- color: #1e293b;
|
|
|
- font-weight: 500;
|
|
|
- line-height: 1.6;
|
|
|
- margin-bottom: 16px;
|
|
|
-}
|
|
|
-.q-number {
|
|
|
- margin-right: 6px;
|
|
|
- font-weight: bold;
|
|
|
-}
|
|
|
-
|
|
|
-.question-body {
|
|
|
- padding-left: 18px;
|
|
|
-}
|
|
|
-
|
|
|
-/* 选项规整排列排版 */
|
|
|
-.option-item {
|
|
|
- display: block;
|
|
|
- margin-bottom: 14px !important;
|
|
|
- font-size: 14px;
|
|
|
- color: #334155;
|
|
|
- white-space: normal;
|
|
|
- line-height: 1.4;
|
|
|
-}
|
|
|
-.option-key {
|
|
|
- font-weight: bold;
|
|
|
- margin-right: 4px;
|
|
|
-}
|
|
|
-
|
|
|
-/* 呼吸灯特效 */
|
|
|
-@keyframes blink {
|
|
|
- 50% { opacity: 0.7; }
|
|
|
+ width: 80px;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
</style>
|