TextQuestionView.vue 15 KB

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