ExamPaperPreview.vue 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. <script setup lang="ts">
  2. import {
  3. examQuestionDataApi,
  4. examRecordDataApi,
  5. examRecordPaperStructApi,
  6. examRecordQuestionsApi,
  7. onlinePracticeTypeApi,
  8. } from "@/api/onlinePractice";
  9. import {
  10. ExamQuestion,
  11. PaperStruct,
  12. QuestionWrapperItem,
  13. } from "@/types/student-client";
  14. import { toChineseNumber } from "@/utils/utils";
  15. import { onMounted } from "vue";
  16. import { Close, Checkmark } from "@vicons/ionicons5";
  17. const props = defineProps<{
  18. examId: string | number;
  19. examRecordDataId: string | number;
  20. fromCache: boolean;
  21. courseCode: string;
  22. }>();
  23. const emit = defineEmits<{
  24. (e: "ready"): void;
  25. }>();
  26. let questionGroupList = $ref<PaperStruct["defaultPaper"]["questionGroupList"]>(
  27. []
  28. );
  29. let practiceType = $ref("");
  30. async function initData() {
  31. const ptRes = await onlinePracticeTypeApi(props.examId);
  32. practiceType = ptRes.data.PRACTICE_TYPE;
  33. const { paperStruct, examQuestionList, examRecordData } =
  34. await getPaperData();
  35. if (!paperStruct || !examQuestionList) {
  36. $message.error("获取试卷信息失败", {
  37. duration: 15,
  38. closable: true,
  39. });
  40. return;
  41. }
  42. const examQuestionMap = await getQuestionWrapperMapData(
  43. examQuestionList,
  44. examRecordData.examRecord.paperType
  45. );
  46. questionGroupList = paperStruct.defaultPaper.questionGroupList.map(
  47. (questionGroup) => {
  48. questionGroup.questionWrapperList = questionGroup.questionWrapperList.map(
  49. (question) => {
  50. return examQuestionMap[question.questionId];
  51. }
  52. );
  53. return questionGroup;
  54. }
  55. );
  56. }
  57. async function getPaperData() {
  58. type FetchDataFuncType = [
  59. Promise<{ data: PaperStruct }>,
  60. Promise<{
  61. data: { examQuestionEntities: ExamQuestion[] };
  62. }>,
  63. Promise<{
  64. data: { examRecord: { paperType: string } };
  65. }>
  66. ];
  67. const fetchDataFunc: FetchDataFuncType = [
  68. examRecordPaperStructApi(props.examRecordDataId, props.fromCache),
  69. examRecordQuestionsApi(props.examRecordDataId, props.fromCache),
  70. examRecordDataApi(props.examRecordDataId, props.fromCache),
  71. ];
  72. const [paperStructRes, examQuestionListRes, examRecordDataRes] =
  73. await Promise.all(fetchDataFunc);
  74. const [paperStruct, examQuestionList, examRecordData] = [
  75. paperStructRes.data,
  76. examQuestionListRes.data.examQuestionEntities,
  77. examRecordDataRes.data,
  78. ];
  79. return {
  80. paperStruct,
  81. examQuestionList,
  82. examRecordData,
  83. };
  84. }
  85. async function getQuestionWrapperMapData(
  86. examQuestionList: ExamQuestion[],
  87. groupCode: string
  88. ) {
  89. let examQuestionMap: Record<string, ExamQuestion[]> = {};
  90. examQuestionList.forEach((q) => {
  91. if (!examQuestionMap[q.questionId]) examQuestionMap[q.questionId] = [];
  92. examQuestionMap[q.questionId].push(q);
  93. });
  94. const fetchFunc = Object.keys(examQuestionMap).map((questionId) =>
  95. examQuestionDataApi({
  96. questionId,
  97. examId: props.examId,
  98. courseCode: props.courseCode,
  99. groupCode,
  100. })
  101. );
  102. const questionList = await Promise.all(fetchFunc);
  103. let examQuestionWrapperMap: Record<string, QuestionWrapperItem> = {};
  104. questionList.forEach((qItem) => {
  105. const q = qItem.data;
  106. const questionList = examQuestionMap[q.id].map((questionInfo, index) => {
  107. return Object.assign(
  108. {},
  109. questionInfo,
  110. q.masterVersion.questionUnitList[index]
  111. ) as ExamQuestion;
  112. });
  113. examQuestionWrapperMap[q.id] = Object.assign(
  114. { limitedPlayTimes: 0, questionUnitWrapperList: [] },
  115. { examQuestionList: questionList, questionId: q.id },
  116. q.masterVersion
  117. );
  118. });
  119. return examQuestionWrapperMap;
  120. }
  121. function restoreAudio(str: string) {
  122. return (str || "")
  123. .replace(/<a/g, "<audio controls ")
  124. .replace(/url=/g, "src=")
  125. .replace(/a>/g, "audio>");
  126. }
  127. const optionName = "ABCDEFGHIJ".split("");
  128. function indexToABCD(index: number) {
  129. return optionName[index];
  130. }
  131. function parseRightAnswer(
  132. questionType: string,
  133. rightAnswer: string[] | undefined,
  134. optionPermutation: number[] | undefined
  135. ) {
  136. if (!rightAnswer) return "";
  137. // 选择题答案是非乱序的真实答案,展示时要转成乱序的题目答案。
  138. if (["SINGLE_CHOICE", "MULTIPLE_CHOICE"].includes(questionType)) {
  139. const permutationOptions = optionPermutation as number[];
  140. const permutationAnswer = rightAnswer
  141. .map((answer) => permutationOptions.indexOf(Number(answer)))
  142. .sort();
  143. return permutationAnswer.map((ans) => indexToABCD(ans)).join("");
  144. } else if (["TRUE_OR_FALSE"].includes(questionType)) {
  145. return { true: "正确", false: "错误" }[rightAnswer.join("")];
  146. } else {
  147. return rightAnswer.join("");
  148. }
  149. }
  150. function parseStudentAnswer(
  151. questionType: string,
  152. studentAnswer: string | null,
  153. optionPermutation: number[] | undefined
  154. ) {
  155. if (!studentAnswer) return "";
  156. // 选择题答案是非乱序的真实答案,展示时要转成乱序的题目答案。
  157. if (["SINGLE_CHOICE", "MULTIPLE_CHOICE"].includes(questionType)) {
  158. const permutationOptions = optionPermutation as number[];
  159. const permutationAnswer = studentAnswer
  160. .split("")
  161. .map((answer) => permutationOptions.indexOf(Number(answer)))
  162. .sort();
  163. return permutationAnswer.map((ans) => indexToABCD(ans)).join("");
  164. } else if (["TRUE_OR_FALSE"].includes(questionType)) {
  165. return { true: "正确", false: "错误" }[studentAnswer];
  166. } else {
  167. return studentAnswer;
  168. }
  169. }
  170. function equalAnswer(
  171. questionType: string,
  172. studentAnswer: string | null,
  173. rightAnswer: string[] | undefined
  174. ) {
  175. if (!rightAnswer) return null;
  176. if (["FILL_UP", "ESSAY"].includes(questionType)) return null;
  177. return studentAnswer === rightAnswer.join("");
  178. }
  179. function checkIsObjective(questionType: string) {
  180. return ["SINGLE_CHOICE", "MULTIPLE_CHOICE", "TRUE_OR_FALSE"].includes(
  181. questionType
  182. );
  183. }
  184. onMounted(async () => {
  185. await initData().catch(() => false);
  186. emit("ready");
  187. });
  188. </script>
  189. <template>
  190. <div class="exam-paper">
  191. <div
  192. v-for="(group, gindex) in questionGroupList"
  193. :key="gindex"
  194. class="question-group"
  195. >
  196. <h3 class="group-title">
  197. {{ toChineseNumber(gindex + 1) }}、{{ group.groupName }}({{
  198. group.groupScore
  199. }}分)
  200. </h3>
  201. <div class="group-questions">
  202. <div
  203. v-for="questionWrapper in group.questionWrapperList"
  204. :key="questionWrapper.questionId"
  205. class="question-wrapper"
  206. >
  207. <div
  208. v-if="questionWrapper.body"
  209. class="question-wrapper-body"
  210. v-html="restoreAudio(questionWrapper.body)"
  211. ></div>
  212. <div
  213. v-for="question in questionWrapper.examQuestionList"
  214. :key="question.questionId"
  215. class="question-item"
  216. >
  217. <div class="question-item-title line-text">
  218. <span>{{ question.order }}、</span>
  219. <div
  220. class="question-item-title-content"
  221. v-html="restoreAudio(question.body)"
  222. ></div>
  223. </div>
  224. <!-- options -->
  225. <div
  226. v-if="
  227. question.questionOptionList &&
  228. question.questionOptionList.length
  229. "
  230. class="question-item-options"
  231. >
  232. <div
  233. v-for="(optionOrder, oindex) in question.optionPermutation"
  234. :key="optionOrder"
  235. class="question-item-option line-text"
  236. >
  237. <span>{{ indexToABCD(oindex) }}、</span>
  238. <div
  239. class="question-item-option-content"
  240. v-html="
  241. restoreAudio(question.questionOptionList[optionOrder].body)
  242. "
  243. ></div>
  244. </div>
  245. </div>
  246. <!-- right answer -->
  247. <div
  248. v-if="practiceType !== 'NO_ANSWER'"
  249. class="question-item-answer line-text"
  250. >
  251. <span>正确答案:</span>
  252. <div
  253. class="question-item-answer-content"
  254. v-html="
  255. parseRightAnswer(
  256. question.questionType,
  257. question.rightAnswer,
  258. question.optionPermutation
  259. )
  260. "
  261. ></div>
  262. </div>
  263. <!-- student answer -->
  264. <div class="question-item-answer line-text">
  265. <span>学生答案:</span>
  266. <div
  267. class="question-item-answer-content"
  268. v-html="
  269. parseStudentAnswer(
  270. question.questionType,
  271. question.studentAnswer,
  272. question.optionPermutation
  273. )
  274. "
  275. ></div>
  276. <div
  277. v-if="checkIsObjective(question.questionType)"
  278. class="question-item-answer-result"
  279. >
  280. <n-icon
  281. v-if="
  282. equalAnswer(
  283. question.questionType,
  284. question.studentAnswer,
  285. question.rightAnswer
  286. )
  287. "
  288. :component="Checkmark"
  289. color="#13bb8a"
  290. :size="16"
  291. ></n-icon>
  292. <n-icon
  293. v-else
  294. :component="Close"
  295. color="#ed4014"
  296. :size="16"
  297. ></n-icon>
  298. </div>
  299. </div>
  300. </div>
  301. </div>
  302. </div>
  303. </div>
  304. </div>
  305. </template>
  306. <style scoped>
  307. .question-group {
  308. margin-bottom: 20px;
  309. }
  310. .group-title {
  311. font-size: var(--app-font-size-large);
  312. font-weight: 600;
  313. margin-bottom: 5px;
  314. }
  315. .question-item {
  316. margin-bottom: 10px;
  317. }
  318. .question-item-options {
  319. margin-bottom: 10px;
  320. }
  321. .line-text > * {
  322. display: inline-block;
  323. vertical-align: top;
  324. }
  325. .line-text audio {
  326. height: 32px;
  327. }
  328. .line-text .question-item-answer-result .n-icon {
  329. vertical-align: middle;
  330. margin-top: -2px;
  331. }
  332. </style>
  333. <style>
  334. .line-text audio {
  335. height: 32px;
  336. }
  337. .line-text img {
  338. display: inline-block;
  339. vertical-align: middle;
  340. }
  341. </style>