|
|
@@ -0,0 +1,1316 @@
|
|
|
+<template>
|
|
|
+ <div class="exam-taking" v-loading="loading">
|
|
|
+ <!-- 顶部考试信息 -->
|
|
|
+ <el-card class="header-card" shadow="never">
|
|
|
+ <div class="exam-header">
|
|
|
+ <div class="exam-title">{{ examInfo.examName }}</div>
|
|
|
+ <div class="exam-meta">
|
|
|
+ <span class="meta-item">题目数量:{{ questionList.length }}</span>
|
|
|
+ <span class="meta-divider">|</span>
|
|
|
+ <span class="meta-item"
|
|
|
+ >考试时间:{{ formatDateTime(examInfo.startTime) }}</span
|
|
|
+ >
|
|
|
+ <span class="meta-divider">|</span>
|
|
|
+ <span class="meta-item">考试时长:{{ examInfo.durationMinutes }}分钟</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 主体区域 -->
|
|
|
+ <div class="exam-body" v-if="hasLoaded">
|
|
|
+ <!-- 左侧:答题区 -->
|
|
|
+ <div class="exam-aside">
|
|
|
+ <!-- 答题中:剩余时间 + 答题卡 -->
|
|
|
+ <template v-if="mode === 'taking'">
|
|
|
+ <div class="aside-card">
|
|
|
+ <div class="timer-bar">
|
|
|
+ <i class="el-icon-timer timer-icon"></i>
|
|
|
+ <span class="timer-label">剩余时间</span>
|
|
|
+ <span class="timer-value">{{ countdown }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="aside-card">
|
|
|
+ <div class="aside-card-title">答题卡</div>
|
|
|
+ <div class="answer-card-grid">
|
|
|
+ <button
|
|
|
+ v-for="(q, idx) in questionList"
|
|
|
+ :key="q.id"
|
|
|
+ type="button"
|
|
|
+ class="answer-card-cell"
|
|
|
+ :class="{
|
|
|
+ answered: isAnswered(q),
|
|
|
+ current: idx === currentIndex
|
|
|
+ }"
|
|
|
+ @click="goTo(idx)"
|
|
|
+ >
|
|
|
+ {{ idx + 1 }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 结果模式:成绩汇总 + 答题卡 -->
|
|
|
+ <template v-else>
|
|
|
+ <div class="aside-card result-summary-card">
|
|
|
+ <div class="aside-card-title">考试成绩</div>
|
|
|
+ <div class="result-summary">
|
|
|
+ <div class="summary-row">
|
|
|
+ <span class="summary-label">考核成绩:</span>
|
|
|
+ <span class="summary-value score-value">{{
|
|
|
+ formatScore(examInfo.assessmentScore)
|
|
|
+ }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="summary-row">
|
|
|
+ <span class="summary-label">补考成绩:</span>
|
|
|
+ <span class="summary-value">{{
|
|
|
+ formatScore(examInfo.retakeScore)
|
|
|
+ }}</span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="summary-row"
|
|
|
+ v-if="examInfo.passScore !== null && examInfo.passScore !== undefined"
|
|
|
+ >
|
|
|
+ <span class="summary-label">合格分:</span>
|
|
|
+ <span class="summary-value">{{ examInfo.passScore }}</span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="summary-row"
|
|
|
+ v-if="examInfo.totalScore !== null && examInfo.totalScore !== undefined"
|
|
|
+ >
|
|
|
+ <span class="summary-label">总分:</span>
|
|
|
+ <span class="summary-value">{{ examInfo.totalScore }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="summary-row">
|
|
|
+ <span class="summary-label">结果:</span>
|
|
|
+ <span
|
|
|
+ class="summary-value"
|
|
|
+ :class="{ 'is-fail': !isPass }"
|
|
|
+ >{{ isPass ? '合格' : '不合格' }}</span
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="aside-card">
|
|
|
+ <div class="aside-card-title">答题卡</div>
|
|
|
+ <div class="answer-card-grid">
|
|
|
+ <button
|
|
|
+ v-for="(q, idx) in questionList"
|
|
|
+ :key="q.id"
|
|
|
+ type="button"
|
|
|
+ class="answer-card-cell"
|
|
|
+ :class="{
|
|
|
+ answered: isAnswered(q),
|
|
|
+ current: idx === currentIndex
|
|
|
+ }"
|
|
|
+ @click="goTo(idx)"
|
|
|
+ >
|
|
|
+ {{ idx + 1 }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 考试人员 -->
|
|
|
+ <div class="aside-card">
|
|
|
+ <div class="aside-card-title">考试人员</div>
|
|
|
+ <div class="candidate">
|
|
|
+ <div class="candidate-avatar">
|
|
|
+ <i class="el-icon-user-solid"></i>
|
|
|
+ </div>
|
|
|
+ <div class="candidate-info">
|
|
|
+ <div class="candidate-row">
|
|
|
+ <span class="candidate-label">姓名:</span>
|
|
|
+ <span>{{ userInfo.realName || userInfo.name || userInfo.username || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="candidate-row">
|
|
|
+ <span class="candidate-label">工号:</span>
|
|
|
+ <span>{{ userInfo.accountId || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="candidate-row">
|
|
|
+ <span class="candidate-label">部门:</span>
|
|
|
+ <span>{{ deptInfo.name || userInfo.deptName || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="candidate-row">
|
|
|
+ <span class="candidate-label">类型:</span>
|
|
|
+ <span>{{ userInfo.userType || '厂内人员' }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧:题目区 -->
|
|
|
+ <div class="exam-main">
|
|
|
+ <el-card shadow="never" class="question-card">
|
|
|
+ <div class="question-card-header">
|
|
|
+ <span>{{
|
|
|
+ mode === 'taking' ? '题目' : '题目作答情况'
|
|
|
+ }}</span>
|
|
|
+ <span
|
|
|
+ v-if="mode === 'result'"
|
|
|
+ class="result-mode-tag"
|
|
|
+ >已交卷,仅查看</span>
|
|
|
+ </div>
|
|
|
+ <div class="question-card-body">
|
|
|
+ <div v-if="currentQuestion" class="question-block">
|
|
|
+ <div class="question-title">
|
|
|
+ <span class="question-no">{{ currentIndex + 1 }}.</span>
|
|
|
+ <span class="question-type">{{
|
|
|
+ formatQuestionType(currentQuestion.questionType)
|
|
|
+ }}</span>
|
|
|
+ <span class="question-score" v-if="currentQuestion.questionScore"
|
|
|
+ >({{ currentQuestion.questionScore }} 分)</span
|
|
|
+ >
|
|
|
+ <div class="question-content">
|
|
|
+ {{ currentQuestion.questionContent }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 选项题: 单选 / 判断 / 多选 -->
|
|
|
+ <div
|
|
|
+ v-if="isChoiceType(currentQuestion.questionType)"
|
|
|
+ class="option-list"
|
|
|
+ >
|
|
|
+ <!-- 单选 + 判断:都走单选组(judge 在你们系统里是 4 选项 radio) -->
|
|
|
+ <el-radio-group
|
|
|
+ v-if="
|
|
|
+ getQuestionTypeCode(currentQuestion.questionType) === 1 ||
|
|
|
+ getQuestionTypeCode(currentQuestion.questionType) === 3
|
|
|
+ "
|
|
|
+ v-model="answers[currentQuestion.id]"
|
|
|
+ :disabled="mode === 'result'"
|
|
|
+ class="option-group"
|
|
|
+ @change="onChoiceChange(currentQuestion, $event)"
|
|
|
+ >
|
|
|
+ <el-radio
|
|
|
+ v-for="opt in currentQuestion.optionList"
|
|
|
+ :key="opt.id"
|
|
|
+ :label="opt.sortNo"
|
|
|
+ class="option-item option-item--radio"
|
|
|
+ >
|
|
|
+ <span class="option-label"
|
|
|
+ >{{ String.fromCharCode(64 + opt.sortNo) }}.</span
|
|
|
+ >
|
|
|
+ <span class="option-content">{{ opt.optionContent }}</span>
|
|
|
+ </el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+
|
|
|
+ <!-- 多选:用原生 checkbox 完全控制样式,避开 el-checkbox 默认 font-size:0 / inline-block 布局 -->
|
|
|
+ <div
|
|
|
+ v-else-if="
|
|
|
+ getQuestionTypeCode(currentQuestion.questionType) === 2
|
|
|
+ "
|
|
|
+ class="option-group option-group--checkbox"
|
|
|
+ >
|
|
|
+ <label
|
|
|
+ v-for="opt in currentQuestion.optionList"
|
|
|
+ :key="opt.id"
|
|
|
+ class="option-item option-item--checkbox"
|
|
|
+ :class="{
|
|
|
+ 'is-checked': isMultiChecked(
|
|
|
+ currentQuestion.id,
|
|
|
+ opt.sortNo
|
|
|
+ ),
|
|
|
+ 'is-disabled': mode === 'result'
|
|
|
+ }"
|
|
|
+ @click.prevent="
|
|
|
+ mode === 'taking' &&
|
|
|
+ toggleMultiChoice(currentQuestion, opt.sortNo)
|
|
|
+ "
|
|
|
+ >
|
|
|
+ <span class="checkbox-box">
|
|
|
+ <span class="checkbox-tick"></span>
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ class="checkbox-native"
|
|
|
+ :checked="
|
|
|
+ isMultiChecked(currentQuestion.id, opt.sortNo)
|
|
|
+ "
|
|
|
+ :disabled="mode === 'result'"
|
|
|
+ @click.stop
|
|
|
+ />
|
|
|
+ </span>
|
|
|
+ <span class="option-label"
|
|
|
+ >{{ String.fromCharCode(64 + opt.sortNo) }}.</span
|
|
|
+ >
|
|
|
+ <span class="option-content">{{
|
|
|
+ opt.optionContent
|
|
|
+ }}</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 填空 / 问答 -->
|
|
|
+ <div v-else class="essay-block">
|
|
|
+ <el-input
|
|
|
+ type="textarea"
|
|
|
+ :rows="6"
|
|
|
+ v-model="answers[currentQuestion.id]"
|
|
|
+ :disabled="mode === 'result'"
|
|
|
+ placeholder="请输入答案"
|
|
|
+ @input="onEssayInput(currentQuestion, $event)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-empty
|
|
|
+ v-else
|
|
|
+ description="暂无试题"
|
|
|
+ class="question-empty"
|
|
|
+ ></el-empty>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="question-card-footer">
|
|
|
+ <template v-if="mode === 'taking'">
|
|
|
+ <el-button
|
|
|
+ :disabled="currentIndex === 0"
|
|
|
+ type="primary"
|
|
|
+ plain
|
|
|
+ @click="prev"
|
|
|
+ >上一题</el-button
|
|
|
+ >
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ @click="next"
|
|
|
+ >下一题</el-button
|
|
|
+ >
|
|
|
+ <el-button
|
|
|
+ type="warning"
|
|
|
+ @click="handleSubmit"
|
|
|
+ >交卷</el-button
|
|
|
+ >
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-empty
|
|
|
+ v-else
|
|
|
+ description="正在加载考试..."
|
|
|
+ class="loading-empty"
|
|
|
+ ></el-empty>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+ import {
|
|
|
+ start,
|
|
|
+ retake,
|
|
|
+ saveAnswer,
|
|
|
+ submitExam
|
|
|
+ } from '@/api/examManagement/examManagement.js';
|
|
|
+
|
|
|
+ // 题库类型映射(兼容不同来源的表示形式)
|
|
|
+ const QUESTION_TYPE_MAP = {
|
|
|
+ single: { code: 1, label: '单选题', isChoice: true },
|
|
|
+ SINGLE: { code: 1, label: '单选题', isChoice: true },
|
|
|
+ '1': { code: 1, label: '单选题', isChoice: true },
|
|
|
+ multiple: { code: 2, label: '多选题', isChoice: true },
|
|
|
+ MULTIPLE: { code: 2, label: '多选题', isChoice: true },
|
|
|
+ '2': { code: 2, label: '多选题', isChoice: true },
|
|
|
+ judge: { code: 3, label: '判断题', isChoice: true },
|
|
|
+ JUDGE: { code: 3, label: '判断题', isChoice: true },
|
|
|
+ '3': { code: 3, label: '判断题', isChoice: true },
|
|
|
+ qa: { code: 4, label: '问答题', isChoice: false },
|
|
|
+ QA: { code: 4, label: '问答题', isChoice: false },
|
|
|
+ essay: { code: 4, label: '问答题', isChoice: false },
|
|
|
+ ESSAY: { code: 4, label: '问答题', isChoice: false },
|
|
|
+ question_answer: { code: 4, label: '问答题', isChoice: false },
|
|
|
+ QUESTION_ANSWER: { code: 4, label: '问答题', isChoice: false },
|
|
|
+ '4': { code: 4, label: '问答题', isChoice: false }
|
|
|
+ };
|
|
|
+
|
|
|
+ export default {
|
|
|
+ name: 'ExamTaking',
|
|
|
+
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: false,
|
|
|
+ hasLoaded: false,
|
|
|
+ // 'taking' 答题中 | 'result' 交卷后查看结果
|
|
|
+ mode: 'taking',
|
|
|
+ // 'start' 首次考试 | 'retake' 补考
|
|
|
+ entryMode: 'start',
|
|
|
+ examInfo: {
|
|
|
+ attemptId: null,
|
|
|
+ examId: null,
|
|
|
+ examName: '',
|
|
|
+ durationMinutes: 0,
|
|
|
+ startTime: '',
|
|
|
+ endTime: '',
|
|
|
+ answerDeadline: '',
|
|
|
+ assessmentScore: null,
|
|
|
+ retakeScore: null,
|
|
|
+ passScore: null,
|
|
|
+ totalScore: null,
|
|
|
+ retakeCount: null,
|
|
|
+ examStatus: null
|
|
|
+ },
|
|
|
+ questionList: [],
|
|
|
+ answers: {}, // { [questionId]: answerValue }
|
|
|
+ currentIndex: 0,
|
|
|
+ countdown: '00:00:00',
|
|
|
+ timer: null,
|
|
|
+ serverTimeOffset: 0,
|
|
|
+ expiredHandled: false,
|
|
|
+ storageKey: '',
|
|
|
+ // 交卷接口原始返回,用于结果模式展示
|
|
|
+ submitResult: null,
|
|
|
+ // 单题答案保存的"静默中"标识,用于防抖控制
|
|
|
+ answerSaving: {}
|
|
|
+ };
|
|
|
+ },
|
|
|
+
|
|
|
+ computed: {
|
|
|
+ currentQuestion() {
|
|
|
+ return this.questionList[this.currentIndex] || null;
|
|
|
+ },
|
|
|
+ // 当前显示的最终分数(补考优先,否则用考核成绩)
|
|
|
+ currentScore() {
|
|
|
+ if (this.examInfo.retakeScore !== null && this.examInfo.retakeScore !== undefined && this.examInfo.retakeScore !== '') {
|
|
|
+ return this.examInfo.retakeScore;
|
|
|
+ }
|
|
|
+ return this.examInfo.assessmentScore;
|
|
|
+ },
|
|
|
+ // 是否合格:优先用 submit 接口返回的 passed,否则按分数 vs 合格线推断
|
|
|
+ isPass() {
|
|
|
+ if (this.submitResult && typeof this.submitResult.passed === 'boolean') {
|
|
|
+ return this.submitResult.passed;
|
|
|
+ }
|
|
|
+ const pass = Number(this.examInfo.passScore);
|
|
|
+ const score = Number(this.currentScore);
|
|
|
+ if (Number.isNaN(pass) || pass <= 0) {
|
|
|
+ // 没有合格线时,只要分数有值即视为合格(粗略判定)
|
|
|
+ return !Number.isNaN(score) && score > 0;
|
|
|
+ }
|
|
|
+ if (Number.isNaN(score)) return false;
|
|
|
+ return score >= pass;
|
|
|
+ },
|
|
|
+ // 登录用户信息
|
|
|
+ userInfo() {
|
|
|
+ return this.$store?.state?.user?.info || {};
|
|
|
+ },
|
|
|
+ // 当前数据权限部门信息
|
|
|
+ deptInfo() {
|
|
|
+ const dept = this.$store?.state?.user?.authorityDept;
|
|
|
+ if (!dept) return {};
|
|
|
+ return dept.deptName ? { name: dept.deptName } : dept;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ created() {
|
|
|
+ this.init();
|
|
|
+ },
|
|
|
+
|
|
|
+ beforeDestroy() {
|
|
|
+ this.stopTimer();
|
|
|
+ },
|
|
|
+
|
|
|
+ methods: {
|
|
|
+ async init() {
|
|
|
+ const { examId, mode, data: dataStr, submitted } = this.$route.query;
|
|
|
+ if (!examId) {
|
|
|
+ this.$message.error('缺少考试ID,无法开始考试');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.entryMode = mode === 'retake' ? 'retake' : 'start';
|
|
|
+ this.storageKey = `exam-taking-${examId}-${this.entryMode}`;
|
|
|
+ // 优先使用上次暂存的答案(支持刷新恢复)
|
|
|
+ this.restoreAnswers();
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.loading = true;
|
|
|
+ // 主路径:本页面主动调 start / retake 拿试卷
|
|
|
+ let data = this.parseQueryData(dataStr);
|
|
|
+ if (!data) {
|
|
|
+ data = await this.fetchAttemptData(
|
|
|
+ examId,
|
|
|
+ this.entryMode
|
|
|
+ );
|
|
|
+ }
|
|
|
+ this.bindExamData(data);
|
|
|
+ // mode=retake 表示"重新考试 / 补考",即便 submitted=true 也要进答题态
|
|
|
+ // submitted=true + mode=start 表示"已提交的考试,只查看结果"
|
|
|
+ if (mode === 'retake') {
|
|
|
+ this.mode = 'taking';
|
|
|
+ this.startTimer();
|
|
|
+ } else if (submitted === true || submitted === 'true') {
|
|
|
+ this.mode = 'result';
|
|
|
+ this.stopTimer();
|
|
|
+ } else {
|
|
|
+ this.mode = 'taking';
|
|
|
+ this.startTimer();
|
|
|
+ }
|
|
|
+ this.hasLoaded = true;
|
|
|
+ } catch (error) {
|
|
|
+ this.$message.error(error?.message || '获取试卷失败');
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 解析列表页通过 query 透传过来的 start/retake 响应
|
|
|
+ parseQueryData(dataStr) {
|
|
|
+ if (!dataStr) return null;
|
|
|
+ try {
|
|
|
+ const raw = typeof dataStr === 'string'
|
|
|
+ ? decodeURIComponent(dataStr)
|
|
|
+ : dataStr;
|
|
|
+ const obj = JSON.parse(raw);
|
|
|
+ return obj && typeof obj === 'object' ? obj : null;
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('解析路由 data 失败', e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 拉取最新试卷数据(列表页不再预调,本页面自己负责调 start / retake)
|
|
|
+ async fetchAttemptData(examId, mode) {
|
|
|
+ if (mode === 'retake') {
|
|
|
+ return retake({ examId: String(examId) });
|
|
|
+ }
|
|
|
+ return start({ examId: String(examId) });
|
|
|
+ },
|
|
|
+
|
|
|
+ bindExamData(data) {
|
|
|
+ if (!data) return;
|
|
|
+ this.examInfo = {
|
|
|
+ attemptId: data.attemptId,
|
|
|
+ examId: data.examId,
|
|
|
+ examName: data.examName,
|
|
|
+ durationMinutes: data.durationMinutes,
|
|
|
+ startTime: data.startTime,
|
|
|
+ endTime: data.endTime,
|
|
|
+ answerDeadline: data.answerDeadline,
|
|
|
+ assessmentScore: data.assessmentScore,
|
|
|
+ retakeScore: data.retakeScore,
|
|
|
+ passScore: data.passScore,
|
|
|
+ totalScore: data.totalScore,
|
|
|
+ retakeCount: data.retakeCount,
|
|
|
+ examStatus: data.examStatus
|
|
|
+ };
|
|
|
+ // 处理题目:用 id -> answer 的映射初始化
|
|
|
+ this.questionList = Array.isArray(data.questionList)
|
|
|
+ ? data.questionList
|
|
|
+ : [];
|
|
|
+ // 初始化答案:若有上次答案,优先用上次的;否则按后端返回的 answer 字段初始化
|
|
|
+ const previous = this.answers || {};
|
|
|
+ const next = {};
|
|
|
+ this.questionList.forEach((q) => {
|
|
|
+ if (previous[q.id] !== undefined) {
|
|
|
+ next[q.id] = previous[q.id];
|
|
|
+ } else if (q.answer !== undefined && q.answer !== null && q.answer !== '') {
|
|
|
+ const t = this.getQuestionTypeCode(q.questionType);
|
|
|
+ if (t === 2) {
|
|
|
+ next[q.id] = String(q.answer)
|
|
|
+ .split(',')
|
|
|
+ .map((s) => Number(s))
|
|
|
+ .filter((n) => !Number.isNaN(n));
|
|
|
+ } else {
|
|
|
+ next[q.id] = q.answer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ this.answers = next;
|
|
|
+
|
|
|
+ // 计算服务器时间偏移,便于后续倒计时校准
|
|
|
+ this.calibrateServerTime(data);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 校准服务器与本地时间偏移(answerDeadline 是服务器时间)
|
|
|
+ calibrateServerTime(data) {
|
|
|
+ try {
|
|
|
+ const serverNow = data.answerDeadline
|
|
|
+ ? new Date(data.answerDeadline).getTime()
|
|
|
+ : Date.now();
|
|
|
+ // 服务端时间通常更早到达本地,这里粗略以 0 偏差处理,
|
|
|
+ // 真实严谨场景需要后端返回 now 字段
|
|
|
+ this.serverTimeOffset = 0;
|
|
|
+ this.deadlineMs = serverNow;
|
|
|
+ } catch (e) {
|
|
|
+ this.deadlineMs = Date.now() + (this.examInfo.durationMinutes || 30) * 60 * 1000;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ startTimer() {
|
|
|
+ this.stopTimer();
|
|
|
+ this.tick();
|
|
|
+ this.timer = setInterval(this.tick, 1000);
|
|
|
+ },
|
|
|
+
|
|
|
+ stopTimer() {
|
|
|
+ if (this.timer) {
|
|
|
+ clearInterval(this.timer);
|
|
|
+ this.timer = null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ tick() {
|
|
|
+ if (!this.deadlineMs) {
|
|
|
+ this.countdown = '00:00:00';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const remain = this.deadlineMs - Date.now() - this.serverTimeOffset;
|
|
|
+ if (remain <= 0) {
|
|
|
+ this.countdown = '00:00:00';
|
|
|
+ this.stopTimer();
|
|
|
+ if (!this.expiredHandled) {
|
|
|
+ this.expiredHandled = true;
|
|
|
+ this.$message.warning('考试时间已到,系统自动交卷');
|
|
|
+ // 自动交卷前也把当前简答 / 填空题的答案先落库
|
|
|
+ this.flushCurrentAnswer();
|
|
|
+ this.doSubmit();
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const totalSec = Math.floor(remain / 1000);
|
|
|
+ const h = String(Math.floor(totalSec / 3600)).padStart(2, '0');
|
|
|
+ const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
|
|
|
+ const s = String(totalSec % 60).padStart(2, '0');
|
|
|
+ this.countdown = `${h}:${m}:${s}`;
|
|
|
+ // 每次 tick 后把答案暂存起来,刷新页面不丢
|
|
|
+ this.persistAnswers();
|
|
|
+ },
|
|
|
+
|
|
|
+ persistAnswers() {
|
|
|
+ if (!this.storageKey) return;
|
|
|
+ try {
|
|
|
+ sessionStorage.setItem(this.storageKey, JSON.stringify({
|
|
|
+ answers: this.answers,
|
|
|
+ currentIndex: this.currentIndex
|
|
|
+ }));
|
|
|
+ } catch (e) {
|
|
|
+ // 静默失败,不影响功能
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ restoreAnswers() {
|
|
|
+ if (!this.storageKey) return;
|
|
|
+ try {
|
|
|
+ const cached = sessionStorage.getItem(this.storageKey);
|
|
|
+ if (cached) {
|
|
|
+ const parsed = JSON.parse(cached);
|
|
|
+ if (parsed.answers) this.answers = parsed.answers;
|
|
|
+ if (typeof parsed.currentIndex === 'number') {
|
|
|
+ this.currentIndex = parsed.currentIndex;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ clearPersistedAnswers() {
|
|
|
+ if (!this.storageKey) return;
|
|
|
+ try {
|
|
|
+ sessionStorage.removeItem(this.storageKey);
|
|
|
+ } catch (e) {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 题型识别:0=未识别,1=单选,2=多选,3=判断,4=问答
|
|
|
+ getQuestionTypeCode(type) {
|
|
|
+ if (type === undefined || type === null) return 1;
|
|
|
+ const key = String(type).toLowerCase();
|
|
|
+ const entry =
|
|
|
+ QUESTION_TYPE_MAP[key] ||
|
|
|
+ QUESTION_TYPE_MAP[String(type).replace(/\s/g, '')];
|
|
|
+ if (entry) return entry.code;
|
|
|
+ // 兼容中文:包含"多"=多选,包含"判"=判断,包含"问答"=问答,默认单选
|
|
|
+ if (typeof type === 'string') {
|
|
|
+ if (type.includes('问答')) return 4;
|
|
|
+ if (type.includes('多')) return 2;
|
|
|
+ if (type.includes('判')) return 3;
|
|
|
+ if (type.includes('单')) return 1;
|
|
|
+ }
|
|
|
+ return 1;
|
|
|
+ },
|
|
|
+
|
|
|
+ formatQuestionType(type) {
|
|
|
+ const code = this.getQuestionTypeCode(type);
|
|
|
+ return (
|
|
|
+ QUESTION_TYPE_MAP[String(code)]?.label || type || ''
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ isChoiceType(type) {
|
|
|
+ return this.getQuestionTypeCode(type) >= 1 && this.getQuestionTypeCode(type) <= 3;
|
|
|
+ },
|
|
|
+
|
|
|
+ isAnswered(q) {
|
|
|
+ const v = this.answers[q.id];
|
|
|
+ if (v === undefined || v === null || v === '') return false;
|
|
|
+ if (Array.isArray(v)) return v.length > 0;
|
|
|
+ return true;
|
|
|
+ },
|
|
|
+
|
|
|
+ prev() {
|
|
|
+ if (this.currentIndex > 0) {
|
|
|
+ this.flushCurrentAnswer();
|
|
|
+ this.currentIndex -= 1;
|
|
|
+ this.persistAnswers();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ next() {
|
|
|
+ if (this.currentIndex < this.questionList.length - 1) {
|
|
|
+ this.flushCurrentAnswer();
|
|
|
+ this.currentIndex += 1;
|
|
|
+ this.persistAnswers();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ goTo(idx) {
|
|
|
+ if (
|
|
|
+ idx >= 0 &&
|
|
|
+ idx < this.questionList.length &&
|
|
|
+ idx !== this.currentIndex
|
|
|
+ ) {
|
|
|
+ this.flushCurrentAnswer();
|
|
|
+ this.currentIndex = idx;
|
|
|
+ this.persistAnswers();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 选项题变更(单选 / 判断):el-radio-group v-model 触发,value 为 sortNo
|
|
|
+ onChoiceChange(q, value) {
|
|
|
+ if (this.mode !== 'taking') return;
|
|
|
+ this.$set(this.answers, q.id, value);
|
|
|
+ this.persistAnswers();
|
|
|
+ this.persistSingleAnswer(q);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 多选题:判断某 sortNo 是否已选
|
|
|
+ isMultiChecked(qid, sortNo) {
|
|
|
+ const arr = this.answers[qid];
|
|
|
+ return Array.isArray(arr) && arr.includes(sortNo);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 多选题:点击切换某个选项
|
|
|
+ toggleMultiChoice(q, sortNo) {
|
|
|
+ if (this.mode !== 'taking') return;
|
|
|
+ const qid = q.id;
|
|
|
+ const cur = Array.isArray(this.answers[qid])
|
|
|
+ ? [...this.answers[qid]]
|
|
|
+ : [];
|
|
|
+ const idx = cur.indexOf(sortNo);
|
|
|
+ if (idx > -1) {
|
|
|
+ cur.splice(idx, 1);
|
|
|
+ } else {
|
|
|
+ cur.push(sortNo);
|
|
|
+ }
|
|
|
+ this.$set(this.answers, qid, cur);
|
|
|
+ this.persistAnswers();
|
|
|
+ this.persistSingleAnswer(q);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 简答/填空题输入:只更新本地 + sessionStorage,saveAnswer 推迟到离开本题时再调
|
|
|
+ onEssayInput(q, value) {
|
|
|
+ if (this.mode !== 'taking') return;
|
|
|
+ this.$set(this.answers, q.id, value);
|
|
|
+ this.persistAnswers();
|
|
|
+ },
|
|
|
+
|
|
|
+ // 离开当前题前:若是简答 / 填空(code=4),把当前题答案落库
|
|
|
+ flushCurrentAnswer() {
|
|
|
+ const q = this.questionList[this.currentIndex];
|
|
|
+ if (!q) return;
|
|
|
+ const code = this.getQuestionTypeCode(q.questionType);
|
|
|
+ if (code === 4) {
|
|
|
+ this.persistSingleAnswer(q);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 把某道题的答案调 /ehs/examManagement/answer 落库(防抖)
|
|
|
+ persistSingleAnswer(q) {
|
|
|
+ if (!q || !q.id) return;
|
|
|
+ if (this.answerSaving[q.id]) return; // 已有请求在飞
|
|
|
+ if (!this.examInfo.attemptId) return; // 没有 attemptId 不能落库
|
|
|
+
|
|
|
+ const value = this.answers[q.id];
|
|
|
+ const code = this.getQuestionTypeCode(q.questionType);
|
|
|
+ // 选择题:把 sortNo 转回 option.id(对应 selectedOptionIds 字段)
|
|
|
+ let selectedOptionIds = null;
|
|
|
+ let answerText = null;
|
|
|
+ if (code === 1 || code === 3) {
|
|
|
+ const sortNo = Array.isArray(value) ? value[0] : value;
|
|
|
+ const opt = (q.optionList || []).find((o) => o.sortNo === sortNo);
|
|
|
+ selectedOptionIds = opt ? [opt.id] : [];
|
|
|
+ if (code === 3) answerText = sortNo != null ? String(sortNo) : '';
|
|
|
+ } else if (code === 2) {
|
|
|
+ const sortNos = Array.isArray(value) ? value : value ? [value] : [];
|
|
|
+ selectedOptionIds = (q.optionList || [])
|
|
|
+ .filter((o) => sortNos.includes(o.sortNo))
|
|
|
+ .map((o) => o.id);
|
|
|
+ } else {
|
|
|
+ // 问答 / 填空
|
|
|
+ answerText = value == null ? '' : String(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ const body = {
|
|
|
+ attemptId: this.examInfo.attemptId,
|
|
|
+ attemptQuestionId: q.id,
|
|
|
+ answer: answerText || '',
|
|
|
+ selectedOptionIds: selectedOptionIds || []
|
|
|
+ };
|
|
|
+
|
|
|
+ // 静默调用,不打断用户;失败也不弹错(交卷时由后端校验)
|
|
|
+ this.answerSaving[q.id] = true;
|
|
|
+ saveAnswer(body)
|
|
|
+ .catch((err) => {
|
|
|
+ console.warn('保存单题答案失败', err);
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ this.$set(this.answerSaving, q.id, false);
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ async handleSubmit() {
|
|
|
+ const unanswered = this.questionList.filter((q) => !this.isAnswered(q));
|
|
|
+ if (unanswered.length > 0) {
|
|
|
+ try {
|
|
|
+ await this.$confirm(
|
|
|
+ `还有 ${unanswered.length} 道题未作答,确认交卷吗?`,
|
|
|
+ '交卷确认',
|
|
|
+ {
|
|
|
+ confirmButtonText: '确认交卷',
|
|
|
+ cancelButtonText: '继续答题',
|
|
|
+ type: 'warning'
|
|
|
+ }
|
|
|
+ );
|
|
|
+ } catch (e) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 交卷前把当前简答 / 填空题的答案先落库,避免遗失
|
|
|
+ this.flushCurrentAnswer();
|
|
|
+ this.doSubmit();
|
|
|
+ },
|
|
|
+
|
|
|
+ async doSubmit() {
|
|
|
+ this.stopTimer();
|
|
|
+ try {
|
|
|
+ this.loading = true;
|
|
|
+ // 调交卷接口,后端会写入 attempt 状态并给出最终结果
|
|
|
+ const result = await submitExam({
|
|
|
+ attemptId: this.examInfo.attemptId
|
|
|
+ });
|
|
|
+ this.clearPersistedAnswers();
|
|
|
+ this.$message.success('交卷成功');
|
|
|
+
|
|
|
+ // 用交卷结果更新考试信息,并进入结果模式
|
|
|
+ this.applySubmitResult(result);
|
|
|
+ this.mode = 'result';
|
|
|
+ } catch (error) {
|
|
|
+ this.$message.error(error?.message || '提交答卷失败');
|
|
|
+ // 失败时允许继续作答
|
|
|
+ this.startTimer();
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 把交卷返回的结果合并到 examInfo
|
|
|
+ applySubmitResult(result) {
|
|
|
+ if (!result) return;
|
|
|
+ const prev = this.examInfo;
|
|
|
+ this.examInfo = {
|
|
|
+ ...prev,
|
|
|
+ attemptId: result.attemptId ?? prev.attemptId,
|
|
|
+ examId: result.examId ?? prev.examId,
|
|
|
+ assessmentScore:
|
|
|
+ result.assessmentScore ?? prev.assessmentScore,
|
|
|
+ retakeScore: result.retakeScore ?? prev.retakeScore,
|
|
|
+ totalScore: prev.totalScore,
|
|
|
+ examStatus: result.attemptStatus ?? prev.examStatus,
|
|
|
+ endTime: result.submitTime ?? prev.endTime,
|
|
|
+ // 交卷后 re-bind 题目数据,确保 answer 字段是最新的
|
|
|
+ };
|
|
|
+ // 记录是否合格(没有合格线时按 score > 0 粗略判定)
|
|
|
+ this.submitResult = result;
|
|
|
+ },
|
|
|
+
|
|
|
+ formatDateTime(value) {
|
|
|
+ if (!value) return '';
|
|
|
+ const d = new Date(value);
|
|
|
+ if (Number.isNaN(d.getTime())) return value;
|
|
|
+ const pad = (n) => String(n).padStart(2, '0');
|
|
|
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
|
+ },
|
|
|
+
|
|
|
+ // 格式化分数:空值显示 "-"
|
|
|
+ formatScore(value) {
|
|
|
+ if (value === undefined || value === null || value === '') return '-';
|
|
|
+ return value;
|
|
|
+ },
|
|
|
+ }
|
|
|
+ };
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+ .exam-taking {
|
|
|
+ padding: 16px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ min-height: calc(100vh - 60px);
|
|
|
+ }
|
|
|
+
|
|
|
+ .header-card {
|
|
|
+ margin-bottom: 16px;
|
|
|
+
|
|
|
+ ::v-deep .el-card__body {
|
|
|
+ padding: 16px 20px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .exam-header {
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .exam-title {
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .exam-meta {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 13px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-item {
|
|
|
+ margin: 0 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .meta-divider {
|
|
|
+ color: #c0c4cc;
|
|
|
+ margin: 0 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .exam-body {
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+ align-items: flex-start;
|
|
|
+ }
|
|
|
+
|
|
|
+ .exam-aside {
|
|
|
+ width: 260px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .aside-card {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .aside-card-title {
|
|
|
+ background: #f0f5ff;
|
|
|
+ color: #606266;
|
|
|
+ text-align: center;
|
|
|
+ padding: 8px 0;
|
|
|
+ font-size: 14px;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+ }
|
|
|
+
|
|
|
+ .timer-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 14px 12px;
|
|
|
+ gap: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .timer-icon {
|
|
|
+ color: #f56c6c;
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .timer-label {
|
|
|
+ color: #606266;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .timer-value {
|
|
|
+ color: #f56c6c;
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 600;
|
|
|
+ letter-spacing: 1px;
|
|
|
+ font-family: 'Courier New', Consolas, monospace;
|
|
|
+ }
|
|
|
+
|
|
|
+ .answer-card-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(9, 1fr);
|
|
|
+ gap: 8px;
|
|
|
+ padding: 14px 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .answer-card-cell {
|
|
|
+ position: relative;
|
|
|
+ height: 32px;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ background: #fff;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 13px;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ padding: 0;
|
|
|
+ line-height: 30px;
|
|
|
+ font-weight: 400;
|
|
|
+
|
|
|
+ &:hover:not(.answered) {
|
|
|
+ border-color: #409eff;
|
|
|
+ color: #409eff;
|
|
|
+ transform: translateY(-1px);
|
|
|
+ box-shadow: 0 2px 6px rgba(64, 158, 255, 0.12);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 已答:实心蓝底白字,带阴影
|
|
|
+ &.answered {
|
|
|
+ background: #409eff;
|
|
|
+ border-color: #409eff;
|
|
|
+ color: #fff;
|
|
|
+ box-shadow: 0 1px 3px rgba(64, 158, 255, 0.35);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 选中状态:加粗橙色边框 + 浅橙底,无论是否已答都凸显
|
|
|
+ &.current {
|
|
|
+ border: 2px solid #409eff;
|
|
|
+ line-height: 28px; // 补偿 2px 边框
|
|
|
+ font-weight: 600;
|
|
|
+
|
|
|
+ &.answered {
|
|
|
+ background: #409eff;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .candidate {
|
|
|
+ display: flex;
|
|
|
+ padding: 12px;
|
|
|
+ gap: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .candidate-avatar {
|
|
|
+ width: 60px;
|
|
|
+ height: 60px;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #fafafa;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #c0c4cc;
|
|
|
+ font-size: 32px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .candidate-info {
|
|
|
+ flex: 1;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ line-height: 1.8;
|
|
|
+ }
|
|
|
+
|
|
|
+ .candidate-label {
|
|
|
+ color: #909399;
|
|
|
+ margin-right: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .exam-main {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-card {
|
|
|
+ min-height: 540px;
|
|
|
+
|
|
|
+ ::v-deep .el-card__body {
|
|
|
+ padding: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-card-header {
|
|
|
+ background: #f0f5ff;
|
|
|
+ color: #303133;
|
|
|
+ padding: 10px 16px;
|
|
|
+ font-size: 14px;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-card-body {
|
|
|
+ padding: 20px 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-card-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 12px 24px;
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+ background: #fafafa;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-empty,
|
|
|
+ .loading-empty {
|
|
|
+ padding: 80px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-title {
|
|
|
+ color: #303133;
|
|
|
+ font-size: 15px;
|
|
|
+ line-height: 1.8;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-no {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #409eff;
|
|
|
+ margin-right: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-type {
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 500;
|
|
|
+ margin-right: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-score {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .question-content {
|
|
|
+ margin-top: 8px;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-list {
|
|
|
+ margin-top: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-group {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-item {
|
|
|
+ margin: 0 !important;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ white-space: normal;
|
|
|
+ word-break: break-word;
|
|
|
+ padding: 10px 14px;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #f5f7fa;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 选项被选中(单选组自动加 is-checked;多选由我们自己 toggle)
|
|
|
+ &.is-checked {
|
|
|
+ background: #ecf5ff;
|
|
|
+ border-color: #409eff;
|
|
|
+
|
|
|
+ .option-label {
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-content {
|
|
|
+ color: #303133;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-disabled {
|
|
|
+ cursor: not-allowed;
|
|
|
+ opacity: 0.85;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 单选(el-radio)的 input 控件与首行文字对齐
|
|
|
+ &--radio {
|
|
|
+ ::v-deep .el-radio__input {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ line-height: 1.6;
|
|
|
+ }
|
|
|
+
|
|
|
+ ::v-deep .el-radio__label {
|
|
|
+ flex: 1;
|
|
|
+ padding-left: 8px !important;
|
|
|
+ line-height: 1.6;
|
|
|
+ white-space: normal;
|
|
|
+ word-break: break-word;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 多选(原生 checkbox)容器内统一字号
|
|
|
+ &--checkbox {
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-label {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-right: 4px;
|
|
|
+ transition: color 0.2s;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-content {
|
|
|
+ color: #303133;
|
|
|
+ transition: all 0.2s;
|
|
|
+ word-break: break-word;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 多选分组:重置 elementui 的 font-size: 0
|
|
|
+ .option-group--checkbox {
|
|
|
+ font-size: 14px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 自绘复选框方框
|
|
|
+ .checkbox-box {
|
|
|
+ flex-shrink: 0;
|
|
|
+ position: relative;
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 2px;
|
|
|
+ background-color: #fff;
|
|
|
+ margin-top: 2px;
|
|
|
+ transition:
|
|
|
+ border-color 0.2s,
|
|
|
+ background-color 0.2s;
|
|
|
+ box-sizing: border-box;
|
|
|
+ margin-right: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .checkbox-tick {
|
|
|
+ position: absolute;
|
|
|
+ top: 1px;
|
|
|
+ left: 5px;
|
|
|
+ width: 4px;
|
|
|
+ height: 8px;
|
|
|
+ border: solid #fff;
|
|
|
+ border-width: 0 2px 2px 0;
|
|
|
+ transform: rotate(45deg) scale(0);
|
|
|
+ transform-origin: center;
|
|
|
+ opacity: 0;
|
|
|
+ transition: all 0.15s ease-in 0.05s;
|
|
|
+ box-sizing: border-box;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 原生 checkbox 透明覆盖层(用于接收焦点 / 表单语义,视觉上交给 .checkbox-box)
|
|
|
+ .checkbox-native {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ opacity: 0;
|
|
|
+ margin: 0;
|
|
|
+ cursor: pointer;
|
|
|
+ z-index: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-item--checkbox.is-checked {
|
|
|
+ .checkbox-box {
|
|
|
+ background-color: #409eff;
|
|
|
+ border-color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .checkbox-tick {
|
|
|
+ opacity: 1;
|
|
|
+ transform: rotate(45deg) scale(1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .essay-block {
|
|
|
+ margin-top: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ ::v-deep .el-radio {
|
|
|
+ margin-right: 0;
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============== 结果模式样式 ==============
|
|
|
+ .result-summary-card .aside-card-title {
|
|
|
+ background: #f0f9ff;
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .result-summary {
|
|
|
+ padding: 14px 16px;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.9;
|
|
|
+ }
|
|
|
+
|
|
|
+ .summary-row {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ border-bottom: 1px dashed #f0f0f0;
|
|
|
+ padding: 4px 0;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .summary-label {
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+
|
|
|
+ .summary-value {
|
|
|
+ color: #303133;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+
|
|
|
+ .summary-value.score-value {
|
|
|
+ color: #f56c6c;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .summary-value.is-fail {
|
|
|
+ color: #f56c6c;
|
|
|
+ }
|
|
|
+
|
|
|
+ .result-mode-tag {
|
|
|
+ margin-left: 8px;
|
|
|
+ background: #e6f7ff;
|
|
|
+ color: #1890ff;
|
|
|
+ border: 1px solid #91d5ff;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 1px 8px;
|
|
|
+ border-radius: 10px;
|
|
|
+ font-weight: normal;
|
|
|
+ }
|
|
|
+</style>
|