| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784 |
- <!-- 刷题练习页 -->
- <template>
- <view class="practice-screen">
- <!-- 练习用时顶栏(绿色) -->
- <view class="timer-bar">
- <text class="timer-label">练习用时</text>
- <text class="timer-text">{{ timerDisplay }}</text>
- <text class="timer-mode">刷题练习模式</text>
- </view>
- <!-- 答题统计行 -->
- <view class="stat-row">
- <view class="stat-item">
- <text class="stat-num">{{ answered }}</text>
- <text class="stat-label">已答</text>
- </view>
- <view class="stat-divider"></view>
- <view class="stat-item">
- <text class="stat-num stat-correct">{{ correct }}</text>
- <text class="stat-label">正确</text>
- </view>
- <view class="stat-divider"></view>
- <view class="stat-item">
- <text class="stat-num stat-wrong">{{ incorrect }}</text>
- <text class="stat-label">错误</text>
- </view>
- <view class="stat-divider"></view>
- <view class="stat-item">
- <text class="stat-num">{{ accuracyText }}</text>
- <text class="stat-label">正确率</text>
- </view>
- </view>
- <!-- 题号导航面板(底部弹出,仿 mockExamPlay) -->
- <view v-if="showQuestionNav" class="q-nav-panel" @tap="closeNav">
- <view class="q-nav-scroll-wrapper" @tap.stop>
- <view class="q-nav-header">
- <text class="q-nav-title">题目列表</text>
- <text class="q-nav-close" @tap="closeNav">✕</text>
- </view>
- <scroll-view scroll-y class="q-nav-scroll" :style="'height:' + navScrollHeight + 'px'">
- <view v-for="(group, gi) in questionTypeGroups" :key="gi" class="q-group">
- <view class="q-group-title">{{ group.questionTypeName }}({{ (group.questions || []).length }}题)</view>
- <view class="q-num-row">
- <view
- v-for="(item, qi) in group.questions"
- :key="qi"
- class="q-num"
- :class="getNavNumClass(item, gi, qi)"
- :data-gi="gi"
- :data-qi="qi"
- @tap="onNavNumTap"
- >{{ item.seq }}</view>
- </view>
- </view>
- <!-- 图例 -->
- <view class="nav-legend">
- <view class="legend-item"><view class="q-num legend-dot answered"></view><text>已答对</text></view>
- <view class="legend-item"><view class="q-num legend-dot wrong"></view><text>答错</text></view>
- <view class="legend-item"><view class="q-num legend-dot current"></view><text>当前</text></view>
- </view>
- </scroll-view>
- </view>
- </view>
- <!-- 题目内容 -->
- <scroll-view scroll-y class="q-scroll-content" :style="scrollStyle">
- <view v-if="currentQuestion && currentQuestion.id" class="q-card">
- <!-- 题目头部 -->
- <view class="q-header">
- <view class="q-header-left">
- <text class="q-type-badge">{{ currentQuestion.questionTypeName }}</text>
- <text class="q-num-text">第 {{ currentSeq }} 题</text>
- <text v-if="currentQuestion.difficulty" class="diff-badge" :class="diffClass(currentQuestion.difficulty)">{{ diffName(currentQuestion.difficulty) }}</text>
- </view>
- <view class="view-answer-btn" @tap="toggleAnswer">
- {{ viewAnswerShowType ? '关闭答案' : '查看答案' }}
- </view>
- </view>
- <!-- 题干 -->
- <view class="q-content">
- <rich-text :nodes="currentQuestion.questionContent || ''"></rich-text>
- </view>
- <!-- 选项 -->
- <view class="q-options">
- <view
- v-for="(opt, oi) in (currentQuestion.options || [])"
- :key="oi"
- class="q-option"
- :class="{ selected: isSelected(opt.optionTag) }"
- :data-tag="opt.optionTag"
- @tap="onOptionTap"
- >
- <view class="q-option-label">{{ opt.optionTag }}</view>
- <view class="q-option-content">
- <rich-text :nodes="opt.optionContent || ''"></rich-text>
- </view>
- </view>
- </view>
- <!-- 查看答案区域 -->
- <view v-if="viewAnswerShowType" class="answer-result-box">
- <view class="answer-row">
- <text class="answer-label">答案:</text>
- <text class="answer-value">{{ formatAnswer(currentQuestion.correctAnswer) }}</text>
- </view>
- <view class="analysis-title">试题解析</view>
- <view class="analysis-content">
- <rich-text v-if="currentQuestion.analysis" :nodes="currentQuestion.analysis"></rich-text>
- <text v-else class="analysis-empty">无</text>
- </view>
- </view>
- </view>
- <view v-else-if="loading" class="empty-tip">加载中...</view>
- <view v-else class="empty-tip">暂无题目</view>
- <view class="scroll-bottom-space"></view>
- </scroll-view>
- <!-- 上一题/下一题 -->
- <view class="q-nav">
- <view class="q-nav-btn" :class="{ disabled: isFirstQuestion }" @tap="prevQuestion">上一题</view>
- <view class="q-nav-btn q-nav-center" @tap="toggleQuestionNav">选题</view>
- <view class="q-nav-btn q-nav-btn-next" :class="{ disabled: isLastQuestion }" @tap="nextQuestion">下一题</view>
- </view>
- </view>
- </template>
- <script>
- import {
- examUserPracticePanel,
- examUserPracticeQuestionDetail,
- examUserPracticeSubmit,
- examUserExamStart,
- } from '../../api/index.js';
- const DIFFICULTY_MAP = {
- 1: { name: '简单', cls: 'diff-easy' },
- 2: { name: '一般', cls: 'diff-normal' },
- 3: { name: '较难', cls: 'diff-hard' },
- 4: { name: '极难', cls: 'diff-extreme' },
- };
- export default {
- data() {
- return {
- newData: {},
- examInfo: {
- knowledgePointName: '',
- totalCount: 0,
- },
- questionTypeGroups: [],
- currentQuestion: null,
- groupIndex: 0,
- questionIndex: 0,
- viewAnswerShowType: false,
- showQuestionNav: false,
- navScrollHeight: 300,
- loading: false,
- // 计时器
- elapsedSeconds: 0,
- timerInterval: null,
- }
- },
- computed: {
- answered() {
- let n = 0;
- this.questionTypeGroups.forEach(g => {
- if (g.questions) g.questions.forEach(q => { if (q.answerStatus === 1 || q.answerStatus === 2) n++; });
- });
- return n;
- },
- correct() {
- let n = 0;
- this.questionTypeGroups.forEach(g => {
- if (g.questions) g.questions.forEach(q => { if (q.answerStatus === 1) n++; });
- });
- return n;
- },
- incorrect() {
- let n = 0;
- this.questionTypeGroups.forEach(g => {
- if (g.questions) g.questions.forEach(q => { if (q.answerStatus === 2) n++; });
- });
- return n;
- },
- accuracyText() {
- let total = 0;
- this.questionTypeGroups.forEach(g => { if (g.questions) total += g.questions.length; });
- if (total === 0 || this.correct === 0) return '0%';
- return (this.correct / total * 100).toFixed(0) + '%';
- },
- timerDisplay() {
- const m = Math.floor(this.elapsedSeconds / 60);
- const s = this.elapsedSeconds % 60;
- return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
- },
- isFirstQuestion() {
- return this.groupIndex === 0 && this.questionIndex === 0;
- },
- isLastQuestion() {
- const last = this.questionTypeGroups.length - 1;
- if (last < 0) return true;
- const lastQ = this.questionTypeGroups[last].questions.length - 1;
- return this.groupIndex === last && this.questionIndex === lastQ;
- },
- currentSeq() {
- const g = this.questionTypeGroups[this.groupIndex];
- if (!g || !g.questions[this.questionIndex]) return '-';
- return g.questions[this.questionIndex].seq;
- },
- scrollStyle() {
- // timer-bar ~48px + stat-row ~52px + bottom-nav ~44px
- const sys = uni.getSystemInfoSync();
- const h = sys.windowHeight - 48 - 52 - 44;
- return 'height:' + (h > 200 ? h : 400) + 'px;';
- },
- },
- onLoad(options) {
- this.newData = {
- knowledgePointId: options.knowledgePointId || '',
- attemptId: options.attemptId || '',
- examId: options.examId || '',
- };
- uni.setNavigationBarTitle({ title: '刷题练习' });
- this.startTimer();
- // 计算题号面板可滚动高度
- const sys = uni.getSystemInfoSync();
- this.navScrollHeight = Math.floor(sys.windowHeight * 0.5);
- this.loadPanel();
- },
- onUnload() {
- this.stopTimer();
- },
- // 支持导航栏"题号"按钮(pages.json 中配置 navigationBarButtons)
- onNavigationBarButtonTap(e) {
- if (e.index === 0) {
- this.showQuestionNav = !this.showQuestionNav;
- }
- },
- methods: {
- closeNav() {
- this.showQuestionNav = false;
- },
- toggleQuestionNav() {
- this.showQuestionNav = !this.showQuestionNav;
- },
- toggleAnswer() {
- this.viewAnswerShowType = !this.viewAnswerShowType;
- },
- onOptionTap(e) {
- const tag = e.currentTarget.dataset.tag;
- if (tag !== undefined) this.selectOption(tag);
- },
- onNavNumTap(e) {
- const gi = Number(e.currentTarget.dataset.gi);
- const qi = Number(e.currentTarget.dataset.qi);
- this.jumpToQuestion(gi, qi);
- },
- startTimer() { this.timerInterval = setInterval(() => {
- this.elapsedSeconds++;
- }, 1000);
- },
- stopTimer() {
- if (this.timerInterval) clearInterval(this.timerInterval);
- },
- diffName(d) { return DIFFICULTY_MAP[d] ? DIFFICULTY_MAP[d].name : ''; },
- diffClass(d) { return DIFFICULTY_MAP[d] ? DIFFICULTY_MAP[d].cls : ''; },
- isSelected(tag) {
- if (!this.currentQuestion || !this.currentQuestion.userAnswer) return false;
- return this.currentQuestion.userAnswer.indexOf(tag) !== -1;
- },
- formatAnswer(ans) {
- if (!ans) return '';
- return ans.split(',').map(a => {
- if (a === 'Y') return '对';
- if (a === 'N') return '错';
- return a;
- }).join('、');
- },
- getNavNumClass(item, gi, qi) {
- const isCurrent = this.groupIndex === gi && this.questionIndex === qi;
- if (isCurrent) return 'current';
- if (item.answerStatus === 1) return 'answered';
- if (item.answerStatus === 2) return 'wrong';
- return '';
- },
- // 加载左侧面板数据
- loadPanel() {
- if (this.loading) return;
- this.loading = true;
- examUserPracticePanel({ knowledgePointId: this.newData.knowledgePointId }).then(res => {
- const resp = res.data || {};
- if (resp.code !== 200) {
- uni.showToast({ title: resp.message || '加载失败', icon: 'none' });
- return;
- }
- const d = resp.data || {};
- this.examInfo = {
- knowledgePointName: d.knowledgePointName || '',
- totalCount: d.totalCount || 0,
- };
- this.$set(this, 'questionTypeGroups', d.questionTypeGroups || []);
- this.groupIndex = 0;
- this.questionIndex = 0;
- const first = (d.questionTypeGroups || [])[0];
- if (first && first.questions && first.questions[0]) {
- this.loadQuestionDetail(first.questions[0].questionId, first.questions[0].userAnswer);
- }
- }).catch(e => {
- console.error('loadPanel error', e);
- uni.showToast({ title: '加载失败', icon: 'none' });
- }).finally(() => {
- this.loading = false;
- });
- },
- // 加载单题详情
- loadQuestionDetail(questionId, userAnswer) {
- examUserPracticeQuestionDetail({ questionId }).then(res => {
- const resp = res.data || {};
- if (resp.code !== 200) return;
- const q = resp.data || {};
- // 判断题补充选项
- if (q.questionType == 3) {
- q.options = [
- { optionContent: '正确', optionTag: 'Y', sortOrder: 1 },
- { optionContent: '错误', optionTag: 'N', sortOrder: 2 },
- ];
- }
- q.userAnswer = Array.isArray(userAnswer)
- ? userAnswer
- : (userAnswer ? userAnswer.split(',') : []);
- this.$set(this, 'currentQuestion', q);
- this.$set(this, 'viewAnswerShowType', false);
- }).catch(e => {
- console.error('loadQuestionDetail error', e);
- });
- },
- // 选择选项
- selectOption(tag) {
- if (!this.currentQuestion) return;
- const type = this.currentQuestion.questionType;
- let ans = [...(this.currentQuestion.userAnswer || [])];
- if (type == 1 || type == 3) {
- // 单选 / 判断:单选
- ans = [tag];
- } else if (type == 2) {
- // 多选
- const idx = ans.indexOf(tag);
- if (idx === -1) {
- ans.push(tag);
- } else {
- ans.splice(idx, 1);
- }
- }
- this.$set(this.currentQuestion, 'userAnswer', ans);
- },
- // 提交当前题答案并切换题目
- _submitThenGo(nextGroupIndex, nextQuestionIndex) {
- const q = this.currentQuestion;
- const hasAnswer = q && q.userAnswer && q.userAnswer[0];
- if (!hasAnswer) {
- this._goTo(nextGroupIndex, nextQuestionIndex);
- return;
- }
- const userAnswerStr = q.userAnswer.join(',');
- examUserPracticeSubmit({
- attemptId: this.newData.attemptId,
- questionId: q.id,
- userAnswer: userAnswerStr,
- }).then(res => {
- const data = (res.data || {}).data || {};
- // 回写 answerStatus 和 userAnswer 到面板 item
- const answerStatus = data.answerStatus;
- const g = this.questionTypeGroups[this.groupIndex];
- if (g && g.questions[this.questionIndex]) {
- this.$set(g.questions[this.questionIndex], 'answerStatus', answerStatus);
- this.$set(g.questions[this.questionIndex], 'userAnswer', userAnswerStr);
- }
- this._goTo(nextGroupIndex, nextQuestionIndex);
- }).catch(() => {
- this._goTo(nextGroupIndex, nextQuestionIndex);
- });
- },
- _goTo(gi, qi) {
- this.groupIndex = gi;
- this.questionIndex = qi;
- const g = this.questionTypeGroups[gi];
- if (!g) return;
- const item = g.questions[qi];
- if (!item) return;
- this.loadQuestionDetail(item.questionId, item.userAnswer);
- },
- prevQuestion() {
- if (this.isFirstQuestion) return;
- let gi = this.groupIndex;
- let qi = this.questionIndex;
- if (qi > 0) {
- qi--;
- } else {
- gi--;
- qi = (this.questionTypeGroups[gi] || { questions: [] }).questions.length - 1;
- }
- this._submitThenGo(gi, qi);
- },
- nextQuestion() {
- if (this.isLastQuestion) return;
- let gi = this.groupIndex;
- let qi = this.questionIndex;
- const curGroup = this.questionTypeGroups[gi];
- if (qi < curGroup.questions.length - 1) {
- qi++;
- } else {
- gi++;
- qi = 0;
- }
- this._submitThenGo(gi, qi);
- },
- // 从题号面板跳题
- jumpToQuestion(gi, qi) {
- this.showQuestionNav = false;
- if (gi === this.groupIndex && qi === this.questionIndex) return;
- this._submitThenGo(gi, qi);
- },
- },
- }
- </script>
- <style lang="stylus" scoped>
- .practice-screen
- height 100%
- display flex
- flex-direction column
- background #f0f4fb
- overflow hidden
- // 顶部绿色计时器
- .timer-bar
- display flex
- align-items center
- justify-content center
- gap 16rpx
- background linear-gradient(90deg, #52c41a, #389e0d)
- padding 12rpx 32rpx
- flex-shrink 0
- .timer-label
- color rgba(255,255,255,.8)
- font-size 24rpx
- .timer-text
- color #fff
- font-size 44rpx
- font-weight 700
- letter-spacing 4rpx
- .timer-mode
- color rgba(255,255,255,.6)
- font-size 22rpx
- // 统计行
- .stat-row
- display flex
- align-items center
- background #fff
- padding 12rpx 0
- flex-shrink 0
- border-bottom 1rpx solid #e8edf3
- .stat-item
- flex 1
- display flex
- flex-direction column
- align-items center
- .stat-num
- font-size 36rpx
- font-weight 700
- color #333
- .stat-correct
- color #52c41a
- .stat-wrong
- color #ff4d4f
- .stat-label
- font-size 22rpx
- color #999
- margin-top 2rpx
- .stat-divider
- width 1rpx
- height 48rpx
- background #e8edf3
- // 题目内容滚动区
- .q-scroll-content
- flex 1
- overflow hidden
- .q-card
- margin 24rpx
- padding 28rpx
- background #fff
- border-radius 20rpx
- border 1rpx solid #dde3ed
- // 题头
- .q-header
- display flex
- align-items center
- justify-content space-between
- margin-bottom 20rpx
- .q-header-left
- display flex
- align-items center
- flex-wrap wrap
- gap 10rpx
- .q-type-badge
- background #1677ff
- color #fff
- font-size 22rpx
- padding 4rpx 14rpx
- border-radius 8rpx
- .q-num-text
- font-size 26rpx
- font-weight 600
- color #333
- .diff-badge
- font-size 20rpx
- padding 2rpx 12rpx
- border-radius 8rpx
- border 1rpx solid transparent
- .diff-easy
- background #f6ffed
- color #52c41a
- border-color #b7eb8f
- .diff-normal
- background #e6f4ff
- color #1677ff
- border-color #91caff
- .diff-hard
- background #fff7e6
- color #fa8c16
- border-color #ffd591
- .diff-extreme
- background #f9f0ff
- color #722ed1
- border-color #d3adf7
- .view-answer-btn
- background #1677ff
- color #fff
- font-size 24rpx
- padding 8rpx 24rpx
- border-radius 10rpx
- flex-shrink 0
- // 题干
- .q-content
- font-size 28rpx
- color #333
- line-height 1.7
- margin-bottom 24rpx
- // 选项
- .q-options
- display flex
- flex-direction column
- gap 16rpx
- margin-bottom 16rpx
- .q-option
- display flex
- align-items center
- border 1rpx solid #e8edf3
- border-radius 12rpx
- padding 20rpx 24rpx
- .q-option.selected
- border-color #1677ff
- background #e6f7ff
- .q-option-label
- background #1677ff
- color #fff
- .q-option-label
- width 48rpx
- height 48rpx
- line-height 48rpx
- text-align center
- border-radius 50%
- background #f0f0f0
- color #666
- font-size 24rpx
- font-weight 600
- flex-shrink 0
- margin-right 20rpx
- .q-option-content
- flex 1
- font-size 28rpx
- color #333
- // 答案区
- .answer-result-box
- background #f5f5f5
- border-radius 12rpx
- padding 24rpx
- margin-top 16rpx
- .answer-row
- font-size 26rpx
- color #333
- margin-bottom 12rpx
- .answer-label
- font-weight 600
- .answer-value
- color #52c41a
- font-weight 600
- .analysis-title
- font-size 26rpx
- font-weight 600
- color #333
- margin-bottom 8rpx
- .analysis-content
- font-size 26rpx
- color #666
- line-height 1.7
- margin 0 20rpx
- .analysis-empty
- color #999
- .scroll-bottom-space
- height 40rpx
- // 底部上一题/选题/下一题
- .q-nav
- display flex
- background #fff
- border-top 1rpx solid #e8edf3
- flex-shrink 0
- .q-nav-btn
- flex 1
- height 88rpx
- line-height 88rpx
- text-align center
- font-size 28rpx
- color #1677ff
- .q-nav-btn.q-nav-btn-next
- border-left 1rpx solid #e8edf3
- .q-nav-center
- border-left 1rpx solid #e8edf3
- border-right 1rpx solid #e8edf3
- .q-nav-btn.disabled
- color #ccc
- // 题号导航底部弹层
- .q-nav-panel
- position fixed
- top 0
- left 0
- right 0
- bottom 0
- background rgba(0,0,0,0.45)
- z-index 100
- display flex
- flex-direction column
- justify-content flex-end
- .q-nav-scroll-wrapper
- background #fff
- border-radius 32rpx 32rpx 0 0
- max-height 70vh
- display flex
- flex-direction column
- .q-nav-header
- display flex
- align-items center
- justify-content space-between
- padding 28rpx 28rpx 16rpx
- flex-shrink 0
- .q-nav-title
- font-size 30rpx
- font-weight 600
- color #222
- .q-nav-close
- font-size 32rpx
- color #999
- padding 10rpx
- .q-nav-scroll
- overflow hidden
- padding-bottom 40rpx
- .q-group
- padding 0 28rpx 16rpx
- .q-group-title
- font-size 24rpx
- color #666
- line-height 60rpx
- .q-num-row
- display flex
- flex-wrap wrap
- .q-num
- width 72rpx
- height 72rpx
- line-height 72rpx
- text-align center
- font-size 24rpx
- border-radius 12rpx
- margin 8rpx
- background #f4f6fa
- color #333
- border 1rpx solid transparent
- .q-num.answered
- background #f6ffed
- color #52c41a
- border-color #b7eb8f
- .q-num.wrong
- background #fff2f0
- color #ff4d4f
- border-color #ffa39e
- .q-num.current
- background #1677ff
- color #fff
- // 图例
- .nav-legend
- display flex
- gap 24rpx
- flex-wrap wrap
- padding 12rpx 28rpx 24rpx
- .legend-item
- display flex
- align-items center
- gap 8rpx
- font-size 22rpx
- color #666
- .legend-dot
- width 40rpx
- height 40rpx
- margin 0
- font-size 0
- // 空态
- .empty-tip
- text-align center
- line-height 120rpx
- color #aaa
- font-size 24rpx
- </style>
|