TextQuestionView.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. <script setup lang="ts">
  2. import QuestionBody from "./QuestionBody.vue";
  3. import VueQrcode from "@chenfengyuan/vue-qrcode";
  4. import { store } from "@/store/store";
  5. import { onUnmounted, watch } from "vue";
  6. import { httpApp } from "@/plugins/axiosApp";
  7. import UploadPhotos from "./UploadPhotos.vue";
  8. import { Checkmark } from "@vicons/ionicons5";
  9. let isShowAnswer = $ref(false);
  10. function toggleShowAnswer() {
  11. isShowAnswer = !isShowAnswer;
  12. }
  13. const examRecordDataId = store.exam.examRecordDataId;
  14. const order = store.exam.currentQuestion.order;
  15. const examQuestion = $computed(() => store.exam.currentQuestion);
  16. const rightAnswerTransform = $computed(() =>
  17. examQuestion.rightAnswer?.join("")
  18. );
  19. const canAttachPhotos = $computed(
  20. () => store.exam.WEIXIN_ANSWER_ENABLED && !isAudioAnswerType
  21. );
  22. const isAudioAnswerType = $computed(
  23. () => store.exam.currentQuestion.answerType === "SINGLE_AUDIO"
  24. );
  25. const shouldFetchQrCode = $computed(() => store.exam.WEIXIN_ANSWER_ENABLED);
  26. let studentAnswer = $ref("");
  27. let originalStudentAnswer: string | null = $ref(null);
  28. watch(
  29. () => store.exam.currentQuestion,
  30. () => {
  31. if (store.exam.currentQuestion) {
  32. studentAnswer = store.exam.currentQuestion.studentAnswer || "";
  33. originalStudentAnswer = store.exam.currentQuestion.studentAnswer;
  34. }
  35. },
  36. { immediate: true }
  37. );
  38. watch(
  39. () => studentAnswer,
  40. () => {
  41. let realAnswer = undefined;
  42. if (studentAnswer) {
  43. // 如果有实际内容
  44. realAnswer = studentAnswer
  45. .replace(/<sup><\/sup>/gi, "")
  46. .replace(/<sub><\/sub>/gi, "")
  47. .replace(/<script/gi, "&lt;script")
  48. .replace(/script>/gi, "script&gt;");
  49. }
  50. if (realAnswer !== store.exam.currentQuestion.studentAnswer) {
  51. store.updateExamQuestion({
  52. order: examQuestion.order,
  53. studentAnswer: realAnswer,
  54. });
  55. }
  56. }
  57. );
  58. const answerDiv: HTMLDivElement = $ref();
  59. const answerWordCount = $computed(() => {
  60. if (studentAnswer && answerDiv) {
  61. return answerDiv.innerText.replace(/\s+/g, "").length;
  62. } else {
  63. const ele = document.createElement("div");
  64. ele.innerHTML = studentAnswer;
  65. return ele.innerText.replace(/\s+/g, "").length;
  66. }
  67. });
  68. function disableCtrl(e: KeyboardEvent) {
  69. if (e.ctrlKey || e.metaKey || e.altKey) {
  70. // .ctrlKey tells that ctrl key was pressed.
  71. e.preventDefault();
  72. return false;
  73. }
  74. return true;
  75. }
  76. function textInput($event: Event) {
  77. studentAnswer = (<HTMLDivElement>$event.target).innerHTML;
  78. }
  79. let copyNode: DocumentFragment;
  80. function textCopy() {
  81. const selElm = getSelection();
  82. if (!selElm) return;
  83. var selRange = selElm.getRangeAt(0);
  84. copyNode = selRange.cloneContents();
  85. }
  86. function textCut() {
  87. const selElm = getSelection();
  88. if (!selElm) return;
  89. const selRange = selElm.getRangeAt(0);
  90. copyNode = selRange.extractContents();
  91. studentAnswer = answerDiv.innerHTML;
  92. }
  93. function textPaste() {
  94. const selElm = getSelection();
  95. if (!selElm) return;
  96. const selRange = selElm.getRangeAt(0);
  97. selRange.deleteContents();
  98. selRange.insertNode(copyNode.cloneNode(true));
  99. studentAnswer = answerDiv.innerHTML;
  100. }
  101. function textSup() {
  102. const origHMTL = answerDiv.innerHTML + "";
  103. getSelection()?.getRangeAt(0).surroundContents(document.createElement("sup"));
  104. if (answerDiv.querySelector("sup sup, sub sub, sup sub, sub sup")) {
  105. console.log("不允许多层上标下标");
  106. answerDiv.innerHTML = origHMTL;
  107. } else {
  108. studentAnswer = answerDiv.innerHTML;
  109. }
  110. }
  111. function undoTextSup() {
  112. const selElm = getSelection();
  113. if (!selElm) return;
  114. // 取消上标时,必须选择之前的文字,才能实现
  115. // @ts-expect-error 非web标准方法
  116. // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  117. selElm.modify("extend", "left", "character");
  118. let selRange = selElm.getRangeAt(0);
  119. const documentFragment = selRange.extractContents();
  120. if (documentFragment.textContent) {
  121. const text = new Text(documentFragment.textContent);
  122. selRange.insertNode(text);
  123. studentAnswer = answerDiv.innerHTML;
  124. }
  125. }
  126. function textSub() {
  127. const origHMTL = answerDiv.innerHTML + "";
  128. getSelection()?.getRangeAt(0).surroundContents(document.createElement("sub"));
  129. if (answerDiv.querySelector("sup sup, sub sub, sup sub, sub sup")) {
  130. console.log("不允许多层上标下标");
  131. answerDiv.innerHTML = origHMTL;
  132. } else {
  133. studentAnswer = answerDiv.innerHTML;
  134. }
  135. }
  136. let qrValue = $ref("");
  137. let qrScanned = $ref(false);
  138. let hasUnmounted = false;
  139. async function fetchQRCode() {
  140. if (shouldFetchQrCode && !qrValue) {
  141. const transferFileType =
  142. examQuestion.answerType === "SINGLE_AUDIO" ? "AUDIO" : "PIC";
  143. try {
  144. const response = await httpApp.post<string>(
  145. "/api/ecs_oe_student/examControl/getQrCode",
  146. {
  147. examRecordDataId,
  148. order: examQuestion.order,
  149. transferFileType,
  150. testEnv: false,
  151. },
  152. {
  153. "axios-retry": {
  154. retries: 10,
  155. retryDelay: () => 3 * 1000,
  156. retryCondition: () => !hasUnmounted,
  157. },
  158. noErrorMessage: true,
  159. }
  160. );
  161. let origin = window.location.origin;
  162. if (import.meta.env.DEV) {
  163. origin = import.meta.env.VITE_CONFIG_API_SERVER as string;
  164. }
  165. const toReplaceOrigin = new URL(response.data).origin;
  166. qrValue = response.data.replace(toReplaceOrigin, origin);
  167. } catch (error) {
  168. logger({
  169. cnl: ["server"],
  170. pgu: "AUTO",
  171. act: "获取简答题二维码出错",
  172. possibleError: error,
  173. });
  174. }
  175. }
  176. }
  177. onUnmounted(() => {
  178. hasUnmounted = true;
  179. });
  180. watch(
  181. () => store.exam.questionQrCodeScanned,
  182. () => {
  183. if (store.exam.questionQrCodeScanned.order === examQuestion.order) {
  184. qrScanned = true;
  185. }
  186. }
  187. );
  188. let pictureAnswer: {
  189. transferFileType: string;
  190. order: number;
  191. fileUrl: string;
  192. } = $ref();
  193. let uploadModalVisible = $ref(false);
  194. watch(
  195. () => store.exam.questionAnswerFileUrl,
  196. (value) => {
  197. for (const q of value) {
  198. if (!q?.saved) {
  199. let acknowledgeStatus = "CONFIRMED";
  200. // 目前只针对音频题有丢弃的可能
  201. if (
  202. q.transferFileType === "PIC" &&
  203. (q.order != order || !uploadModalVisible)
  204. ) {
  205. acknowledgeStatus = "DISCARDED";
  206. }
  207. httpApp
  208. .post(
  209. "/api/ecs_oe_student/examControl/saveUploadedFileAcknowledgeStatus",
  210. {
  211. examRecordDataId,
  212. filePath: q.fileUrl,
  213. order: q.order,
  214. acknowledgeStatus,
  215. }
  216. )
  217. .then(() => {
  218. if (q.transferFileType === "AUDIO") {
  219. store.updateExamQuestion({
  220. order: q.order,
  221. studentAnswer: q.fileUrl,
  222. });
  223. } else if (
  224. acknowledgeStatus === "CONFIRMED" &&
  225. q.transferFileType === "PIC"
  226. ) {
  227. pictureAnswer = q;
  228. console.log("first");
  229. }
  230. q.saved = true;
  231. if (acknowledgeStatus === "CONFIRMED")
  232. $message.info("小程序作答已更新");
  233. })
  234. .catch(() => {
  235. $message.error("更新小程序答案失败!");
  236. });
  237. }
  238. }
  239. }
  240. );
  241. watch(
  242. () => store.exam.currentQuestion?.order,
  243. async () => {
  244. if (!store.exam.WEIXIN_ANSWER_ENABLED) return;
  245. if (store.exam.currentQuestion?.questionType !== "ESSAY") return;
  246. await fetchQRCode();
  247. },
  248. { immediate: true }
  249. );
  250. let photoAnswers: string[] = $computed({
  251. get() {
  252. if (!studentAnswer) return [];
  253. const ele = document.createElement("div");
  254. ele.innerHTML = studentAnswer;
  255. const imgs = ele.querySelectorAll(
  256. ".photo-answer"
  257. ) as unknown as HTMLImageElement[];
  258. return [...imgs].map((e) =>
  259. e.src.replace("?x-oss-process=image/resize,m_lfit,h_200,w_200", "")
  260. );
  261. },
  262. set(pSrcs) {
  263. let imageStr = pSrcs.map(
  264. (v) =>
  265. `<a href='${v}' target='_blank' ><img class='photo-answer' src='${
  266. v + "?x-oss-process=image/resize,m_lfit,h_200,w_200"
  267. }' /></a>`
  268. );
  269. const ele = document.createElement("div");
  270. ele.innerHTML = studentAnswer || "";
  271. const pEle = ele.querySelectorAll(".photo-answers-block");
  272. if (pEle) [...pEle].forEach((v) => v.remove());
  273. // console.log(ele.innerHTML);
  274. if (!ele.innerHTML && pSrcs.length === 0) {
  275. // 完全为空则重置答案
  276. studentAnswer = "";
  277. // 更新answerDiv的内容
  278. originalStudentAnswer = null;
  279. } else {
  280. studentAnswer =
  281. ele.innerHTML +
  282. `<div class='photo-answers-block'>${imageStr.join("")}</div>`;
  283. // 更新answerDiv的内容
  284. originalStudentAnswer = studentAnswer;
  285. }
  286. },
  287. });
  288. function photoRemoved(url: string) {
  289. photoAnswers = photoAnswers.filter((v) => v !== url);
  290. }
  291. function photosReseted(urls: string[]) {
  292. photoAnswers = [...urls];
  293. }
  294. </script>
  295. <template>
  296. <div class="question-view">
  297. <question-body :questionBody="examQuestion.body"></question-body>
  298. <div class="ops">
  299. <div class="score">({{ examQuestion.questionScore }}分)</div>
  300. </div>
  301. <div class="option">
  302. <div v-if="!isAudioAnswerType">
  303. <div class="menu">
  304. <n-button
  305. type="success"
  306. class="text-ops"
  307. size="small"
  308. @click="textCopy"
  309. >
  310. 复制
  311. </n-button>
  312. <n-button
  313. type="success"
  314. class="text-ops"
  315. size="small"
  316. @click="textCut"
  317. >
  318. 剪切
  319. </n-button>
  320. <n-button
  321. type="success"
  322. class="text-ops"
  323. size="small"
  324. @click="textPaste"
  325. >
  326. 粘贴
  327. </n-button>
  328. <n-button
  329. type="success"
  330. class="text-ops"
  331. size="small"
  332. @click="textSup"
  333. >
  334. 上标
  335. </n-button>
  336. <n-button
  337. type="success"
  338. class="text-ops"
  339. size="small"
  340. @click="undoTextSup"
  341. >
  342. 取消上标
  343. </n-button>
  344. <n-button
  345. type="success"
  346. class="text-ops"
  347. size="small"
  348. @click="textSub"
  349. >
  350. 下标
  351. </n-button>
  352. <n-button
  353. type="success"
  354. class="text-ops"
  355. size="small"
  356. @click="undoTextSup"
  357. >
  358. 取消下标
  359. </n-button>
  360. </div>
  361. <div
  362. ref="answerDiv"
  363. ondragstart="return false"
  364. ondrop="return false"
  365. :contenteditable="true"
  366. class="stu-answer"
  367. @keydown="disableCtrl"
  368. @input="($event) => textInput($event)"
  369. @blur="($event) => textInput($event)"
  370. v-html="originalStudentAnswer"
  371. ></div>
  372. <div
  373. style="
  374. margin-top: -25px;
  375. margin-bottom: 25px;
  376. width: 100%;
  377. max-width: 500px;
  378. "
  379. >
  380. <div style="float: right; margin-right: 10px">
  381. {{ answerWordCount }}
  382. </div>
  383. </div>
  384. </div>
  385. <div v-if="shouldFetchQrCode && isAudioAnswerType">
  386. <div>
  387. <div v-if="qrValue" style="display: flex">
  388. <VueQrcode
  389. :value="qrValue"
  390. :options="{ width: 200 }"
  391. style="margin-left: -10px"
  392. />
  393. <div style="margin-top: 10px">
  394. <div>
  395. 请使用<span style="font-weight: 900; color: #1e90ff">微信</span
  396. >扫描二维码后,在微信小程序上{{
  397. isAudioAnswerType ? "录音" : "拍照"
  398. }},并上传文件。
  399. </div>
  400. <div v-if="qrScanned" style="margin-top: 30px; font-size: 30px">
  401. {{ examQuestion.studentAnswer ? "已上传" : "已扫描" }}
  402. <n-icon :componet="Checkmark" />
  403. </div>
  404. </div>
  405. </div>
  406. <div v-else>正在获取二维码...</div>
  407. </div>
  408. <div
  409. class="audio-answer audio-answer-line-height"
  410. style="margin-top: 20px"
  411. >
  412. <span class="audio-answer-line-height">答案:</span>
  413. <audio
  414. v-if="examQuestion.studentAnswer"
  415. class="audio-answer-line-height"
  416. controls
  417. controlsList="nodownload"
  418. :src="examQuestion.studentAnswer"
  419. />
  420. <span v-else class="audio-answer-line-height">未上传文件</span>
  421. </div>
  422. </div>
  423. <div v-if="canAttachPhotos" style="padding-top: 1px">
  424. <UploadPhotos
  425. v-model:uploadModalVisible="uploadModalVisible"
  426. :defaultList="photoAnswers.map((v) => v)"
  427. :qrValue="qrValue"
  428. style="margin-top: 20px; width: 350px"
  429. :pictureAnswer="pictureAnswer"
  430. @onPhotoRemoved="photoRemoved"
  431. @onPhotosReseted="photosReseted"
  432. />
  433. </div>
  434. <div class="reset" style="padding-top: 20px">
  435. <span v-if="store.examShouldShowAnswer">
  436. <n-button type="success" @click="toggleShowAnswer">
  437. 显示答案
  438. </n-button>
  439. </span>
  440. <div v-if="isShowAnswer">
  441. 正确答案:
  442. <div class="right-answer-section" v-html="rightAnswerTransform"></div>
  443. </div>
  444. </div>
  445. </div>
  446. </div>
  447. </template>
  448. <style scoped>
  449. .question-view {
  450. display: grid;
  451. grid-row-gap: 10px;
  452. }
  453. .question-body {
  454. font-size: 18px;
  455. /* margin-bottom: 10px; */
  456. }
  457. .ops {
  458. display: flex;
  459. align-items: flex-end;
  460. }
  461. .text-ops {
  462. margin: 0 5px 5px 0;
  463. }
  464. .stu-answer {
  465. width: 100%;
  466. max-width: 500px;
  467. min-height: 300px;
  468. border: 1px solid grey;
  469. padding: 2px;
  470. }
  471. .audio-answer {
  472. font-size: 24px;
  473. line-height: 54px;
  474. }
  475. .audio-answer-line-height {
  476. line-height: 54px;
  477. vertical-align: text-bottom;
  478. }
  479. </style>
  480. <style>
  481. .photo-answers-block {
  482. display: none;
  483. }
  484. </style>