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);
},
});