ObjectiveAnswer.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  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 {
  183. studentObjectiveConfirmData,
  184. saveStudentObjectiveConfirmData,
  185. } from "@/api/checkPage";
  186. import { message } from "ant-design-vue";
  187. import { onMounted, watch } from "vue";
  188. import "viewerjs/dist/viewer.css";
  189. import Viewer from "viewerjs";
  190. import { store } from "@/store/store";
  191. import ZoomPaper from "@/components/ZoomPaper.vue";
  192. import { useTimers } from "@/setups/useTimers";
  193. import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
  194. import vls from "@/utils/storage";
  195. import { StudentObjectiveInfo, PaperRecogData } from "@/types";
  196. import { doLogout } from "@/api/markPage";
  197. import { maxNum } from "@/utils/utils";
  198. const { addTimeout } = useTimers();
  199. const studentIds = $ref(vls.get("check-students", []));
  200. onMounted(async () => {
  201. if (studentIds.length === 0) {
  202. void message.info("没有需要处理的考生,请返回。");
  203. return;
  204. }
  205. await getNextStudent();
  206. });
  207. let currentStudentId = $ref("");
  208. const currentIndex = $computed(() => studentIds.indexOf(currentStudentId));
  209. const isFirst = $computed(() => currentIndex === 0);
  210. const isLast = $computed(() => currentIndex === studentIds.length - 1);
  211. const isMultiStudent = $computed(() => studentIds.length > 1);
  212. const totalScore = $computed(() => {
  213. if (!student) return 0;
  214. return student.objectiveScore || 0;
  215. });
  216. const curImageUrl = $computed(() =>
  217. student ? student.sheetUrls[currentImage]?.url : ""
  218. );
  219. let student: StudentObjectiveInfo | null = $ref(null);
  220. /** 后台数据错误,停止整个页面的流程 */
  221. let dataError = $ref(false);
  222. let answerMap: Record<string, { answer: string; isRight: boolean }> = {};
  223. interface AnswerTagType {
  224. mainNumber: number;
  225. subNumber: number;
  226. answer: string;
  227. style: Record<string, string>;
  228. }
  229. let answerTags = $ref<AnswerTagType[]>([]);
  230. let optionsBlocks = $ref([]);
  231. const answersComputed = $computed(() => {
  232. let mains = student?.answers.map((v) => ({
  233. mainTitle: "",
  234. mainNumber: v.mainNumber,
  235. subs: [v],
  236. }));
  237. const mSet = new Set();
  238. mains = mains?.filter((v) => {
  239. if (!mSet.has(v.mainNumber)) {
  240. mSet.add(v.mainNumber);
  241. v.subs = [];
  242. return true;
  243. }
  244. });
  245. mains?.forEach((v) => {
  246. v.mainTitle = student?.titles[v.mainNumber] ?? "";
  247. v.subs =
  248. student?.answers.filter((v2) => v2.mainNumber === v.mainNumber) ?? [];
  249. });
  250. return mains;
  251. });
  252. function logout() {
  253. doLogout();
  254. }
  255. async function getNextStudent() {
  256. if (isLast) {
  257. void message.warning("已经是最后一份!");
  258. return;
  259. }
  260. student = await getStudent(studentIds[currentIndex + 1]);
  261. }
  262. async function getPreviousStudent() {
  263. if (isFirst) {
  264. void message.warning("已经是第一份!");
  265. return;
  266. }
  267. student = await getStudent(studentIds[currentIndex - 1]);
  268. }
  269. async function getStudent(studentId: string) {
  270. const res = await studentObjectiveConfirmData(studentId).catch(() => {
  271. dataError = true;
  272. });
  273. if (dataError) {
  274. void message.error(res.message, 24 * 60 * 60);
  275. throw new Error("取学生信息出错: " + res.message);
  276. }
  277. const stu = res.data as StudentObjectiveInfo;
  278. // stu.sheetUrls = [
  279. // { index: 1, url: "/1-1.jpg" },
  280. // { index: 2, url: "/1-2.jpg" },
  281. // ];
  282. currentStudentId = stu.studentId;
  283. currentImage = 0;
  284. browsedImageIndexes = [0];
  285. answerMap = {};
  286. stu.answers.forEach((item) => {
  287. answerMap[`${item.mainNumber}_${item.subNumber}`] = {
  288. answer: item.answer,
  289. isRight: item.answer === item.standardAnswer,
  290. };
  291. });
  292. return stu;
  293. }
  294. const allowKey = [
  295. "Delete",
  296. "Backspace",
  297. "ArrowLeft",
  298. "ArrowRight",
  299. "#",
  300. "Shift",
  301. "[A-Za-z]",
  302. ];
  303. const allowKeyRef = new RegExp(allowKey.join("|"));
  304. function onPreventAnswerKey(e: KeyboardEvent) {
  305. console.log(e);
  306. if (!allowKeyRef.test(e.key)) {
  307. e.preventDefault();
  308. }
  309. }
  310. function changeAnswer(event: Event, question: string, defaultValue?: string) {
  311. const target = event.target as HTMLInputElement;
  312. student.answers = student.answers.map((v) => {
  313. if (
  314. v.mainNumber === question.mainNumber &&
  315. v.subNumber === question.subNumber
  316. ) {
  317. v.answer = target?.value.toUpperCase().trim() || defaultValue || "";
  318. }
  319. return v;
  320. });
  321. }
  322. let loading = false;
  323. async function saveStudentAnswer() {
  324. if (!student) return;
  325. if (loading) return;
  326. loading = true;
  327. const data = {
  328. studentId: student.studentId,
  329. answers: student.answers.map((v) => v.answer || "#").join(","),
  330. };
  331. // if (!answers.match(/^(#*,*[A-Z]*)+$/g)) {
  332. // void message.error("答案只能是#和大写英文字母");
  333. // return;
  334. // }
  335. const res = await saveStudentObjectiveConfirmData(data).catch(() => false);
  336. loading = false;
  337. if (!res) {
  338. void message.error("保存失败,请刷新页面。");
  339. } else {
  340. void message.success("保存成功");
  341. if (!isMultiStudent) {
  342. window.close();
  343. return;
  344. }
  345. if (isLast) {
  346. student = await getStudent(studentIds[currentIndex]);
  347. } else {
  348. await getNextStudent();
  349. }
  350. }
  351. }
  352. function paperLoad() {
  353. if (!student.sheetUrls[currentImage]?.recogData) {
  354. answerTags = [];
  355. optionsBlocks = [];
  356. return;
  357. }
  358. const imgDom = document.getElementById("mark-body-paper");
  359. const { naturalWidth, naturalHeight } = imgDom;
  360. const recogData: PaperRecogData = JSON.parse(
  361. window.atob(student.sheetUrls[currentImage].recogData)
  362. );
  363. answerTags = [];
  364. optionsBlocks = [];
  365. recogData.question.forEach((question) => {
  366. question.fill_result.forEach((result) => {
  367. const tagSize = result.fill_size[1];
  368. const fillPositions = result.fill_position.map((pos) => {
  369. return pos.split(",").map((n) => n * 1);
  370. });
  371. const offsetLt = result.fill_size.map((item) => item * 0.4);
  372. const tagLeft =
  373. maxNum(fillPositions.map((pos) => pos[0])) +
  374. result.fill_size[0] -
  375. offsetLt[0];
  376. const tagTop = fillPositions[0][1] - offsetLt[1];
  377. const { answer, isRight } =
  378. answerMap[`${result.main_number}_${result.sub_number}`] || {};
  379. answerTags.push({
  380. mainNumber: result.main_number,
  381. subNumber: result.sub_number,
  382. answer,
  383. style: {
  384. height: ((100 * tagSize) / naturalHeight).toFixed(4) + "%",
  385. fontSize: ((100 * 20) / tagSize).toFixed(4) + "%",
  386. left: ((100 * tagLeft) / naturalWidth).toFixed(4) + "%",
  387. top: ((100 * tagTop) / naturalHeight).toFixed(4) + "%",
  388. position: "absolute",
  389. color: isRight ? "#05b575" : "#f53f3f",
  390. fontWeight: 600,
  391. lineHeight: 1,
  392. zIndex: 9,
  393. },
  394. });
  395. // 测试:选项框
  396. // fillPositions.forEach((fp, index) => {
  397. // optionsBlocks.push({
  398. // mainNumber: result.main_number,
  399. // subNumber: result.sub_number,
  400. // filled: !!result.fill_option[index],
  401. // style: {
  402. // width:
  403. // ((100 * result.fill_size[0]) / naturalWidth).toFixed(4) + "%",
  404. // height:
  405. // ((100 * result.fill_size[1]) / naturalHeight).toFixed(4) + "%",
  406. // left:
  407. // ((100 * (fp[0] - offsetLt[0])) / naturalWidth).toFixed(4) + "%",
  408. // top:
  409. // ((100 * (fp[1] - offsetLt[1])) / naturalHeight).toFixed(4) + "%",
  410. // position: "absolute",
  411. // border: "1px solid #f53f3f",
  412. // background: result.fill_option[index]
  413. // ? "rgba(245, 63, 63, 0.5)"
  414. // : "transparent",
  415. // zIndex: 9,
  416. // },
  417. // });
  418. // });
  419. });
  420. });
  421. }
  422. //#region : 显示大图,供查看和翻转
  423. let currentImage = $ref(0);
  424. let browsedImageIndexes = $ref([0]);
  425. // let allViewed = $computed(() => {
  426. // let indexes = Array.from(new Set(browsedImageIndexes));
  427. // return indexes.length == (student?.sheetUrls || []).length;
  428. // });
  429. watch(
  430. () => currentImage,
  431. () => {
  432. browsedImageIndexes.push(currentImage);
  433. }
  434. );
  435. function switchImageArrow({
  436. left = false,
  437. right = false,
  438. }: {
  439. left?: boolean;
  440. right?: boolean;
  441. }) {
  442. if (left) {
  443. if (currentImage > 0) {
  444. currentImage--;
  445. }
  446. }
  447. if (right) {
  448. if (currentImage < student.sheetUrls.length - 1) {
  449. currentImage++;
  450. }
  451. }
  452. }
  453. function switchImage(event: MouseEvent) {
  454. const image = event.target as HTMLImageElement;
  455. const layerX: number = (event as any).layerX;
  456. if (layerX * 2 < image.width) {
  457. if (currentImage > 0) {
  458. currentImage--;
  459. }
  460. } else {
  461. if (currentImage < student.sheetUrls.length - 1) {
  462. currentImage++;
  463. }
  464. }
  465. }
  466. const showBigImage = (event: MouseEvent) => {
  467. event.preventDefault();
  468. // console.log(event);
  469. let viewer: Viewer = null as unknown as Viewer;
  470. viewer && viewer.destroy();
  471. viewer = new Viewer((event.target as HTMLElement).parentElement, {
  472. // inline: true,
  473. viewed() {
  474. viewer.zoomTo(1);
  475. },
  476. hidden() {
  477. viewer.destroy();
  478. },
  479. zIndex: 1000000,
  480. });
  481. viewer.show();
  482. };
  483. //#endregion : 显示大图,供查看和翻转
  484. //#region : 放大缩小和之后的滚动
  485. const answerPaperScale = $computed(() => {
  486. // 放大、缩小不影响页面之前的滚动条定位
  487. let percentWidth = 0;
  488. let percentTop = 0;
  489. const container = document.querySelector<HTMLDivElement>(
  490. ".mark-body-container"
  491. );
  492. if (container) {
  493. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  494. percentWidth = scrollLeft / scrollWidth;
  495. percentTop = scrollTop / scrollHeight;
  496. }
  497. addTimeout(() => {
  498. if (container) {
  499. const { scrollWidth, scrollHeight } = container;
  500. container.scrollTo({
  501. left: scrollWidth * percentWidth,
  502. top: scrollHeight * percentTop,
  503. });
  504. }
  505. }, 10);
  506. const scale = store.setting.uiSetting["answer.paper.scale"];
  507. return scale * 100 + "%";
  508. });
  509. const answerPaperFontSize = $computed(() => {
  510. const scale = store.setting.uiSetting["answer.paper.scale"];
  511. return scale * 14 + "px";
  512. });
  513. //#endregion : 放大缩小和之后的滚动
  514. //#region rotateRight
  515. let rotateDegree = $ref(0);
  516. function rotateRight() {
  517. rotateDegree = (rotateDegree + 90) % 360;
  518. }
  519. //#endregion
  520. </script>