import { EnumQuestionType, EnumReviewMode } from "@/common/enum"; import { getPaper } from '@/api/modules/study'; import { Study } from "@/types"; import { Question } from "@/types/study"; /** * @description 解码 HTML 实体 * 由于 uv-parse 的 decodeEntity 只支持有限的实体,需要手动解码音标等特殊实体 * 使用手动映射表,兼容所有 uni-app 平台(包括小程序) */ export const decodeHtmlEntities = (str: string): string => { if (!str) return str; // 音标和常用 HTML 实体映射表 const entityMap: Record = { // 音标相关 - 锐音符 (acute) 'aacute': 'á', 'eacute': 'é', 'iacute': 'í', 'oacute': 'ó', 'uacute': 'ú', // 音标相关 - 重音符 (grave) 'agrave': 'à', 'egrave': 'è', 'igrave': 'ì', 'ograve': 'ò', 'ugrave': 'ù', // 音标相关 - 扬抑符 (circumflex) 'acirc': 'â', 'ecirc': 'ê', 'icirc': 'î', 'ocirc': 'ô', 'ucirc': 'û', // 音标相关 - 分音符 (umlaut/diaeresis) 'auml': 'ä', 'euml': 'ë', 'iuml': 'ï', 'ouml': 'ö', 'uuml': 'ü', // 音标相关 - 波浪符 (tilde) 'ntilde': 'ñ', 'atilde': 'ã', 'otilde': 'õ', // 其他常用实体 'amp': '&', 'lt': '<', 'gt': '>', 'quot': '"', 'apos': "'", 'nbsp': '\u00A0', 'copy': '©', 'reg': '®', 'trade': '™', 'mdash': '—', 'ndash': '–', 'hellip': '…', // 数学符号 'times': '×', 'divide': '÷', 'plusmn': '±', }; // 处理命名实体(如 í) // 使用 [a-z0-9] 以支持包含数字的实体名称 let result = str.replace(/&([a-z0-9]+);/gi, (match, entity) => { const lowerEntity = entity.toLowerCase(); if (entityMap[lowerEntity]) { return entityMap[lowerEntity]; } return match; // 如果找不到映射,保持原样 }); // 处理数字实体(如 í 或 í) result = result.replace(/&#(\d+);/g, (match, num) => { return String.fromCharCode(parseInt(num, 10)); }); result = result.replace(/&#x([0-9a-f]+);/gi, (match, hex) => { return String.fromCharCode(parseInt(hex, 16)); }); return result; } export const useExam = () => { const questionTypeDesc: Record = { [EnumQuestionType.SINGLE_CHOICE]: '单选题', [EnumQuestionType.MULTIPLE_CHOICE]: '多选题', [EnumQuestionType.JUDGMENT]: '判断题', [EnumQuestionType.FILL_IN_THE_BLANK]: '填空题', [EnumQuestionType.SUBJECTIVE]: '主观题', [EnumQuestionType.SHORT_ANSWER]: '简答题', [EnumQuestionType.ESSAY]: '问答题', [EnumQuestionType.ANALYSIS]: '分析题', [EnumQuestionType.OTHER]: '阅读题' } // 题型顺序 const questionTypeOrder = [ EnumQuestionType.SINGLE_CHOICE, EnumQuestionType.MULTIPLE_CHOICE, EnumQuestionType.JUDGMENT, EnumQuestionType.FILL_IN_THE_BLANK, EnumQuestionType.SUBJECTIVE, EnumQuestionType.SHORT_ANSWER, EnumQuestionType.ESSAY, EnumQuestionType.ANALYSIS, EnumQuestionType.OTHER ]; let interval: NodeJS.Timeout | null = null; const countDownCallback = ref<() => void>(() => { }); // 练习时长 const practiceDuration = ref(0); const formatPracticeDuration = computed(() => { const hours = Math.floor(practiceDuration.value / 3600); const minutes = Math.floor((practiceDuration.value % 3600) / 60); const seconds = practiceDuration.value % 60; if (hours > 0) { return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } else { return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } }); // 考试时长 const examDuration = ref(0); const formatExamDuration = computed(() => { const hours = Math.floor(examDuration.value / 3600); const minutes = Math.floor((examDuration.value % 3600) / 60); const seconds = examDuration.value % 60; return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; }); const swiperDuration = ref(300); const questionList = ref([]); // 练习设置 const practiceSettings = ref({ reviewMode: EnumReviewMode.AFTER_SUBMIT, autoNext: false }); // 收藏列表 const favoriteList = ref([]); // 不会列表 const notKnowList = ref([]); // 重点标记列表 const markList = ref([]); /// 虚拟题目索引,包含子题 const virtualCurrentIndex = ref(0); /// 虚拟总题量,包含子题 const virtualTotalCount = computed(() => { return questionList.value.reduce((acc, item) => { if (item.subQuestions && item.subQuestions.length > 0) { return acc + item.subQuestions.length; } return acc + 1; }, 0); }); // 包含状态的问题列表 const stateQuestionList = computed(() => { const parseQuestion = (qs: Study.Question) => { if (qs.subQuestions && qs.subQuestions.length > 0) { qs.isLeaf = false; qs.subQuestions.forEach((subQs, index) => { subQs.isDone = isDone(subQs); subQs.isCorrect = isQuestionCorrect(subQs); subQs.isNotAnswer = isQuestionNotAnswer(subQs); subQs.isLeaf = true; subQs.options.forEach(option => { option.isCorrect = isOptionCorrect(subQs, option); option.isSelected = isOptionSelected(subQs, option); option.isMissed = !option.isSelected && option.isCorrect; option.isIncorrect = !option.isCorrect && option.isSelected; }); }); } else { qs.isSubQuestion = false; qs.isDone = isDone(qs); qs.isCorrect = isQuestionCorrect(qs); qs.isNotAnswer = isQuestionNotAnswer(qs); qs.isLeaf = true; qs.options.forEach(option => { option.isCorrect = isOptionCorrect(qs, option); option.isSelected = isOptionSelected(qs, option); option.isMissed = !option.isSelected && option.isCorrect; option.isIncorrect = !option.isCorrect && option.isSelected; }); } return qs; } return questionList.value.map((item, index) => { return parseQuestion(item) }); }); /// 扁平化题目列表,用于答题卡 const flatQuestionList = computed(() => { return stateQuestionList.value.flatMap(item => { if (item.subQuestions && item.subQuestions.length > 0) { return item.subQuestions.flat(); } return item; }); }); /// 按照题型分组,用于答题卡 const groupedQuestionList = computed(() => { const state = questionTypeOrder.map(type => { return { type, list: [] as { question: Study.Question; index: number }[] } }); flatQuestionList.value.forEach((qs, index) => { let group; if (qs.isSubQuestion) { group = state.find(item => item.type === qs.parentTypeId); } else { group = state.find(item => item.type === qs.typeId); } if (group) { group.list.push({ question: qs, index }); } else { state.push({ type: qs.typeId, list: [{ question: qs, index }] }); } }); return state; }); /// 是否全部做完 const isAllDone = computed(() => { return doneCount.value === virtualTotalCount.value; }); // 当前下标 const currentIndex = ref(0); // 子题下标 const subQuestionIndex = ref(0); // 当前题目 const currentQuestion = computed(() => { return questionList.value[currentIndex.value]; }); // 当前子题 const currentSubQuestion = computed(() => { if (currentQuestion.value.subQuestions.length === 0) { return null; } if (subQuestionIndex.value >= currentQuestion.value.subQuestions.length) { return null; } return currentQuestion.value.subQuestions[subQuestionIndex.value]; }); /// 总题量,不区分子题,等同于接口返回的题目列表 const totalCount = computed(() => { return questionList.value.length; }); /// 已做题的数量 --> stateQuestionList const doneCount = computed(() => { // 有答案的或者不会做的,都认为是做了 let count = 0; for (let i = 0; i <= stateQuestionList.value.length - 1; i++) { const qs = stateQuestionList.value[i]; if (qs.subQuestions && qs.subQuestions.length > 0) { qs.subQuestions.forEach(subQs => { if (subQs.isDone) { count++; } }); } else { if (qs.isDone) { count++; } } } return count; }); /// 未做题的数量 const notDoneCount = computed(() => { return virtualTotalCount.value - doneCount.value; }); /// 包含子题的题目计算整体做题进度 const calcProgress = (qs: Study.Question): number => { if (qs.subQuestions && qs.subQuestions.length > 0) { return qs.subQuestions.reduce((acc, q) => acc + calcProgress(q), 0) / qs.subQuestions.length; } return qs.isDone ? 100 : 0; } /// 题目是否做完 const isDone = (qs: Study.Question): boolean => { if (qs.subQuestions && qs.subQuestions.length > 0) { return qs.subQuestions.every(q => isDone(q)); } return (qs.answers && qs.answers.filter(item => !!item).length > 0) || !!qs.isNotKnow; } /// 题目是否正确 const isQuestionCorrect = (qs: Study.Question): boolean => { let { answers, answer1, answer2, typeId } = qs; answers = answers?.filter(item => !!item) || []; answer1 = answer1 || ''; answer2 = answer2 || ''; if ([EnumQuestionType.SINGLE_CHOICE, EnumQuestionType.JUDGMENT].includes(typeId)) { return answer1.includes(answers[0]); } else if ([EnumQuestionType.MULTIPLE_CHOICE].includes(typeId)) { return answers.length === answer1.length && answers.every(item => answer1.includes(item)); } else { // 主观题 A 对 B 错 return answers.includes('A') && !answers.includes('B'); } }; /// 题目是否未作答 const isQuestionNotAnswer = (qs: Study.Question): boolean => { if (qs.subQuestions && qs.subQuestions.length > 0) { return qs.subQuestions.every(q => isQuestionNotAnswer(q)); } return !qs.answers || qs.answers.filter(item => !!item).length === 0; } /// 选项是否正确 const isOptionCorrect = (question: Study.Question, option: Study.QuestionOption) => { const { answers, answer1, typeId } = question; if ([EnumQuestionType.SINGLE_CHOICE, EnumQuestionType.JUDGMENT].includes(typeId)) { return answer1?.includes(option.no); } else if ([EnumQuestionType.MULTIPLE_CHOICE].includes(typeId)) { return answer1?.includes(option.no); } else { return answers?.includes(option.no) && option.no === 'A'; } } /// 选项是否选中 const isOptionSelected = (question: Study.Question, option: Study.QuestionOption) => { return question.answers.includes(option.no); } // 是否可以切换上一题 const prevEnable = computed(() => { return virtualCurrentIndex.value > 0; }); // 是否可以切换下一题 const nextEnable = computed(() => { if (currentQuestion.value) { if (currentQuestion.value.isSubQuestion) { console.log(5, subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1) return subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1; } else { if (currentQuestion.value.subQuestions && currentQuestion.value.subQuestions.length > 0) { console.log(subQuestionIndex.value, currentQuestion.value.subQuestions.length - 1) // 子题可以切换 return subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1; } else { // 大题可以切换 return currentIndex.value < questionList.value.length - 1; } } } console.log(7, currentQuestion.value) return false; }); // 下一题 const nextQuestion = () => { if (!nextEnable.value) { return; } // if (currentIndex.value < questionList.value.length - 1) { // currentIndex.value++; // setTimeout(() => { // subQuestionIndex.value = 0; // }, 300); // } if (currentQuestion.value.subQuestions && currentQuestion.value.subQuestions.length > 0) { if (subQuestionIndex.value < currentQuestion.value.subQuestions.length - 1) { subQuestionIndex.value++; } else { currentIndex.value++; subQuestionIndex.value = 0; } } else { currentIndex.value++; } } // 上一题 const prevQuestion = () => { if (!prevEnable.value) { return; } if (currentIndex.value > 0) { // currentIndex.value--; // setTimeout(() => { // subQuestionIndex.value = 0; // }, 300); } if (currentQuestion.value.subQuestions && currentQuestion.value.subQuestions.length > 0) { if (subQuestionIndex.value > 0) { subQuestionIndex.value--; } else { currentIndex.value--; } } else { if (currentIndex.value > 0) { currentIndex.value--; // 如果上一个题是子题,那么,默认选中最后一个子题 const prevQuestion = questionList.value[currentIndex.value - 1]; if (prevQuestion && prevQuestion.subQuestions && prevQuestion.subQuestions.length > 0) { subQuestionIndex.value = prevQuestion.subQuestions.length - 1; } } } } // 快速下一题 const nextQuestionQuickly = () => { if (!nextEnable) { return; } swiperDuration.value = 0; setTimeout(() => { nextQuestion(); setTimeout(() => { swiperDuration.value = 300; }, 0); }, 0); } // 快速上一题 const prevQuestionQuickly = () => { if (!prevEnable.value) { return; } swiperDuration.value = 0; setTimeout(() => { prevQuestion(); setTimeout(() => { swiperDuration.value = 300; }, 0); }, 0); } // 通过下标切换题目 const changeIndex = (index: number, subIndex?: number) => { swiperDuration.value = 0; setTimeout(() => { currentIndex.value = index; if (!subIndex !== undefined) { subQuestionIndex.value = subIndex || 0; } setTimeout(() => { swiperDuration.value = 300; }, 0); }, 0); } const changeSubIndex = (index: number) => { subQuestionIndex.value = index; } // 开始计时 const startTiming = () => { interval = setInterval(() => { practiceDuration.value += 1; }, 1000); } // 停止计时 const stopTiming = () => { interval && clearInterval(interval); interval = null; } // 开始倒计时 const startCountdown = () => { interval = setInterval(() => { if (examDuration.value <= 0) { console.log('停止倒计时') stopExamDuration(); return; } examDuration.value -= 1; }, 1000); } // 停止倒计时 const stopExamDuration = () => { interval && clearInterval(interval); interval = null; countDownCallback.value && countDownCallback.value(); } const setExamDuration = (duration: number) => { examDuration.value = duration; } const setPracticeDuration = (duration: number) => { practiceDuration.value = duration; } const setCountDownCallback = (callback: () => void) => { countDownCallback.value = callback; } const setDuration = (duration: number) => { examDuration.value = duration; practiceDuration.value = duration; } /// 整理题目结构 const transerQuestions = (arr: Study.Question[]) => { let offset = 0; return arr.map((item: Study.Question, index: number) => { const result = { ...item, index: index }; // 如果有子节点,处理子节点并计算subIndex if (item.subQuestions && Array.isArray(item.subQuestions) && item.subQuestions.length > 0) { // 为当前节点设置offset result.offset = offset; result.subQuestions = item.subQuestions.map((child, childIndex) => ({ ...child, subIndex: childIndex, isSubQuestion: true, parentId: item.id, parentTypeId: item.typeId, parentIndex: index, index: index, virtualIndex: index + result.offset + childIndex })); // 更新offset,累加当前节点的子节点数量 offset += (item.subQuestions.length - 1); } else { // 如果没有子节点,设置offset为当前累计值 result.offset = offset; result.isSubQuestion = false; result.virtualIndex = result.index + offset; } return result; }); } // 将ExamineeQuestion转为Question const setQuestionList = (list: Study.ExamineeQuestion[]) => { const orders = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; // 数据预处理 // 1、给每个项目补充额外字段 const transerQuestion = (item: Study.ExamineeQuestion, index: number): Study.Question => { return { ...item, // 处理没有题型的大题,统一作为阅读题 typeId: (item.typeId === null || item.typeId === undefined) ? EnumQuestionType.OTHER : item.typeId, answers: item.answers || [], subQuestions: item.subQuestions?.map(transerQuestion) || [], options: item.options?.map((option, index) => { // 移除选项编号(如 A.)并解码 HTML 实体(如 í → í) const cleanedOption = option.replace(/[A-Z]\./g, '').replace(/\s/g, ' '); return { name: decodeHtmlEntities(cleanedOption), no: orders[index], id: index, isAnswer: false, isCorrect: false, isSelected: false } as Study.QuestionOption }) || [], totalScore: item.totalScore || 0, offset: 0, index: index, virtualIndex: 0 } as Study.Question } questionList.value = transerQuestions(list.map((item, index) => transerQuestion(item, index))); } /// 重置题目状态 const reset = () => { questionList.value = questionList.value.map(item => { return { ...item, answers: [], isMark: false, isNotKnow: false, hasParsed: false, showParse: false, subQuestions: item.subQuestions.map(subItem => { return { ...subItem, answers: [], isMark: false, isNotKnow: false, hasParsed: false, showParse: false } }), options: item.options.map(option => { return { ...option, isAnswer: false, isCorrect: false, isSelected: false, isMissed: false, isIncorrect: false } }) } }); console.log(questionList.value) changeIndex(0); practiceDuration.value = 0; examDuration.value = 0; interval && clearInterval(interval); interval = null; } /// 设置子题下标 const setSubQuestionIndex = (index: number) => { subQuestionIndex.value = index; } // 切换阅卷模式 const setPracticeSettings = (settings: Study.PracticeSettings) => { practiceSettings.value = settings; } // watch(() => currentIndex.value, (val) => { // subQuestionIndex.value = 0; // }, { // immediate: false // }); watch([() => currentIndex.value, () => subQuestionIndex.value], (val) => { console.log('currentIndex.value', currentIndex.value) console.log('subQuestionIndex.value', subQuestionIndex.value) const qs = questionList.value[val[0]]; virtualCurrentIndex.value = qs.index + qs.offset + val[1]; }, { immediate: false }); return { practiceSettings, questionList, groupedQuestionList, stateQuestionList, flatQuestionList, favoriteList, notKnowList, markList, currentIndex, isAllDone, totalCount, virtualCurrentIndex, virtualTotalCount, subQuestionIndex, setSubQuestionIndex, doneCount, notDoneCount, questionTypeDesc, currentSubQuestion, prevEnable, nextEnable, nextQuestion, prevQuestion, nextQuestionQuickly, prevQuestionQuickly, swiperDuration, practiceDuration, examDuration, formatExamDuration, formatPracticeDuration, startTiming, stopTiming, setDuration, startCountdown, stopExamDuration, setExamDuration, setPracticeDuration, setCountDownCallback, setQuestionList, changeIndex, reset, isQuestionCorrect, isOptionCorrect, setPracticeSettings, } }