import {computed, ref, watch} from 'vue' import {injectLocal, provideLocal} from "@vueuse/core"; import {fnPlaceholder} from "@/utils/uni-helper"; import _ from "lodash"; import {array, empty, number} from "@/uni_modules/uv-ui-tools/libs/function/test"; const key = Symbol('QUESTION_SERVICE') // 从外部提供一个存储答题和提交的触发机制 // questionService依赖于paperService,主要用来分担一部分paperService的功能 // 与答案和打分相关的一些API集中在了这个服务 // 尽量保持只需要依靠paper/paper.questions中计算的相关API在paperService中 // 与userData计算相关的API集中的questionService中 export const useProvideQuestionService = function ( paperService, chunkSize = 10, enableDuration = true, overrideService = {}) { const { allowAnswer, allowScore, index, questions, questionsMap, isCheckbox, commitQuestion, scoreQuestion, isObjective, isAnswerCorrect, goToQuestion, scorePaper } = paperService const container = ref({}) const committing = ref([]) const answerProps = ref(['answer', 'attachments', 'duration', 'questionId']) const scoreProps = ref(['answer', 'attachments', 'score', 'questionId']) // 这个answer/attachments是显示用的,不会提交 const errorCount = ref(0) const chunk = computed(() => (errorCount.value + 1) * chunkSize) const timestampMap = new Map() // 另一个结构体存放时间,不污染container中的userData const errorLimit = 3 // 如果提交API连续超过3次失败,则不用再重试了 const createUserDataFn = computed(() => { if (allowAnswer.value && allowScore.value) throw new Error('allowAnswer,allowScore can not be `true` at the same time!') if (!allowAnswer.value && !allowScore.value) return (q) => q // 原样返回,因为不会涉及到更改 if (allowAnswer.value) return (question) => _.pick(question, answerProps.value) if (allowScore.value) return (question) => _.pick(question, scoreProps.value) }) const getUserData = function (q) { let userData = container.value[q.questionId] if (userData) return userData userData = createUserDataFn.value(q) encodeToUserData(q, userData) container.value[q.questionId] = userData return userData } const cleanUserData = function () { container.value = {} committing.value = [] timestampMap.clear() } const isAnswered = function (q) { const data = getUserData(q) return !empty(data.answer) || !empty(data.attachments) } const isScored = function (q) { const data = getUserData(q) return number(data.score) } const answeredPartition = computed(() => _.partition(questions.value, q => isAnswered(q))) const answeredQuestions = computed(() => answeredPartition.value[0]) const unansweredQuestions = computed(() => answeredPartition.value[1]) const isAllAnswered = computed(() => questions.value.length == answeredQuestions.value.length) const scoredPartition = computed(() => _.partition(questions.value, q => isScored(q))) const scoredQuestions = computed(() => scoredPartition.value[0]) const unscoredQuestions = computed(() => scoredPartition.value[1]) const isAllScored = computed(() => questions.value.length == scoredQuestions.value.length) const passedPartition = computed(() => _.partition(scoredQuestions.value, q => isPassed(q))) const passedQuestions = computed(() => passedPartition.value[0]) const unpassedQuestions = computed(() => passedPartition.value[1]) const isCompleteLocal = (q) => (allowAnswer.value && isAnswered(q)) || (allowScore.value && isScored(q)) const isAllLocalComplete = computed(() => (allowAnswer.value && isAllAnswered.value) || (allowScore.value && isAllScored.value) ) const allUncompleteLocalQuestions = computed(() => { if (allowAnswer.value) return unansweredQuestions.value if (allowScore.value) return unscoredQuestions.value return [] }) const isPassed = (q) => { const userData = getUserData(q) if (!number(userData.score)) return false return userData.score >= q.scoreTotal * 0.8 } const encodeToUserData = (q, userData) => { // 与decodeFromUserData对应,有些字段,前后台需要转义 // 多选兼容视图类型,要注意这里,转换answer是因为组件不支持string类型的传型,提交时会适配后台 if (isCheckbox(q)) userData.answer = empty(userData.answer) ? [] : q.answer.split(',') // attachments做兼容性转换 userData.attachments = empty(userData.attachments) ? [] : array(userData.attachments) ? userData.attachments : q.attachments.split(',') } const decodeFromUserData = (userData) => { // 与encodeToUserData对应,提交或反向赋值时要使用 if (allowAnswer.value) { return { ...userData, answer: userData.answer?.toString(), // 兼容多选 attachments: userData.attachments // 图片提交时不用转 } } if (allowScore.value) { const copy = {...userData} delete copy.answer // 打分时不需要提交答案 delete copy.attachments return copy } return userData } /*@description 本地提交答案,达到chunkSize时自动向服务端同步 * */ const pushChunk = function (userData) { // 结算duration if (allowAnswer.value && enableDuration) { const duration = userData.duration || 0 const diffSeconds = (new Date().getTime() - timestampMap.get(userData.questionId)) / 1000 userData.duration = Math.round(duration + diffSeconds) } // chunk 机制只关心分段提交行为,并不关心提交的数据值 // 因为userData在container中是唯一引用,所以用户的答题或者打分可能会反复保存 // 但只要保持最后一次操作后,有提交行为,即可保证userData均与服务端同步了 if (committing.value.includes(userData)) return committing.value.push(userData) // chunk = 0 不执行分段提交,要等forceChunk调用 if (chunk.value && errorCount.value <= errorLimit && committing.value.length >= chunk.value) { return doChunk() } } const rollbackChunk = function (commits) { // 不触发,只是回加,doChunk等一下次自然机会 _.remove(committing.value, item => commits.includes(item)) committing.value.push(...commits) } /* * @description 同步本地数据到服务端 * */ const doChunk = function () { if (empty(committing.value)) return // prepare commit data, 需要兼容多选,所以重组了对象,后面使用的时候要小心引用变更 const rawCommits = [...committing.value] const commits = rawCommits.map(decodeFromUserData) committing.value.length = 0 const commitFn = allowAnswer.value ? commitQuestion : allowScore.value ? scoreQuestion : fnPlaceholder return commitFn(commits) .then(res => { errorCount.value = 0 // sync props to realtime commits.forEach(userData => { const raw = questionsMap.value[userData.questionId] _.assign(raw, userData) }) }) .catch(e => { console.log('commitQuestion failed', e, rawCommits) errorCount.value += 1 // 扩容延缓提交 // rollback to committing rollbackChunk(rawCommits) throw e // 原样抛出,使得外部调用await doChunk时能正常中断 }) } const getAllCommits = () => questions.value.map(q => userDataToCommit(getUserData(q))) // hooks // 完成答题计时功能 // NOTE:如果页面上要显示计时,只需要在当前题上,从userData.duration按秒计数即可。 watch([index, questions], ([newIndex], [oldIndex]) => { if (enableDuration && allowAnswer.value) { // 只有答题开放此功能 if (oldIndex != undefined) { // 旧题清空时间戳 const old = questions.value[oldIndex] if (old) timestampMap.delete(old.questionId) } // 新进入的题重新打时间戳 // 如果在这期间,用户有pushChunk行为,则会结算duration const q = questions.value[newIndex] timestampMap.set(q.questionId, new Date().getTime()) } }) // 完成客观题自动评分 // 如果全是客观题,直接自动完成阅卷 watch([allowScore, questions], async () => { if (allowScore.value) { const targets = questions.value .filter(q => isObjective(q) && !isScored(q)) if (targets.length) { // 提交所有客观题 const commits = targets.map(q => { const userData = getUserData(q) userData.score = isAnswerCorrect(q) ? q.scoreTotal * 1 : 0 return userData }) rollbackChunk(commits) await doChunk() } const nextTargets = unscoredQuestions.value if (nextTargets.length) { goToQuestion(_.first(nextTargets)) } else if (isAllScored) { // 如果全是客观题,直接结束阅卷 await scorePaper() index.value = 0 // 回到第1题 } } }) const options = { container, committing, createUserDataFn, cleanUserData, getUserData, getAllCommits, pushChunk, doChunk, isAnswered, isScored, isAllAnswered, isAllScored, isCompleteLocal, isAllLocalComplete, isPassed, answeredQuestions, unansweredQuestions, scoredQuestions, unscoredQuestions, passedQuestions, unpassedQuestions, allUncompleteLocalQuestions, encodeToUserData, decodeFromUserData, ...overrideService } provideLocal(key, options) return options } export const useInjectQuestionService = function () { return injectLocal(key) }