123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522 |
- <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>
|