|
@@ -0,0 +1,522 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import QuestionBody from "./QuestionBody.vue";
|
|
|
+import VueQrcode from "@chenfengyuan/vue-qrcode";
|
|
|
+import { store } from "@/store/store";
|
|
|
+import { onUnmounted, watch } from "vue";
|
|
|
+import { httpApp } from "@/plugins/axiosApp";
|
|
|
+import UploadPhotos from "./UploadPhotos.vue";
|
|
|
+import { Checkmark } from "@vicons/ionicons5";
|
|
|
+
|
|
|
+let isShowAnswer = $ref(false);
|
|
|
+function toggleShowAnswer() {
|
|
|
+ isShowAnswer = !isShowAnswer;
|
|
|
+}
|
|
|
+
|
|
|
+const examRecordDataId = store.exam.examRecordDataId;
|
|
|
+const order = store.exam.currentQuestion.order;
|
|
|
+const examQuestion = $computed(() => store.exam.currentQuestion);
|
|
|
+const rightAnswerTransform = $computed(() =>
|
|
|
+ examQuestion.rightAnswer?.join("")
|
|
|
+);
|
|
|
+
|
|
|
+const canAttachPhotos = $computed(
|
|
|
+ () => store.exam.WEIXIN_ANSWER_ENABLED && !isAudioAnswerType
|
|
|
+);
|
|
|
+
|
|
|
+const isAudioAnswerType = $computed(
|
|
|
+ () => store.exam.currentQuestion.answerType === "SINGLE_AUDIO"
|
|
|
+);
|
|
|
+
|
|
|
+const shouldFetchQrCode = $computed(() => store.exam.WEIXIN_ANSWER_ENABLED);
|
|
|
+
|
|
|
+let studentAnswer = $ref("");
|
|
|
+let originalStudentAnswer: string | null = $ref(null);
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => store.exam.currentQuestion,
|
|
|
+ () => {
|
|
|
+ if (store.exam.currentQuestion) {
|
|
|
+ studentAnswer = store.exam.currentQuestion.studentAnswer || "";
|
|
|
+ originalStudentAnswer = store.exam.currentQuestion.studentAnswer;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => studentAnswer,
|
|
|
+ () => {
|
|
|
+ let realAnswer = undefined;
|
|
|
+ if (studentAnswer) {
|
|
|
+ // 如果有实际内容
|
|
|
+ realAnswer = studentAnswer
|
|
|
+ .replace(/<sup><\/sup>/gi, "")
|
|
|
+ .replace(/<sub><\/sub>/gi, "")
|
|
|
+ .replace(/<script/gi, "<script")
|
|
|
+ .replace(/script>/gi, "script>");
|
|
|
+ }
|
|
|
+ if (realAnswer !== store.exam.currentQuestion.studentAnswer) {
|
|
|
+ store.updateExamQuestion({
|
|
|
+ order: examQuestion.order,
|
|
|
+ studentAnswer: realAnswer,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+const answerDiv: HTMLDivElement = $ref();
|
|
|
+const answerWordCount = $computed(() => {
|
|
|
+ if (studentAnswer && answerDiv) {
|
|
|
+ return answerDiv.innerText.replace(/\s+/g, "").length;
|
|
|
+ } else {
|
|
|
+ const ele = document.createElement("div");
|
|
|
+ ele.innerHTML = studentAnswer;
|
|
|
+
|
|
|
+ return ele.innerText.replace(/\s+/g, "").length;
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+function disableCtrl(e: KeyboardEvent) {
|
|
|
+ if (e.ctrlKey || e.metaKey || e.altKey) {
|
|
|
+ // .ctrlKey tells that ctrl key was pressed.
|
|
|
+ e.preventDefault();
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+function textInput($event: Event) {
|
|
|
+ studentAnswer = (<HTMLDivElement>$event.target).innerHTML;
|
|
|
+}
|
|
|
+
|
|
|
+let copyNode: DocumentFragment;
|
|
|
+function textCopy() {
|
|
|
+ const selElm = getSelection();
|
|
|
+ if (!selElm) return;
|
|
|
+ var selRange = selElm.getRangeAt(0);
|
|
|
+ copyNode = selRange.cloneContents();
|
|
|
+}
|
|
|
+
|
|
|
+function textCut() {
|
|
|
+ const selElm = getSelection();
|
|
|
+ if (!selElm) return;
|
|
|
+ const selRange = selElm.getRangeAt(0);
|
|
|
+ copyNode = selRange.extractContents();
|
|
|
+ studentAnswer = answerDiv.innerHTML;
|
|
|
+}
|
|
|
+
|
|
|
+function textPaste() {
|
|
|
+ const selElm = getSelection();
|
|
|
+ if (!selElm) return;
|
|
|
+ const selRange = selElm.getRangeAt(0);
|
|
|
+ selRange.deleteContents();
|
|
|
+ selRange.insertNode(copyNode.cloneNode(true));
|
|
|
+ studentAnswer = answerDiv.innerHTML;
|
|
|
+}
|
|
|
+
|
|
|
+function textSup() {
|
|
|
+ const origHMTL = answerDiv.innerHTML + "";
|
|
|
+ getSelection()?.getRangeAt(0).surroundContents(document.createElement("sup"));
|
|
|
+ if (answerDiv.querySelector("sup sup, sub sub, sup sub, sub sup")) {
|
|
|
+ console.log("不允许多层上标下标");
|
|
|
+ answerDiv.innerHTML = origHMTL;
|
|
|
+ } else {
|
|
|
+ studentAnswer = answerDiv.innerHTML;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function undoTextSup() {
|
|
|
+ const selElm = getSelection();
|
|
|
+ if (!selElm) return;
|
|
|
+ // 取消上标时,必须选择之前的文字,才能实现
|
|
|
+ // @ts-expect-error 非web标准方法
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
|
+ selElm.modify("extend", "left", "character");
|
|
|
+ let selRange = selElm.getRangeAt(0);
|
|
|
+ const documentFragment = selRange.extractContents();
|
|
|
+ if (documentFragment.textContent) {
|
|
|
+ const text = new Text(documentFragment.textContent);
|
|
|
+ selRange.insertNode(text);
|
|
|
+ studentAnswer = answerDiv.innerHTML;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function textSub() {
|
|
|
+ const origHMTL = answerDiv.innerHTML + "";
|
|
|
+ getSelection()?.getRangeAt(0).surroundContents(document.createElement("sub"));
|
|
|
+ if (answerDiv.querySelector("sup sup, sub sub, sup sub, sub sup")) {
|
|
|
+ console.log("不允许多层上标下标");
|
|
|
+ answerDiv.innerHTML = origHMTL;
|
|
|
+ } else {
|
|
|
+ studentAnswer = answerDiv.innerHTML;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+let qrValue = $ref("");
|
|
|
+let qrScanned = $ref(false);
|
|
|
+
|
|
|
+let hasUnmounted = false;
|
|
|
+async function fetchQRCode() {
|
|
|
+ if (shouldFetchQrCode && !qrValue) {
|
|
|
+ const transferFileType =
|
|
|
+ examQuestion.answerType === "SINGLE_AUDIO" ? "AUDIO" : "PIC";
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await httpApp.post<string>(
|
|
|
+ "/api/ecs_oe_student/examControl/getQrCode",
|
|
|
+ {
|
|
|
+ examRecordDataId,
|
|
|
+ order: examQuestion.order,
|
|
|
+ transferFileType,
|
|
|
+ testEnv: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "axios-retry": {
|
|
|
+ retries: 10,
|
|
|
+ retryDelay: () => 3 * 1000,
|
|
|
+ retryCondition: () => !hasUnmounted,
|
|
|
+ },
|
|
|
+ noErrorMessage: true,
|
|
|
+ }
|
|
|
+ );
|
|
|
+ let origin = window.location.origin;
|
|
|
+ if (import.meta.env.DEV) {
|
|
|
+ origin = import.meta.env.VITE_CONFIG_API_SERVER as string;
|
|
|
+ }
|
|
|
+ const toReplaceOrigin = new URL(response.data).origin;
|
|
|
+ qrValue = response.data.replace(toReplaceOrigin, origin);
|
|
|
+ } catch (error) {
|
|
|
+ logger({
|
|
|
+ cnl: ["server"],
|
|
|
+ pgu: "AUTO",
|
|
|
+ act: "获取简答题二维码出错",
|
|
|
+ possibleError: error,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+onUnmounted(() => {
|
|
|
+ hasUnmounted = true;
|
|
|
+});
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => store.exam.questionQrCodeScanned,
|
|
|
+ () => {
|
|
|
+ if (store.exam.questionQrCodeScanned.order === examQuestion.order) {
|
|
|
+ qrScanned = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+let pictureAnswer: {
|
|
|
+ transferFileType: string;
|
|
|
+ order: number;
|
|
|
+ fileUrl: string;
|
|
|
+} = $ref();
|
|
|
+let uploadModalVisible = $ref(false);
|
|
|
+watch(
|
|
|
+ () => store.exam.questionAnswerFileUrl,
|
|
|
+ (value) => {
|
|
|
+ for (const q of value) {
|
|
|
+ if (!q?.saved) {
|
|
|
+ let acknowledgeStatus = "CONFIRMED";
|
|
|
+
|
|
|
+ // 目前只针对音频题有丢弃的可能
|
|
|
+ if (
|
|
|
+ q.transferFileType === "PIC" &&
|
|
|
+ (q.order != order || !uploadModalVisible)
|
|
|
+ ) {
|
|
|
+ acknowledgeStatus = "DISCARDED";
|
|
|
+ }
|
|
|
+ httpApp
|
|
|
+ .post(
|
|
|
+ "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
|
|
|
+ {
|
|
|
+ examRecordDataId,
|
|
|
+ filePath: q.fileUrl,
|
|
|
+ order: q.order,
|
|
|
+ acknowledgeStatus,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ .then(() => {
|
|
|
+ if (q.transferFileType === "AUDIO") {
|
|
|
+ store.updateExamQuestion({
|
|
|
+ order: q.order,
|
|
|
+ studentAnswer: q.fileUrl,
|
|
|
+ });
|
|
|
+ } else if (
|
|
|
+ acknowledgeStatus === "CONFIRMED" &&
|
|
|
+ q.transferFileType === "PIC"
|
|
|
+ ) {
|
|
|
+ pictureAnswer = q;
|
|
|
+ console.log("first");
|
|
|
+ }
|
|
|
+ q.saved = true;
|
|
|
+ if (acknowledgeStatus === "CONFIRMED")
|
|
|
+ $message.info("小程序作答已更新");
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ $message.error("更新小程序答案失败!");
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => store.exam.currentQuestion?.order,
|
|
|
+ async () => {
|
|
|
+ if (!store.exam.WEIXIN_ANSWER_ENABLED) return;
|
|
|
+ if (store.exam.currentQuestion.questionType !== "ESSAY") return;
|
|
|
+
|
|
|
+ await fetchQRCode();
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+
|
|
|
+let photoAnswers: string[] = $computed({
|
|
|
+ get() {
|
|
|
+ if (!studentAnswer) return [];
|
|
|
+ const ele = document.createElement("div");
|
|
|
+ ele.innerHTML = studentAnswer;
|
|
|
+ const imgs = ele.querySelectorAll(
|
|
|
+ ".photo-answer"
|
|
|
+ ) as unknown as HTMLImageElement[];
|
|
|
+ return [...imgs].map((e) =>
|
|
|
+ e.src.replace("?x-oss-process=image/resize,m_lfit,h_200,w_200", "")
|
|
|
+ );
|
|
|
+ },
|
|
|
+ set(pSrcs) {
|
|
|
+ let imageStr = pSrcs.map(
|
|
|
+ (v) =>
|
|
|
+ `<a href='${v}' target='_blank' ><img class='photo-answer' src='${
|
|
|
+ v + "?x-oss-process=image/resize,m_lfit,h_200,w_200"
|
|
|
+ }' /></a>`
|
|
|
+ );
|
|
|
+ const ele = document.createElement("div");
|
|
|
+ ele.innerHTML = studentAnswer || "";
|
|
|
+ const pEle = ele.querySelectorAll(".photo-answers-block");
|
|
|
+ if (pEle) [...pEle].forEach((v) => v.remove());
|
|
|
+ // console.log(ele.innerHTML);
|
|
|
+
|
|
|
+ if (!ele.innerHTML && pSrcs.length === 0) {
|
|
|
+ // 完全为空则重置答案
|
|
|
+ studentAnswer = "";
|
|
|
+ // 更新answerDiv的内容
|
|
|
+ originalStudentAnswer = null;
|
|
|
+ } else {
|
|
|
+ studentAnswer =
|
|
|
+ ele.innerHTML +
|
|
|
+ `<div class='photo-answers-block'>${imageStr.join("")}</div>`;
|
|
|
+ // 更新answerDiv的内容
|
|
|
+ originalStudentAnswer = studentAnswer;
|
|
|
+ }
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+function photoRemoved(url: string) {
|
|
|
+ photoAnswers = photoAnswers.filter((v) => v !== url);
|
|
|
+}
|
|
|
+function photosReseted(urls: string[]) {
|
|
|
+ photoAnswers = [...urls];
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="question-view">
|
|
|
+ <question-body :questionBody="examQuestion.body"></question-body>
|
|
|
+ <div class="ops">
|
|
|
+ <div class="score">({{ examQuestion.questionScore }}分)</div>
|
|
|
+ </div>
|
|
|
+ <div class="option">
|
|
|
+ <div v-if="!isAudioAnswerType">
|
|
|
+ <div class="menu">
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ class="text-ops"
|
|
|
+ size="small"
|
|
|
+ @click="textCopy"
|
|
|
+ >
|
|
|
+ 复制
|
|
|
+ </n-button>
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ class="text-ops"
|
|
|
+ size="small"
|
|
|
+ @click="textCut"
|
|
|
+ >
|
|
|
+ 剪切
|
|
|
+ </n-button>
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ class="text-ops"
|
|
|
+ size="small"
|
|
|
+ @click="textPaste"
|
|
|
+ >
|
|
|
+ 粘贴
|
|
|
+ </n-button>
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ class="text-ops"
|
|
|
+ size="small"
|
|
|
+ @click="textSup"
|
|
|
+ >
|
|
|
+ 上标
|
|
|
+ </n-button>
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ class="text-ops"
|
|
|
+ size="small"
|
|
|
+ @click="undoTextSup"
|
|
|
+ >
|
|
|
+ 取消上标
|
|
|
+ </n-button>
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ class="text-ops"
|
|
|
+ size="small"
|
|
|
+ @click="textSub"
|
|
|
+ >
|
|
|
+ 下标
|
|
|
+ </n-button>
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ class="text-ops"
|
|
|
+ size="small"
|
|
|
+ @click="undoTextSup"
|
|
|
+ >
|
|
|
+ 取消下标
|
|
|
+ </n-button>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ ref="answerDiv"
|
|
|
+ ondragstart="return false"
|
|
|
+ ondrop="return false"
|
|
|
+ :contenteditable="true"
|
|
|
+ class="stu-answer"
|
|
|
+ @keydown="disableCtrl"
|
|
|
+ @input="($event) => textInput($event)"
|
|
|
+ @blur="($event) => textInput($event)"
|
|
|
+ v-html="originalStudentAnswer"
|
|
|
+ ></div>
|
|
|
+ <div
|
|
|
+ style="
|
|
|
+ margin-top: -25px;
|
|
|
+ margin-bottom: 25px;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 500px;
|
|
|
+ "
|
|
|
+ >
|
|
|
+ <div style="float: right; margin-right: 10px">
|
|
|
+ {{ answerWordCount }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="shouldFetchQrCode && isAudioAnswerType">
|
|
|
+ <div>
|
|
|
+ <div v-if="qrValue" style="display: flex">
|
|
|
+ <VueQrcode
|
|
|
+ :value="qrValue"
|
|
|
+ :options="{ width: 200 }"
|
|
|
+ style="margin-left: -10px"
|
|
|
+ />
|
|
|
+ <div style="margin-top: 10px">
|
|
|
+ <div>
|
|
|
+ 请使用<span style="font-weight: 900; color: #1e90ff">微信</span
|
|
|
+ >扫描二维码后,在微信小程序上{{
|
|
|
+ isAudioAnswerType ? "录音" : "拍照"
|
|
|
+ }},并上传文件。
|
|
|
+ </div>
|
|
|
+ <div v-if="qrScanned" style="margin-top: 30px; font-size: 30px">
|
|
|
+ {{ examQuestion.studentAnswer ? "已上传" : "已扫描" }}
|
|
|
+ <n-icon :componet="Checkmark" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else>正在获取二维码...</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ class="audio-answer audio-answer-line-height"
|
|
|
+ style="margin-top: 20px"
|
|
|
+ >
|
|
|
+ <span class="audio-answer-line-height">答案:</span>
|
|
|
+ <audio
|
|
|
+ v-if="examQuestion.studentAnswer"
|
|
|
+ class="audio-answer-line-height"
|
|
|
+ controls
|
|
|
+ controlsList="nodownload"
|
|
|
+ :src="examQuestion.studentAnswer"
|
|
|
+ />
|
|
|
+ <span v-else class="audio-answer-line-height">未上传文件</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="canAttachPhotos" style="padding-top: 1px">
|
|
|
+ <UploadPhotos
|
|
|
+ v-model:uploadModalVisible="uploadModalVisible"
|
|
|
+ :defaultList="photoAnswers.map((v) => v)"
|
|
|
+ :qrValue="qrValue"
|
|
|
+ style="margin-top: 20px; width: 350px"
|
|
|
+ :pictureAnswer="pictureAnswer"
|
|
|
+ @onPhotoRemoved="photoRemoved"
|
|
|
+ @onPhotosReseted="photosReseted"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="reset" style="padding-top: 20px">
|
|
|
+ <span v-if="store.examShouldShowAnswer">
|
|
|
+ <n-button type="success" @click="toggleShowAnswer">
|
|
|
+ 显示答案
|
|
|
+ </n-button>
|
|
|
+ </span>
|
|
|
+ <div v-if="isShowAnswer">
|
|
|
+ 正确答案:
|
|
|
+ <div class="right-answer-section" v-html="rightAnswerTransform"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.question-view {
|
|
|
+ display: grid;
|
|
|
+ grid-row-gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.question-body {
|
|
|
+ font-size: 18px;
|
|
|
+ /* margin-bottom: 10px; */
|
|
|
+}
|
|
|
+
|
|
|
+.ops {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.text-ops {
|
|
|
+ margin: 0 5px 5px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.stu-answer {
|
|
|
+ width: 100%;
|
|
|
+ max-width: 500px;
|
|
|
+ min-height: 300px;
|
|
|
+ border: 1px solid grey;
|
|
|
+ padding: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.audio-answer {
|
|
|
+ font-size: 24px;
|
|
|
+ line-height: 54px;
|
|
|
+}
|
|
|
+.audio-answer-line-height {
|
|
|
+ line-height: 54px;
|
|
|
+ vertical-align: text-bottom;
|
|
|
+}
|
|
|
+</style>
|
|
|
+<style>
|
|
|
+.photo-answers-block {
|
|
|
+ display: none;
|
|
|
+}
|
|
|
+</style>
|