useQuestionInjection.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import {computed, ref, watch} from 'vue'
  2. import {injectLocal, provideLocal} from "@vueuse/core";
  3. import {fnPlaceholder} from "@/utils/uni-helper";
  4. import _ from "lodash";
  5. import {array, empty, number} from "@/uni_modules/uv-ui-tools/libs/function/test";
  6. const key = Symbol('QUESTION_SERVICE')
  7. // 从外部提供一个存储答题和提交的触发机制
  8. // questionService依赖于paperService,主要用来分担一部分paperService的功能
  9. // 与答案和打分相关的一些API集中在了这个服务
  10. // 尽量保持只需要依靠paper/paper.questions中计算的相关API在paperService中
  11. // 与userData计算相关的API集中的questionService中
  12. export const useProvideQuestionService = function (
  13. paperService,
  14. chunkSize = 10,
  15. enableDuration = true,
  16. overrideService = {}) {
  17. const {
  18. allowAnswer,
  19. allowScore,
  20. index,
  21. questions,
  22. questionsMap,
  23. isCheckbox,
  24. commitQuestion,
  25. scoreQuestion,
  26. isObjective,
  27. isAnswerCorrect,
  28. goToQuestion,
  29. scorePaper
  30. } = paperService
  31. const container = ref({})
  32. const committing = ref([])
  33. const answerProps = ref(['answer', 'attachments', 'duration', 'questionId'])
  34. const scoreProps = ref(['answer', 'attachments', 'score', 'questionId']) // 这个answer/attachments是显示用的,不会提交
  35. const errorCount = ref(0)
  36. const chunk = computed(() => (errorCount.value + 1) * chunkSize)
  37. const timestampMap = new Map() // 另一个结构体存放时间,不污染container中的userData
  38. const errorLimit = 3 // 如果提交API连续超过3次失败,则不用再重试了
  39. const createUserDataFn = computed(() => {
  40. if (allowAnswer.value && allowScore.value)
  41. throw new Error('allowAnswer,allowScore can not be `true` at the same time!')
  42. if (!allowAnswer.value && !allowScore.value) return (q) => q // 原样返回,因为不会涉及到更改
  43. if (allowAnswer.value) return (question) => _.pick(question, answerProps.value)
  44. if (allowScore.value) return (question) => _.pick(question, scoreProps.value)
  45. })
  46. const getUserData = function (q) {
  47. let userData = container.value[q.questionId]
  48. if (userData) return userData
  49. userData = createUserDataFn.value(q)
  50. encodeToUserData(q, userData)
  51. container.value[q.questionId] = userData
  52. return userData
  53. }
  54. const cleanUserData = function () {
  55. container.value = {}
  56. committing.value = []
  57. timestampMap.clear()
  58. }
  59. const isAnswered = function (q) {
  60. const data = getUserData(q)
  61. return !empty(data.answer) || !empty(data.attachments)
  62. }
  63. const isScored = function (q) {
  64. const data = getUserData(q)
  65. return number(data.score)
  66. }
  67. const answeredPartition = computed(() => _.partition(questions.value, q => isAnswered(q)))
  68. const answeredQuestions = computed(() => answeredPartition.value[0])
  69. const unansweredQuestions = computed(() => answeredPartition.value[1])
  70. const isAllAnswered = computed(() => questions.value.length == answeredQuestions.value.length)
  71. const scoredPartition = computed(() => _.partition(questions.value, q => isScored(q)))
  72. const scoredQuestions = computed(() => scoredPartition.value[0])
  73. const unscoredQuestions = computed(() => scoredPartition.value[1])
  74. const isAllScored = computed(() => questions.value.length == scoredQuestions.value.length)
  75. const passedPartition = computed(() => _.partition(scoredQuestions.value, q => isPassed(q)))
  76. const passedQuestions = computed(() => passedPartition.value[0])
  77. const unpassedQuestions = computed(() => passedPartition.value[1])
  78. const isCompleteLocal = (q) =>
  79. (allowAnswer.value && isAnswered(q)) ||
  80. (allowScore.value && isScored(q))
  81. const isAllLocalComplete = computed(() =>
  82. (allowAnswer.value && isAllAnswered.value) ||
  83. (allowScore.value && isAllScored.value)
  84. )
  85. const allUncompleteLocalQuestions = computed(() => {
  86. if (allowAnswer.value) return unansweredQuestions.value
  87. if (allowScore.value) return unscoredQuestions.value
  88. return []
  89. })
  90. const isPassed = (q) => {
  91. const userData = getUserData(q)
  92. if (!number(userData.score)) return false
  93. return userData.score >= q.scoreTotal * 0.8
  94. }
  95. const encodeToUserData = (q, userData) => {
  96. // 与decodeFromUserData对应,有些字段,前后台需要转义
  97. // 多选兼容视图类型,要注意这里,转换answer是因为组件不支持string类型的传型,提交时会适配后台
  98. if (isCheckbox(q)) userData.answer = empty(userData.answer) ? [] : q.answer.split(',')
  99. // attachments做兼容性转换
  100. userData.attachments = empty(userData.attachments)
  101. ? []
  102. : array(userData.attachments)
  103. ? userData.attachments
  104. : q.attachments.split(',')
  105. }
  106. const decodeFromUserData = (userData) => {
  107. // 与encodeToUserData对应,提交或反向赋值时要使用
  108. if (allowAnswer.value) {
  109. return {
  110. ...userData,
  111. answer: userData.answer?.toString(), // 兼容多选
  112. attachments: userData.attachments // 图片提交时不用转
  113. }
  114. }
  115. if (allowScore.value) {
  116. const copy = {...userData}
  117. delete copy.answer // 打分时不需要提交答案
  118. delete copy.attachments
  119. return copy
  120. }
  121. return userData
  122. }
  123. /*@description 本地提交答案,达到chunkSize时自动向服务端同步
  124. * */
  125. const pushChunk = function (userData) {
  126. // 结算duration
  127. if (allowAnswer.value && enableDuration) {
  128. const duration = userData.duration || 0
  129. const diffSeconds = (new Date().getTime() - timestampMap.get(userData.questionId)) / 1000
  130. userData.duration = Math.round(duration + diffSeconds)
  131. }
  132. // chunk 机制只关心分段提交行为,并不关心提交的数据值
  133. // 因为userData在container中是唯一引用,所以用户的答题或者打分可能会反复保存
  134. // 但只要保持最后一次操作后,有提交行为,即可保证userData均与服务端同步了
  135. if (committing.value.includes(userData)) return
  136. committing.value.push(userData)
  137. // chunk = 0 不执行分段提交,要等forceChunk调用
  138. if (chunk.value
  139. && errorCount.value <= errorLimit
  140. && committing.value.length >= chunk.value) {
  141. return doChunk()
  142. }
  143. }
  144. const rollbackChunk = function (commits) {
  145. // 不触发,只是回加,doChunk等一下次自然机会
  146. _.remove(committing.value, item => commits.includes(item))
  147. committing.value.push(...commits)
  148. }
  149. /*
  150. * @description 同步本地数据到服务端
  151. * */
  152. const doChunk = function () {
  153. if (empty(committing.value)) return
  154. // prepare commit data, 需要兼容多选,所以重组了对象,后面使用的时候要小心引用变更
  155. const rawCommits = [...committing.value]
  156. const commits = rawCommits.map(decodeFromUserData)
  157. committing.value.length = 0
  158. const commitFn = allowAnswer.value
  159. ? commitQuestion
  160. : allowScore.value
  161. ? scoreQuestion
  162. : fnPlaceholder
  163. return commitFn(commits)
  164. .then(res => {
  165. errorCount.value = 0
  166. // sync props to realtime
  167. commits.forEach(userData => {
  168. const raw = questionsMap.value[userData.questionId]
  169. _.assign(raw, userData)
  170. })
  171. })
  172. .catch(e => {
  173. console.log('commitQuestion failed', e, rawCommits)
  174. errorCount.value += 1 // 扩容延缓提交
  175. // rollback to committing
  176. rollbackChunk(rawCommits)
  177. throw e // 原样抛出,使得外部调用await doChunk时能正常中断
  178. })
  179. }
  180. const getAllCommits = () => questions.value.map(q => userDataToCommit(getUserData(q)))
  181. // hooks
  182. // 完成答题计时功能
  183. // NOTE:如果页面上要显示计时,只需要在当前题上,从userData.duration按秒计数即可。
  184. watch([index, questions], ([newIndex], [oldIndex]) => {
  185. if (enableDuration && allowAnswer.value) { // 只有答题开放此功能
  186. if (oldIndex != undefined) {
  187. // 旧题清空时间戳
  188. const old = questions.value[oldIndex]
  189. if (old) timestampMap.delete(old.questionId)
  190. }
  191. // 新进入的题重新打时间戳
  192. // 如果在这期间,用户有pushChunk行为,则会结算duration
  193. const q = questions.value[newIndex]
  194. timestampMap.set(q.questionId, new Date().getTime())
  195. }
  196. })
  197. // 完成客观题自动评分
  198. // 如果全是客观题,直接自动完成阅卷
  199. watch([allowScore, questions], async () => {
  200. if (allowScore.value) {
  201. const targets = questions.value
  202. .filter(q => isObjective(q) && !isScored(q))
  203. if (targets.length) {
  204. // 提交所有客观题
  205. const commits = targets.map(q => {
  206. const userData = getUserData(q)
  207. userData.score = isAnswerCorrect(q) ? q.scoreTotal * 1 : 0
  208. return userData
  209. })
  210. rollbackChunk(commits)
  211. await doChunk()
  212. }
  213. const nextTargets = unscoredQuestions.value
  214. if (nextTargets.length) {
  215. goToQuestion(_.first(nextTargets))
  216. } else if (isAllScored) {
  217. // 如果全是客观题,直接结束阅卷
  218. await scorePaper()
  219. index.value = 0 // 回到第1题
  220. }
  221. }
  222. })
  223. const options = {
  224. container,
  225. committing,
  226. createUserDataFn,
  227. cleanUserData,
  228. getUserData,
  229. getAllCommits,
  230. pushChunk,
  231. doChunk,
  232. isAnswered,
  233. isScored,
  234. isAllAnswered,
  235. isAllScored,
  236. isCompleteLocal,
  237. isAllLocalComplete,
  238. isPassed,
  239. answeredQuestions,
  240. unansweredQuestions,
  241. scoredQuestions,
  242. unscoredQuestions,
  243. passedQuestions,
  244. unpassedQuestions,
  245. allUncompleteLocalQuestions,
  246. encodeToUserData,
  247. decodeFromUserData,
  248. ...overrideService
  249. }
  250. provideLocal(key, options)
  251. return options
  252. }
  253. export const useInjectQuestionService = function () {
  254. return injectLocal(key)
  255. }