ObjectiveAnswer.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. <template>
  2. <div class="mark check-paper">
  3. <div class="mark-header">
  4. <div class="mark-header-part">
  5. <template v-if="student">
  6. <div class="header-noun">
  7. <span>课程名称:</span>
  8. <span> {{ student.courseName }}({{ student.courseCode }})</span>
  9. </div>
  10. <div class="header-noun">
  11. <span>试卷编号:</span>
  12. <span>{{ student.paperNumber }}</span>
  13. </div>
  14. <div class="header-noun">
  15. <span>姓名:</span>
  16. <span>{{ student.studentName }}</span>
  17. </div>
  18. <div class="header-noun">
  19. <span>学号:</span>
  20. <span>{{ student?.studentCode }}</span>
  21. </div>
  22. <div v-if="studentIds.length > 1" class="header-noun">
  23. <span>进度:</span>
  24. <span> {{ currentIndex + 1 }}/{{ studentIds.length }} </span>
  25. </div>
  26. </template>
  27. </div>
  28. <div class="mark-header-part">
  29. <div class="paper-menu">
  30. <span
  31. v-for="(u, index) in student?.sheetUrls"
  32. :key="index"
  33. :class="{ 'is-active': currentImage === index }"
  34. @click="currentImage = index"
  35. >
  36. {{ index + 1 }}
  37. </span>
  38. </div>
  39. <div class="header-text-btn header-logout" @click="logout">
  40. <img class="header-icon" src="@/assets/icons/icon-return.svg" />返回
  41. </div>
  42. </div>
  43. </div>
  44. <div class="mark-main">
  45. <div class="mark-body">
  46. <div
  47. v-if="student && currentImage !== 0"
  48. class="page-action page-prev"
  49. title="上一张"
  50. @click="switchImageArrow({ left: true })"
  51. >
  52. <ArrowLeftOutlined />
  53. </div>
  54. <div
  55. v-if="student && currentImage !== student.sheetUrls.length - 1"
  56. class="page-action page-next"
  57. title="上一张"
  58. @click="switchImageArrow({ right: true })"
  59. >
  60. <ArrowRightOutlined />
  61. </div>
  62. <div class="mark-body-container">
  63. <div v-if="!student" class="mark-body-none">
  64. <div>
  65. <img src="@/assets/image-none-task.png" />
  66. <p>暂无数据</p>
  67. </div>
  68. </div>
  69. <div
  70. v-else
  71. class="single-image-container"
  72. :style="{ width: answerPaperScale, fontSize: answerPaperFontSize }"
  73. >
  74. <img
  75. id="mark-body-paper"
  76. draggable="false"
  77. :src="curImageUrl"
  78. :style="{
  79. transform:
  80. (rotateDegree ? 'translate( 0, calc(30vh))' : '') +
  81. `rotate(${rotateDegree}deg)`,
  82. }"
  83. @click="switchImage"
  84. @contextmenu="showBigImage"
  85. @load="paperLoad"
  86. />
  87. <div
  88. v-for="(tag, tindex) in answerTags"
  89. :key="tindex"
  90. :style="tag.style"
  91. >
  92. {{ tag.answer }}
  93. </div>
  94. <div
  95. v-for="(tag, tindex) in optionsBlocks"
  96. :key="tindex + 'block'"
  97. :style="tag.style"
  98. ></div>
  99. </div>
  100. </div>
  101. <ZoomPaper v-if="student" showRotate fixed @rotateRight="rotateRight" />
  102. </div>
  103. <div class="mark-board-track">
  104. <div class="board-header no-action">
  105. <div class="board-header-info">
  106. <img src="@/assets/icons/icon-star.svg" />
  107. <span>总分</span>
  108. </div>
  109. <div class="board-header-score">
  110. <transition-group name="score-number-animation" tag="span">
  111. <span :key="totalScore">{{ totalScore }}</span>
  112. </transition-group>
  113. </div>
  114. </div>
  115. <div class="paper-topics">
  116. <div
  117. v-for="group in answersComputed"
  118. :key="group.mainNumber"
  119. class="paper-topic"
  120. >
  121. <h2 class="paper-topic-title">
  122. {{ group.mainNumber }}、{{ group.mainTitle }} ({{
  123. group.subs.length
  124. }})
  125. </h2>
  126. <div class="paper-topic-body">
  127. <div
  128. v-for="question in group.subs"
  129. :key="question.subNumber"
  130. class="paper-topic-question"
  131. >
  132. <span class="question-number">{{ question.subNumber }} </span>
  133. <a-input
  134. class="normal-input"
  135. :class="{
  136. 'long-input': question.type
  137. ? !['SINGLE', 'TRUE_OR_FALSE'].includes(question.type)
  138. : !group.mainTitle.match(/单选|单项|判断/),
  139. }"
  140. :value="question.answer"
  141. :maxLength="
  142. (
  143. question.type
  144. ? ['MULTIPLE'].includes(question.type)
  145. : group.mainTitle.match(/多选|多项|不定项/)
  146. )
  147. ? 100
  148. : 1
  149. "
  150. @keydown="onPreventAnswerKey"
  151. @input="changeAnswer($event, question)"
  152. @blur="changeAnswer($event, question, '#')"
  153. />
  154. </div>
  155. </div>
  156. </div>
  157. </div>
  158. <div :class="['board-footer', { 'is-simple': !isMultiStudent }]">
  159. <qm-button
  160. class="board-submit"
  161. size="medium"
  162. type="primary"
  163. :disabled="!student?.upload"
  164. @click="saveStudentAnswer"
  165. >
  166. 保存
  167. </qm-button>
  168. <div v-if="isMultiStudent" class="student-switch">
  169. <a-button :disabled="isFirst" @click="getPreviousStudent">
  170. 上一份
  171. </a-button>
  172. <a-button :disabled="isLast" @click="getNextStudent">
  173. 下一份
  174. </a-button>
  175. </div>
  176. </div>
  177. </div>
  178. </div>
  179. </div>
  180. </template>
  181. <script lang="ts" setup>
  182. import { message } from "ant-design-vue";
  183. import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
  184. import { onMounted, watch } from "vue";
  185. import "viewerjs/dist/viewer.css";
  186. import Viewer from "viewerjs";
  187. import { StudentObjectiveInfo, PaperRecogData, AnswerTagItem } from "@/types";
  188. import {
  189. studentObjectiveConfirmData,
  190. saveStudentObjectiveConfirmData,
  191. } from "@/api/checkPage";
  192. import { doLogout } from "@/api/markPage";
  193. import { useMarkStore } from "@/store";
  194. import { useTimers } from "@/setups/useTimers";
  195. import vls from "@/utils/storage";
  196. import { maxNum } from "@/utils/utils";
  197. import ZoomPaper from "@/components/ZoomPaper.vue";
  198. const { addTimeout } = useTimers();
  199. const markStore = useMarkStore();
  200. const studentIds = $ref(vls.get("check-students", []));
  201. onMounted(async () => {
  202. if (studentIds.length === 0) {
  203. void message.info("没有需要处理的考生,请返回。");
  204. return;
  205. }
  206. await getNextStudent();
  207. });
  208. let currentStudentId = $ref("");
  209. const currentIndex = $computed(() => studentIds.indexOf(currentStudentId));
  210. const isFirst = $computed(() => currentIndex === 0);
  211. const isLast = $computed(() => currentIndex === studentIds.length - 1);
  212. const isMultiStudent = $computed(() => studentIds.length > 1);
  213. const totalScore = $computed(() => {
  214. if (!student) return 0;
  215. return student.objectiveScore || 0;
  216. });
  217. const curImageUrl = $computed(() =>
  218. student ? student.sheetUrls[currentImage]?.url : ""
  219. );
  220. let student: StudentObjectiveInfo | null = $ref(null);
  221. /** 后台数据错误,停止整个页面的流程 */
  222. let dataError = $ref(false);
  223. let answerMap: Record<string, { answer: string; isRight: boolean }> = {};
  224. let answerTags = $ref<AnswerTagItem[]>([]);
  225. let optionsBlocks = $ref([]);
  226. const answersComputed = $computed(() => {
  227. let mains = student?.answers.map((v) => ({
  228. mainTitle: "",
  229. mainNumber: v.mainNumber,
  230. subs: [v],
  231. }));
  232. const mSet = new Set();
  233. mains = mains?.filter((v) => {
  234. if (!mSet.has(v.mainNumber)) {
  235. mSet.add(v.mainNumber);
  236. v.subs = [];
  237. return true;
  238. }
  239. });
  240. mains?.forEach((v) => {
  241. v.mainTitle = student?.titles[v.mainNumber] ?? "";
  242. v.subs =
  243. student?.answers.filter((v2) => v2.mainNumber === v.mainNumber) ?? [];
  244. });
  245. return mains;
  246. });
  247. function logout() {
  248. doLogout();
  249. }
  250. async function getNextStudent() {
  251. if (isLast) {
  252. void message.warning("已经是最后一份!");
  253. return;
  254. }
  255. student = await getStudent(studentIds[currentIndex + 1]);
  256. }
  257. async function getPreviousStudent() {
  258. if (isFirst) {
  259. void message.warning("已经是第一份!");
  260. return;
  261. }
  262. student = await getStudent(studentIds[currentIndex - 1]);
  263. }
  264. async function getStudent(studentId: string) {
  265. const res = await studentObjectiveConfirmData(studentId).catch(() => {
  266. dataError = true;
  267. });
  268. if (dataError) {
  269. void message.error(res.message, 24 * 60 * 60);
  270. throw new Error("取学生信息出错: " + res.message);
  271. }
  272. const stu = res.data as StudentObjectiveInfo;
  273. // stu.sheetUrls = [
  274. // { index: 1, url: "/1-1.jpg" },
  275. // { index: 2, url: "/1-2.jpg" },
  276. // ];
  277. currentStudentId = stu.studentId;
  278. currentImage = 0;
  279. browsedImageIndexes = [0];
  280. answerMap = {};
  281. stu.answers.forEach((item) => {
  282. answerMap[`${item.mainNumber}_${item.subNumber}`] = {
  283. answer: item.answer,
  284. isRight: item.answer === item.standardAnswer,
  285. };
  286. });
  287. return stu;
  288. }
  289. const allowKey = [
  290. "Delete",
  291. "Backspace",
  292. "ArrowLeft",
  293. "ArrowRight",
  294. "#",
  295. "Shift",
  296. "[A-Za-z]",
  297. ];
  298. const allowKeyRef = new RegExp(allowKey.join("|"));
  299. function onPreventAnswerKey(e: KeyboardEvent) {
  300. console.log(e);
  301. if (!allowKeyRef.test(e.key)) {
  302. e.preventDefault();
  303. }
  304. }
  305. function changeAnswer(event: Event, question: string, defaultValue?: string) {
  306. const target = event.target as HTMLInputElement;
  307. student.answers = student.answers.map((v) => {
  308. if (
  309. v.mainNumber === question.mainNumber &&
  310. v.subNumber === question.subNumber
  311. ) {
  312. v.answer = target?.value.toUpperCase().trim() || defaultValue || "";
  313. }
  314. return v;
  315. });
  316. }
  317. let loading = false;
  318. async function saveStudentAnswer() {
  319. if (!student) return;
  320. if (loading) return;
  321. loading = true;
  322. const data = {
  323. studentId: student.studentId,
  324. answers: student.answers.map((v) => v.answer || "#").join(","),
  325. };
  326. // if (!answers.match(/^(#*,*[A-Z]*)+$/g)) {
  327. // void message.error("答案只能是#和大写英文字母");
  328. // return;
  329. // }
  330. const res = await saveStudentObjectiveConfirmData(data).catch(() => false);
  331. loading = false;
  332. if (!res) {
  333. void message.error("保存失败,请刷新页面。");
  334. } else {
  335. void message.success("保存成功");
  336. if (!isMultiStudent) {
  337. window.close();
  338. return;
  339. }
  340. if (isLast) {
  341. student = await getStudent(studentIds[currentIndex]);
  342. } else {
  343. await getNextStudent();
  344. }
  345. }
  346. }
  347. function paperLoad() {
  348. if (!student.sheetUrls[currentImage]?.recogData) {
  349. answerTags = [];
  350. optionsBlocks = [];
  351. return;
  352. }
  353. const imgDom = document.getElementById("mark-body-paper");
  354. const { naturalWidth, naturalHeight } = imgDom;
  355. const recogData: PaperRecogData = JSON.parse(
  356. window.atob(student.sheetUrls[currentImage].recogData)
  357. );
  358. answerTags = [];
  359. optionsBlocks = [];
  360. recogData.question.forEach((question) => {
  361. question.fill_result.forEach((result) => {
  362. const tagSize = result.fill_size[1];
  363. const fillPositions = result.fill_position.map((pos) => {
  364. return pos.split(",").map((n) => n * 1);
  365. });
  366. const offsetLt = result.fill_size.map((item) => item * 0.4);
  367. const tagLeft =
  368. maxNum(fillPositions.map((pos) => pos[0])) +
  369. result.fill_size[0] -
  370. offsetLt[0];
  371. const tagTop = fillPositions[0][1] - offsetLt[1];
  372. const { answer, isRight } =
  373. answerMap[`${result.main_number}_${result.sub_number}`] || {};
  374. answerTags.push({
  375. mainNumber: result.main_number,
  376. subNumber: result.sub_number,
  377. answer,
  378. style: {
  379. height: ((100 * tagSize) / naturalHeight).toFixed(4) + "%",
  380. fontSize: ((100 * 20) / tagSize).toFixed(4) + "%",
  381. left: ((100 * tagLeft) / naturalWidth).toFixed(4) + "%",
  382. top: ((100 * tagTop) / naturalHeight).toFixed(4) + "%",
  383. position: "absolute",
  384. color: isRight ? "#05b575" : "#f53f3f",
  385. fontWeight: 600,
  386. lineHeight: 1,
  387. zIndex: 9,
  388. },
  389. });
  390. // 测试:选项框
  391. // fillPositions.forEach((fp, index) => {
  392. // optionsBlocks.push({
  393. // mainNumber: result.main_number,
  394. // subNumber: result.sub_number,
  395. // filled: !!result.fill_option[index],
  396. // style: {
  397. // width:
  398. // ((100 * result.fill_size[0]) / naturalWidth).toFixed(4) + "%",
  399. // height:
  400. // ((100 * result.fill_size[1]) / naturalHeight).toFixed(4) + "%",
  401. // left:
  402. // ((100 * (fp[0] - offsetLt[0])) / naturalWidth).toFixed(4) + "%",
  403. // top:
  404. // ((100 * (fp[1] - offsetLt[1])) / naturalHeight).toFixed(4) + "%",
  405. // position: "absolute",
  406. // border: "1px solid #f53f3f",
  407. // background: result.fill_option[index]
  408. // ? "rgba(245, 63, 63, 0.5)"
  409. // : "transparent",
  410. // zIndex: 9,
  411. // },
  412. // });
  413. // });
  414. });
  415. });
  416. }
  417. //#region : 显示大图,供查看和翻转
  418. let currentImage = $ref(0);
  419. let browsedImageIndexes = $ref([0]);
  420. // let allViewed = $computed(() => {
  421. // let indexes = Array.from(new Set(browsedImageIndexes));
  422. // return indexes.length == (student?.sheetUrls || []).length;
  423. // });
  424. watch(
  425. () => currentImage,
  426. () => {
  427. browsedImageIndexes.push(currentImage);
  428. }
  429. );
  430. function switchImageArrow({
  431. left = false,
  432. right = false,
  433. }: {
  434. left?: boolean;
  435. right?: boolean;
  436. }) {
  437. if (left) {
  438. if (currentImage > 0) {
  439. currentImage--;
  440. }
  441. }
  442. if (right) {
  443. if (currentImage < student.sheetUrls.length - 1) {
  444. currentImage++;
  445. }
  446. }
  447. }
  448. function switchImage(event: MouseEvent) {
  449. const image = event.target as HTMLImageElement;
  450. const layerX: number = (event as any).layerX;
  451. if (layerX * 2 < image.width) {
  452. if (currentImage > 0) {
  453. currentImage--;
  454. }
  455. } else {
  456. if (currentImage < student.sheetUrls.length - 1) {
  457. currentImage++;
  458. }
  459. }
  460. }
  461. const showBigImage = (event: MouseEvent) => {
  462. event.preventDefault();
  463. // console.log(event);
  464. let viewer: Viewer = null as unknown as Viewer;
  465. viewer && viewer.destroy();
  466. viewer = new Viewer((event.target as HTMLElement).parentElement, {
  467. // inline: true,
  468. viewed() {
  469. viewer.zoomTo(1);
  470. },
  471. hidden() {
  472. viewer.destroy();
  473. },
  474. zIndex: 1000000,
  475. });
  476. viewer.show();
  477. };
  478. //#endregion : 显示大图,供查看和翻转
  479. //#region : 放大缩小和之后的滚动
  480. const answerPaperScale = $computed(() => {
  481. // 放大、缩小不影响页面之前的滚动条定位
  482. let percentWidth = 0;
  483. let percentTop = 0;
  484. const container = document.querySelector<HTMLDivElement>(
  485. ".mark-body-container"
  486. );
  487. if (container) {
  488. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  489. percentWidth = scrollLeft / scrollWidth;
  490. percentTop = scrollTop / scrollHeight;
  491. }
  492. addTimeout(() => {
  493. if (container) {
  494. const { scrollWidth, scrollHeight } = container;
  495. container.scrollTo({
  496. left: scrollWidth * percentWidth,
  497. top: scrollHeight * percentTop,
  498. });
  499. }
  500. }, 10);
  501. const scale = markStore.setting.uiSetting["answer.paper.scale"];
  502. return scale * 100 + "%";
  503. });
  504. const answerPaperFontSize = $computed(() => {
  505. const scale = markStore.setting.uiSetting["answer.paper.scale"];
  506. return scale * 14 + "px";
  507. });
  508. //#endregion : 放大缩小和之后的滚动
  509. //#region rotateRight
  510. let rotateDegree = $ref(0);
  511. function rotateRight() {
  512. rotateDegree = (rotateDegree + 90) % 360;
  513. }
  514. //#endregion
  515. </script>