import * as paperService from './modules/paper-service.js'; import * as timerService from './modules/timer-service.js'; import { questionApi } from '../../config/question_api.js'; import { campApi } from '../../config/camp_api.js'; Page({ data: { paperId: null, taskId: null, paperInfo: { title: '', description: '', duration_minutes: 0 }, questions: [], currentQuestionIndex: 0, loading: true, answers: [], materials: [], materialTitle: '资料', materialTabLabels: [], materialScrollHeight: 400, currentMaterialIndex: 0, currentQuestionMaterial: null, // 当前题目对应的材料(根据题目的 material_id 查找) panelY: 0, panelHeight: 400, panelYMin: 0, panelYMax: 400, movableAreaHeight: 600, showFullMaterial: false, materialHeight: 400, isResizing: false, startY: 0, startHeight: 200, minHeight: 200, maxHeight: 1000, currentAnswers: [], isMultiple: false, optionMarkers: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'], optionsClasses: [], materialStyle: '', tagStyle: { img: 'max-width: 100%; height: auto;', 'img.inline': 'display: inline-block; vertical-align: middle; max-height: 1.5em;', p: 'margin: 0; padding: 0;', table: 'border-collapse: collapse; margin: 0;', th: 'border: 1px solid #ccc; padding: 4px 8px;', td: 'border: 1px solid #ccc; padding: 4px 8px;', }, courseId: null, showExplanation: false, currentExplanation: '', currentQuestion: null, questionAnsweredStatus: [], showQuestionCard: false, startTime: '', systemInfo: null, animation: null, currentHeight: 400, resizeAnimation: null, isPaused: false, timerText: '00:00:00', pauseStartTime: null, showCustomModal: false, pendingSubmit: false, swiperHeight: 0, }, onLoad(options) { // console.log('页面加载参数:', options); const { paper_id, task_id, course_id, task_title, camp_id } = options; const decodedTitle = task_title ? decodeURIComponent(task_title) : ''; wx.setNavigationBarTitle({ title: decodedTitle || '客观题' }); if (!paper_id) { wx.showToast({ title: '试卷ID不存在', icon: 'none' }); return; } // 计算主内容区高度、问题面板高度、拖动边界(单位 px) // 内容区全屏,工具栏为固定覆盖层不占位;问题面板可很高(从资料标题到屏幕底) try { const sys = wx.getSystemInfoSync(); const w = sys.windowWidth || 375; const h = Math.max(0, sys.windowHeight); const materialHeaderPx = Math.round(160 * (w / 750)); const panelYMin = materialHeaderPx; // 面板高度 = 从资料标题下沿到屏幕底(很高),可上下拖动 const panelHeight = Math.max(200, h - panelYMin); // 向下拖时至少保留约 80px 面板可见,便于继续拖动;工具栏盖在面板上 const panelYMax = Math.max(panelYMin, h - 80); const movableAreaHeight = panelYMax + panelHeight; // 问题面板默认盖住半屏:初始 y 为屏幕高度一半 const defaultPanelY = Math.max(panelYMin, Math.min(panelYMax, Math.round(h / 2))); this.setData({ swiperHeight: h, panelHeight, panelY: defaultPanelY, panelYMin, panelYMax, movableAreaHeight }); } catch (e) {} this.setData({ taskTitle: decodedTitle || '客观题', campId: camp_id, paperId: paper_id, taskId: task_id, courseId: course_id, loading: true, answers: [], questionAnsweredStatus: [], startTime: new Date().toISOString(), }); // 加载题目数据(不包含答案和解析) this.fetchQuestionPaper(paper_id, task_id) .then(() => { setTimeout(() => { this.setData({ loading: false }); }, 300); }) .catch(error => { wx.showToast({ title: '加载试卷失败', icon: 'none' }); this.setData({ loading: false }); }); }, onReady() { // 初始化系统信息 this.systemInfo = wx.getSystemInfoSync(); // 创建动画实例 this.animation = wx.createAnimation({ duration: 0, timingFunction: 'linear' }); }, /** * 根据题目的 material_id 查找对应的材料 * @param {Object} question - 题目对象 * @param {Array} materials - 材料数组(可选,默认使用 this.data.materials) * @returns {Object|null} 对应的材料对象,如果没有则返回 null */ findMaterialForQuestion(question, materials) { const mats = materials || this.data.materials || []; if (!question || !question.material_id) return null; return mats.find(m => m.id === question.material_id) || null; }, /** * 切换题目时更新当前题目的材料和面板位置 * @param {Number} questionIndex - 题目索引 */ updateQuestionMaterial(questionIndex) { const question = this.data.questions[questionIndex]; const currentQuestionMaterial = this.findMaterialForQuestion(question); const { panelYMin, panelYMax, swiperHeight } = this.data; // 如果当前题目有材料,面板默认在半屏;没有材料则在顶部 const defaultPanelY = Math.max(panelYMin, Math.min(panelYMax, Math.round(swiperHeight / 2))); const newPanelY = currentQuestionMaterial ? defaultPanelY : 0; this.setData({ currentQuestionIndex: questionIndex, currentQuestion: question, currentQuestionMaterial, panelY: newPanelY }); }, fetchQuestionPaper(paperId, taskId) { return paperService.fetchQuestionPaper(paperId, taskId).then(paperData => { const materials = paperData.materials || []; const materialTitle = materials.length <= 1 ? '资料' : '资料一'; const tabLabels = ['资料一', '资料二', '资料三', '资料四', '资料五', '资料六', '资料七', '资料八', '资料九', '资料十']; const materialTabLabels = (materials.length > 1) ? materials.map((_, i) => tabLabels[i] || ('资料' + (i + 1))) : []; const h = this.data.swiperHeight || 400; const w = (() => { try { return wx.getSystemInfoSync().windowWidth; } catch (e) { return 375; } })(); const titleBarPx = materials.length > 1 ? Math.round(140 * (w / 750)) : Math.round(100 * (w / 750)); const materialScrollHeight = materials.length > 0 ? Math.max(0, h - titleBarPx) : h; const panelYMin = this.data.panelYMin ?? Math.round(160 * (375 / 750)); const panelHeight = Math.max(200, h - panelYMin); const panelYMax = Math.max(panelYMin, h - 80); const movableAreaHeight = panelYMax + panelHeight; // 有材料时问题面板默认盖住半屏 const defaultPanelY = Math.max(panelYMin, Math.min(panelYMax, Math.round(h / 2))); const panelY = materials.length > 0 ? defaultPanelY : 0; // 根据第一道题目的 material_id 查找对应材料 const firstQuestion = paperData.questions[0] || null; const currentQuestionMaterial = this.findMaterialForQuestion(firstQuestion, materials); // 如果当前题目没有材料,面板从顶部开始 const actualPanelY = currentQuestionMaterial ? defaultPanelY : 0; this.setData({ paperInfo: paperData.paperInfo, questions: paperData.questions, answers: paperData.answers, questionAnsweredStatus: paperData.questionAnsweredStatus, currentQuestionIndex: 0, currentQuestion: firstQuestion, materials, materialTitle, materialTabLabels, materialScrollHeight, currentMaterialIndex: 0, currentQuestionMaterial, panelHeight, panelY: actualPanelY, panelYMin, panelYMax, movableAreaHeight, loading: false }); if (!this.data.taskTitle && paperData.paperInfo && paperData.paperInfo.title) { wx.setNavigationBarTitle({ title: paperData.paperInfo.title }); } // 试卷加载完成后启动计时器 const durationMinutes = paperData.paperInfo ? (paperData.paperInfo.duration_minutes || 0) : 0; if (durationMinutes > 0) { timerService.startTimer(this, durationMinutes, this.data.startTime, () => { wx.showModal({ title: '时间到', content: '答题时间已到,将自动提交', showCancel: false, success: () => { this.submitAnswers(); } }); }); } else { // 如果没有时长限制,使用简单的计时器(显示已用时间) this.startSimpleTimer(); } }); }, stripHtmlTags(html) { if (!html) return ''; return html.replace(/<\/?[^>]+(>|$)/g, ""); }, onMaterialChange(e) { const idx = e.detail.current; const labels = (this.data.materialTabLabels || []); const materialTitle = labels[idx] || ('资料' + (idx + 1)); this.setData({ currentMaterialIndex: idx, materialTitle }); }, onMaterialTabTap(e) { const index = parseInt(e.currentTarget.dataset.index, 10) || 0; const labels = (this.data.materialTabLabels || []); const materialTitle = labels[index] || ('资料' + (index + 1)); this.setData({ currentMaterialIndex: index, materialTitle }); }, updateOptionsClasses() { const { questions, answers } = this.data; const newOptionsClasses = questions.map((question, qIndex) => { const questionAnswers = answers[qIndex] || []; return question.options.map((_, optIndex) => questionAnswers.includes(optIndex) ? 'option-selected-single' : '' ); }); this.setData({ optionsClasses: newOptionsClasses }); }, handleOptionClick(e) { // 处理组件触发的事件 const { optionIndex, questionIndex } = e.detail; const currentQuestion = this.data.questions[questionIndex]; const isSingleChoice = currentQuestion.type === 'single_choice'; const isLastQuestion = questionIndex === this.data.questions.length - 1; let newAnswers = [...this.data.answers]; if (!newAnswers[questionIndex]) { newAnswers[questionIndex] = new Array(currentQuestion.options.length).fill(false); } if (isSingleChoice) { newAnswers[questionIndex] = newAnswers[questionIndex].map((_, i) => i === optionIndex); } else { newAnswers[questionIndex][optionIndex] = !newAnswers[questionIndex][optionIndex]; } let newQuestionAnsweredStatus = [...this.data.questionAnsweredStatus]; newQuestionAnsweredStatus[questionIndex] = true; this.setData({ answers: newAnswers, questionAnsweredStatus: newQuestionAnsweredStatus }, () => { if (isSingleChoice) { if (isLastQuestion) { setTimeout(() => { this.setData({ showQuestionCard: true }); }, 300); } else { const nextIndex = questionIndex + 1; this.updateQuestionMaterial(nextIndex); } } }); }, getOptionClass(index) { const { currentQuestionIndex, answers } = this.data; if (!answers || !answers[currentQuestionIndex]) return ''; return answers[currentQuestionIndex][index] ? 'option-selected' : ''; }, onQuestionTransition() { // 保持为空 }, onQuestionAnimationFinish(e) { const index = e.detail.current; this.setData({ currentQuestionIndex: index, currentMaterialIndex: 0 }); }, // 简单计时器(显示已用时间,无时长限制) startSimpleTimer() { if (this.timerInterval) { clearInterval(this.timerInterval); } this.timerInterval = setInterval(() => { if (!this.data.isPaused) { const startTime = new Date(this.data.startTime); const currentTime = new Date(); const elapsedTime = Math.floor((currentTime - startTime) / 1000); const hours = Math.floor(elapsedTime / 3600); const minutes = Math.floor((elapsedTime % 3600) / 60); const seconds = elapsedTime % 60; const formattedTime = [ hours.toString().padStart(2, '0'), minutes.toString().padStart(2, '0'), seconds.toString().padStart(2, '0') ].join(':'); this.setData({ timerText: formattedTime }); } }, 1000); }, togglePause() { const newPausedState = !this.data.isPaused; // 更新暂停状态,控制遮罩显示/隐藏 this.setData({ isPaused: newPausedState }); if (newPausedState) { // 暂停计时器 const pausedSeconds = timerService.pauseTimer(this); this._pausedSeconds = pausedSeconds; } else { // 恢复计时器 if (this._pausedSeconds !== undefined) { const durationMinutes = this.data.paperInfo ? (this.data.paperInfo.duration_minutes || 0) : 0; if (durationMinutes > 0) { timerService.resumeTimer(this, this._pausedSeconds, () => { wx.showModal({ title: '时间到', content: '答题时间已到,将自动提交', showCancel: false, success: () => { this.submitAnswers(); } }); }); } else { // 简单计时器恢复 this.startSimpleTimer(); } } } }, showAnswerCard: function () { // 实现显示答题卡逻辑 }, nextQuestion: function () { if (this.data.currentQuestionIndex < this.data.questions.length - 1) { this.updateQuestionMaterial(this.data.currentQuestionIndex + 1); } }, onAnswersChange: function () { console.log('Current answers state:', this.data.answers); }, isOptionSelected: function (questionIndex, optionIndex) { const answers = this.data.answers[questionIndex] || []; return answers.indexOf(optionIndex) !== -1; }, isQuestionAnswered: function (index) { const answers = this.data.answers; console.log('answers:', answers); if (!answers || !answers[index]) return false; return answers[index].some(answer => answer === true); }, onMaterialResizeEnd(e) { this.setData({ currentMaterialIndex: e.detail.current }); }, onRichTextTap(e) { const nodes = e.currentTarget.dataset.content; if (!nodes || !Array.isArray(nodes)) return; const images = nodes .filter(node => node.name === 'img') .map(node => node.attrs.src) .filter(url => url && url.startsWith('http')); if (images.length > 0) { wx.previewImage({ current: images[0], urls: images }); } }, previewImage(e) { const { url, urls } = e.currentTarget.dataset; wx.previewImage({ current: url, urls: urls || [url] }); }, onMaterialImageTap(e) { const url = e.currentTarget.dataset.url; if (url) { wx.previewImage({ current: url, urls: [url] }); } }, processRichText(content) { if (!content) return ''; return content.replace(//g, (match, attrs) => { const isInline = match.includes('style="display: inline') || match.includes('vertical-align: middle'); if (isInline) { return match.replace(' answer === true); }, submitAnswers() { // 先关闭自定义答题卡弹窗 this.setData({ // showQuestionCard: false, isPaused: false }); // 检查是否有未作答的题目 const unanswered = this.data.answers.some(arr => !arr || arr.every(v => !v)); // 只定义 doSubmit,不直接调用 const doSubmit = () => { if (this.data.pendingSubmit) return; this.setData({ pendingSubmit: true }); // 获取用户ID const userId = wx.getStorageSync('wxuserid'); if (!userId) { wx.showToast({ title: '请先登录', icon: 'none' }); this.setData({ pendingSubmit: false }); return; } // 计算开始时间和结束时间(Unix时间戳,秒) const startTimeDate = new Date(this.data.startTime); const endTimeDate = new Date(); const startTimeSeconds = Math.floor(startTimeDate.getTime() / 1000); // 开始答题的时间戳(秒) const endTimeSeconds = Math.floor(endTimeDate.getTime() / 1000); // 结束答题的时间戳(秒) // 格式化答案数组 const answers = this.data.questions.map((question, index) => { const answerArray = this.data.answers[index] || []; const userAnswer = answerArray.reduce((acc, curr, idx) => { if (curr) { acc.push(this.data.optionMarkers[idx]); } return acc; }, []).join(','); return { question_id: String(question.id), user_answer: userAnswer || '' }; }); const submitData = { user_id: String(userId), paper_id: String(this.data.paperId), task_id: String(this.data.taskId || ''), answers: answers, start_time: startTimeSeconds, end_time: endTimeSeconds }; if (!submitData.paper_id) { wx.showToast({ title: '参数错误', icon: 'none' }); this.setData({ pendingSubmit: false }); return; } // 先提交答题记录 questionApi.createAnswerRecord(submitData) .then(res => { if (res.code === 200 || res.success === true) { // 客观题完成只看正确率(正确数/试卷总题数),由后端判断是否达标并保留最高正确率 const totalQuestions = (this.data.questions && this.data.questions.length) || 0; const correctCount = (res.correct_count != null) ? Number(res.correct_count) : 0; const progressData = { user_id: String(userId), task_id: String(this.data.taskId), is_completed: true, completed_at: String(endTimeSeconds), objective_correct_count: correctCount, objective_total_count: totalQuestions }; return campApi.updateCampProgress(progressData) .then(progressRes => { wx.showToast({ title: '提交成功', icon: 'success' }); console.log('提交成功', this.data); // 跳转到结果页面 setTimeout(() => { const query = []; query.push('paper_id=' + encodeURIComponent(this.data.paperId)); query.push('task_id=' + encodeURIComponent(this.data.taskId)); query.push('camp_id=' + encodeURIComponent(this.data.campId || '')); query.push('task_title=' + encodeURIComponent(this.data.taskTitle || '客观题')); if (this.data.courseId) { query.push('course_id=' + encodeURIComponent(this.data.courseId)); } wx.redirectTo({ url: '/pages/camp_task_objective_questions_result/index?' + query.join('&') }); }, 1500); this.setData({ pendingSubmit: false }); }) .catch(progressErr => { // 答题记录已提交,但进度更新失败,仍然提示成功 wx.showToast({ title: '提交成功', icon: 'success' }); console.warn('答题记录提交成功,但进度更新失败:', progressErr); // 跳转到结果页面 setTimeout(() => { const query = []; query.push('paper_id=' + encodeURIComponent(this.data.paperId)); query.push('task_id=' + encodeURIComponent(this.data.taskId)); query.push('camp_id=' + encodeURIComponent(this.data.campId || '')); query.push('task_title=' + encodeURIComponent(this.data.taskTitle || '客观题')); if (this.data.courseId) { query.push('course_id=' + encodeURIComponent(this.data.courseId)); } wx.redirectTo({ url: '/pages/camp_task_objective_questions_result/index?' + query.join('&') }); }, 1500); this.setData({ pendingSubmit: false }); }); } else { wx.showToast({ title: res.message || res.msg || '提交失败', icon: 'none' }); this.setData({ pendingSubmit: false }); } }) .catch(err => { wx.showToast({ title: '提交失败', icon: 'none' }); this.setData({ pendingSubmit: false }); console.error('提交答案失败:', err); }); }; // 保存 doSubmit 到 this,供自定义弹窗确认按钮调用 this.doSubmit = doSubmit; if (unanswered) { setTimeout(() => { this.setData({ showCustomModal: true }); }, 200); } else { doSubmit(); } }, // 自定义弹窗的确认 onCustomModalConfirm() { this.setData({ showCustomModal: false }); setTimeout(() => { if (typeof this.doSubmit === 'function') { this.doSubmit(); } }, 200); }, // 自定义弹窗的取消 onCustomModalCancel() { this.setData({ showCustomModal: false }); }, startResize(e) { const touch = e.touches[0]; this.startY = touch.clientY; this.startHeight = this.data.materialHeight; this.setData({ isResizing: true }); }, onResize(e) { if (!this.data.isResizing) return; const touch = e.touches[0]; const deltaY = touch.clientY - this.startY; let newHeight = this.startHeight + deltaY; newHeight = Math.max(this.data.minHeight, Math.min(newHeight, this.data.maxHeight)); this.setData({ materialHeight: newHeight }); }, endResize() { if (!this.data.isResizing) return; const presetHeights = [200, 300, 400, 500, 600, 700, 800, 900, 1000]; const currentHeight = this.data.materialHeight; const targetHeight = presetHeights.reduce((prev, curr) => Math.abs(curr - currentHeight) < Math.abs(prev - currentHeight) ? curr : prev ); this.setData({ materialHeight: targetHeight, isResizing: false }); }, onUnload() { timerService.stopTimer(this); }, });