123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- 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)
- }
|