|
@@ -0,0 +1,364 @@
|
|
|
|
+<script setup lang="ts">
|
|
|
|
+import {
|
|
|
|
+ examQuestionDataApi,
|
|
|
|
+ examRecordDataApi,
|
|
|
|
+ examRecordPaperStructApi,
|
|
|
|
+ examRecordQuestionsApi,
|
|
|
|
+ onlinePracticeTypeApi,
|
|
|
|
+} from "@/api/onlinePractice";
|
|
|
|
+import {
|
|
|
|
+ ExamQuestion,
|
|
|
|
+ PaperStruct,
|
|
|
|
+ QuestionWrapperItem,
|
|
|
|
+} from "@/types/student-client";
|
|
|
|
+import { toChineseNumber } from "@/utils/utils";
|
|
|
|
+import { onMounted } from "vue";
|
|
|
|
+import { Close, Checkmark } from "@vicons/ionicons5";
|
|
|
|
+
|
|
|
|
+const props = defineProps<{
|
|
|
|
+ examId: string | number;
|
|
|
|
+ examRecordDataId: string | number;
|
|
|
|
+ fromCache: boolean;
|
|
|
|
+ courseCode: string;
|
|
|
|
+}>();
|
|
|
|
+const emit = defineEmits<{
|
|
|
|
+ (e: "ready"): void;
|
|
|
|
+}>();
|
|
|
|
+
|
|
|
|
+let questionGroupList = $ref<PaperStruct["defaultPaper"]["questionGroupList"]>(
|
|
|
|
+ []
|
|
|
|
+);
|
|
|
|
+let practiceType = $ref("");
|
|
|
|
+
|
|
|
|
+async function initData() {
|
|
|
|
+ const ptRes = await onlinePracticeTypeApi(props.examId);
|
|
|
|
+ practiceType = ptRes.data.PRACTICE_TYPE;
|
|
|
|
+
|
|
|
|
+ const { paperStruct, examQuestionList, examRecordData } =
|
|
|
|
+ await getPaperData();
|
|
|
|
+ if (!paperStruct || !examQuestionList) {
|
|
|
|
+ $message.error("获取试卷信息失败", {
|
|
|
|
+ duration: 15,
|
|
|
|
+ closable: true,
|
|
|
|
+ });
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const examQuestionMap = await getQuestionWrapperMapData(
|
|
|
|
+ examQuestionList,
|
|
|
|
+ examRecordData.examRecord.paperType
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ questionGroupList = paperStruct.defaultPaper.questionGroupList.map(
|
|
|
|
+ (questionGroup) => {
|
|
|
|
+ questionGroup.questionWrapperList = questionGroup.questionWrapperList.map(
|
|
|
|
+ (question) => {
|
|
|
|
+ return examQuestionMap[question.questionId];
|
|
|
|
+ }
|
|
|
|
+ );
|
|
|
|
+ return questionGroup;
|
|
|
|
+ }
|
|
|
|
+ );
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+async function getPaperData() {
|
|
|
|
+ type FetchDataFuncType = [
|
|
|
|
+ Promise<{ data: PaperStruct }>,
|
|
|
|
+ Promise<{
|
|
|
|
+ data: { examQuestionEntities: ExamQuestion[] };
|
|
|
|
+ }>,
|
|
|
|
+ Promise<{
|
|
|
|
+ data: { examRecord: { paperType: string } };
|
|
|
|
+ }>
|
|
|
|
+ ];
|
|
|
|
+ const fetchDataFunc: FetchDataFuncType = [
|
|
|
|
+ examRecordPaperStructApi(props.examRecordDataId, props.fromCache),
|
|
|
|
+ examRecordQuestionsApi(props.examRecordDataId, props.fromCache),
|
|
|
|
+ examRecordDataApi(props.examRecordDataId, props.fromCache),
|
|
|
|
+ ];
|
|
|
|
+
|
|
|
|
+ const [paperStructRes, examQuestionListRes, examRecordDataRes] =
|
|
|
|
+ await Promise.all(fetchDataFunc);
|
|
|
|
+
|
|
|
|
+ const [paperStruct, examQuestionList, examRecordData] = [
|
|
|
|
+ paperStructRes.data,
|
|
|
|
+ examQuestionListRes.data.examQuestionEntities,
|
|
|
|
+ examRecordDataRes.data,
|
|
|
|
+ ];
|
|
|
|
+ return {
|
|
|
|
+ paperStruct,
|
|
|
|
+ examQuestionList,
|
|
|
|
+ examRecordData,
|
|
|
|
+ };
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+async function getQuestionWrapperMapData(
|
|
|
|
+ examQuestionList: ExamQuestion[],
|
|
|
|
+ groupCode: string
|
|
|
|
+) {
|
|
|
|
+ let examQuestionMap: Record<string, ExamQuestion[]> = {};
|
|
|
|
+ examQuestionList.forEach((q) => {
|
|
|
|
+ if (!examQuestionMap[q.questionId]) examQuestionMap[q.questionId] = [];
|
|
|
|
+ examQuestionMap[q.questionId].push(q);
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ const fetchFunc = Object.keys(examQuestionMap).map((questionId) =>
|
|
|
|
+ examQuestionDataApi({
|
|
|
|
+ questionId,
|
|
|
|
+ examId: props.examId,
|
|
|
|
+ courseCode: props.courseCode,
|
|
|
|
+ groupCode,
|
|
|
|
+ })
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ const questionList = await Promise.all(fetchFunc);
|
|
|
|
+
|
|
|
|
+ let examQuestionWrapperMap: Record<string, QuestionWrapperItem> = {};
|
|
|
|
+ questionList.forEach((qItem) => {
|
|
|
|
+ const q = qItem.data;
|
|
|
|
+ const questionList = examQuestionMap[q.id].map((questionInfo, index) => {
|
|
|
|
+ return Object.assign(
|
|
|
|
+ {},
|
|
|
|
+ questionInfo,
|
|
|
|
+ q.masterVersion.questionUnitList[index]
|
|
|
|
+ ) as ExamQuestion;
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ examQuestionWrapperMap[q.id] = Object.assign(
|
|
|
|
+ {},
|
|
|
|
+ { examQuestionList: questionList, questionId: q.id },
|
|
|
|
+ q.masterVersion
|
|
|
|
+ );
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ return examQuestionWrapperMap;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+function restoreAudio(str: string) {
|
|
|
|
+ return (str || "")
|
|
|
|
+ .replace(/<a/g, "<audio controls ")
|
|
|
|
+ .replace(/url=/g, "src=")
|
|
|
|
+ .replace(/a>/g, "audio>");
|
|
|
|
+}
|
|
|
|
+const optionName = "ABCDEFGHIJ".split("");
|
|
|
|
+function indexToABCD(index: number) {
|
|
|
|
+ return optionName[index];
|
|
|
|
+}
|
|
|
|
+function parseRightAnswer(
|
|
|
|
+ questionType: string,
|
|
|
|
+ rightAnswer: string[] | undefined,
|
|
|
|
+ optionPermutation: number[] | undefined
|
|
|
|
+) {
|
|
|
|
+ if (!rightAnswer) return "";
|
|
|
|
+ // 选择题答案是非乱序的真实答案,展示时要转成乱序的题目答案。
|
|
|
|
+ if (["SINGLE_CHOICE", "MULTIPLE_CHOICE"].includes(questionType)) {
|
|
|
|
+ const permutationOptions = optionPermutation as number[];
|
|
|
|
+ const permutationAnswer = rightAnswer
|
|
|
|
+ .map((answer) => permutationOptions.indexOf(Number(answer)))
|
|
|
|
+ .sort();
|
|
|
|
+ return permutationAnswer.map((ans) => indexToABCD(ans)).join("");
|
|
|
|
+ } else if (["TRUE_OR_FALSE"].includes(questionType)) {
|
|
|
|
+ return { true: "正确", false: "错误" }[rightAnswer.join("")];
|
|
|
|
+ } else {
|
|
|
|
+ return rightAnswer.join("");
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+function parseStudentAnswer(
|
|
|
|
+ questionType: string,
|
|
|
|
+ studentAnswer: string,
|
|
|
|
+ optionPermutation: number[] | undefined
|
|
|
|
+) {
|
|
|
|
+ if (!studentAnswer) return "";
|
|
|
|
+
|
|
|
|
+ // 选择题答案是非乱序的真实答案,展示时要转成乱序的题目答案。
|
|
|
|
+ if (["SINGLE_CHOICE", "MULTIPLE_CHOICE"].includes(questionType)) {
|
|
|
|
+ const permutationOptions = optionPermutation as number[];
|
|
|
|
+ const permutationAnswer = studentAnswer
|
|
|
|
+ .split("")
|
|
|
|
+ .map((answer) => permutationOptions.indexOf(Number(answer)))
|
|
|
|
+ .sort();
|
|
|
|
+ return permutationAnswer.map((ans) => indexToABCD(ans)).join("");
|
|
|
|
+ } else if (["TRUE_OR_FALSE"].includes(questionType)) {
|
|
|
|
+ return { true: "正确", false: "错误" }[studentAnswer];
|
|
|
|
+ } else {
|
|
|
|
+ return studentAnswer;
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+function equalAnswer(
|
|
|
|
+ questionType: string,
|
|
|
|
+ studentAnswer: string,
|
|
|
|
+ rightAnswer: string[] | undefined
|
|
|
|
+) {
|
|
|
|
+ if (!rightAnswer) return null;
|
|
|
|
+ if (["FILL_UP", "ESSAY"].includes(questionType)) return null;
|
|
|
|
+
|
|
|
|
+ return studentAnswer === rightAnswer.join("");
|
|
|
|
+}
|
|
|
|
+function checkIsObjective(questionType: string) {
|
|
|
|
+ return ["SINGLE_CHOICE", "MULTIPLE_CHOICE", "TRUE_OR_FALSE"].includes(
|
|
|
|
+ questionType
|
|
|
|
+ );
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+onMounted(async () => {
|
|
|
|
+ await initData().catch(() => false);
|
|
|
|
+ emit("ready");
|
|
|
|
+});
|
|
|
|
+</script>
|
|
|
|
+
|
|
|
|
+<template>
|
|
|
|
+ <div class="exam-paper">
|
|
|
|
+ <div
|
|
|
|
+ v-for="(group, gindex) in questionGroupList"
|
|
|
|
+ :key="gindex"
|
|
|
|
+ class="question-group"
|
|
|
|
+ >
|
|
|
|
+ <h3 class="group-title">
|
|
|
|
+ {{ toChineseNumber(gindex + 1) }}、{{ group.groupName }}({{
|
|
|
|
+ group.groupScore
|
|
|
|
+ }}分)
|
|
|
|
+ </h3>
|
|
|
|
+ <div class="group-questions">
|
|
|
|
+ <div
|
|
|
|
+ v-for="questionWrapper in group.questionWrapperList"
|
|
|
|
+ :key="questionWrapper.questionId"
|
|
|
|
+ class="question-wrapper"
|
|
|
|
+ >
|
|
|
|
+ <div
|
|
|
|
+ v-if="questionWrapper.body"
|
|
|
|
+ class="question-wrapper-body"
|
|
|
|
+ v-html="restoreAudio(questionWrapper.body)"
|
|
|
|
+ ></div>
|
|
|
|
+ <div
|
|
|
|
+ v-for="question in questionWrapper.examQuestionList"
|
|
|
|
+ :key="question.questionId"
|
|
|
|
+ class="question-item"
|
|
|
|
+ >
|
|
|
|
+ <div class="question-item-title line-text">
|
|
|
|
+ <span>{{ question.order }}、</span>
|
|
|
|
+ <div
|
|
|
|
+ class="question-item-title-content"
|
|
|
|
+ v-html="restoreAudio(question.body)"
|
|
|
|
+ ></div>
|
|
|
|
+ </div>
|
|
|
|
+ <!-- options -->
|
|
|
|
+ <div
|
|
|
|
+ v-if="
|
|
|
|
+ question.questionOptionList &&
|
|
|
|
+ question.questionOptionList.length
|
|
|
|
+ "
|
|
|
|
+ class="question-item-options"
|
|
|
|
+ >
|
|
|
|
+ <div
|
|
|
|
+ v-for="(optionOrder, oindex) in question.optionPermutation"
|
|
|
|
+ :key="optionOrder"
|
|
|
|
+ class="question-item-option line-text"
|
|
|
|
+ >
|
|
|
|
+ <span>{{ indexToABCD(oindex) }}、</span>
|
|
|
|
+ <div
|
|
|
|
+ class="question-item-option-content"
|
|
|
|
+ v-html="
|
|
|
|
+ restoreAudio(question.questionOptionList[optionOrder].body)
|
|
|
|
+ "
|
|
|
|
+ ></div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ <!-- right answer -->
|
|
|
|
+ <div
|
|
|
|
+ v-if="practiceType !== 'NO_ANSWER'"
|
|
|
|
+ class="question-item-answer line-text"
|
|
|
|
+ >
|
|
|
|
+ <span>正确答案:</span>
|
|
|
|
+ <div
|
|
|
|
+ class="question-item-answer-content"
|
|
|
|
+ v-html="
|
|
|
|
+ parseRightAnswer(
|
|
|
|
+ question.questionType,
|
|
|
|
+ question.rightAnswer,
|
|
|
|
+ question.optionPermutation
|
|
|
|
+ )
|
|
|
|
+ "
|
|
|
|
+ ></div>
|
|
|
|
+ </div>
|
|
|
|
+ <!-- student answer -->
|
|
|
|
+ <div class="question-item-answer line-text">
|
|
|
|
+ <span>学生答案:</span>
|
|
|
|
+ <div
|
|
|
|
+ class="question-item-answer-content"
|
|
|
|
+ v-html="
|
|
|
|
+ parseStudentAnswer(
|
|
|
|
+ question.questionType,
|
|
|
|
+ question.studentAnswer,
|
|
|
|
+ question.optionPermutation
|
|
|
|
+ )
|
|
|
|
+ "
|
|
|
|
+ ></div>
|
|
|
|
+ <div
|
|
|
|
+ v-if="checkIsObjective(question.questionType)"
|
|
|
|
+ class="question-item-answer-result"
|
|
|
|
+ >
|
|
|
|
+ <n-icon
|
|
|
|
+ v-if="
|
|
|
|
+ equalAnswer(
|
|
|
|
+ question.questionType,
|
|
|
|
+ question.studentAnswer,
|
|
|
|
+ question.rightAnswer
|
|
|
|
+ )
|
|
|
|
+ "
|
|
|
|
+ :component="Checkmark"
|
|
|
|
+ color="#13bb8a"
|
|
|
|
+ :size="16"
|
|
|
|
+ ></n-icon>
|
|
|
|
+ <n-icon
|
|
|
|
+ v-else
|
|
|
|
+ :component="Close"
|
|
|
|
+ color="#ed4014"
|
|
|
|
+ :size="16"
|
|
|
|
+ ></n-icon>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
|
|
+</template>
|
|
|
|
+
|
|
|
|
+<style scoped>
|
|
|
|
+.question-group {
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
+}
|
|
|
|
+.group-title {
|
|
|
|
+ font-size: var(--app-font-size-large);
|
|
|
|
+ font-weight: 600;
|
|
|
|
+ margin-bottom: 5px;
|
|
|
|
+}
|
|
|
|
+.question-item {
|
|
|
|
+ margin-bottom: 10px;
|
|
|
|
+}
|
|
|
|
+.question-item-options {
|
|
|
|
+ margin-bottom: 10px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.line-text > * {
|
|
|
|
+ display: inline-block;
|
|
|
|
+ vertical-align: top;
|
|
|
|
+}
|
|
|
|
+.line-text audio {
|
|
|
|
+ height: 32px;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+.line-text .question-item-answer-result .n-icon {
|
|
|
|
+ vertical-align: middle;
|
|
|
|
+ margin-top: -2px;
|
|
|
|
+}
|
|
|
|
+</style>
|
|
|
|
+<style>
|
|
|
|
+.line-text audio {
|
|
|
|
+ height: 32px;
|
|
|
|
+}
|
|
|
|
+.line-text img {
|
|
|
|
+ display: inline-block;
|
|
|
|
+ vertical-align: middle;
|
|
|
|
+}
|
|
|
|
+</style>
|