ConfirmPaper.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. <template>
  2. <div v-if="!dataError" class="tw-h-screen confirm-paper">
  3. <header
  4. class="tw-flex tw-gap-2 tw-justify-between tw-items-center header-container"
  5. >
  6. <div class="tw-ml-2">
  7. 进度:<span class="highlight-text">
  8. {{ currentIndex }}/{{ allIds.length }}
  9. </span>
  10. </div>
  11. <div>
  12. 姓名:<span class="highlight-text">{{ student?.name }}</span>
  13. </div>
  14. <div>
  15. 准考证号:<span class="highlight-text">{{ student?.examNumber }}</span>
  16. </div>
  17. <div>
  18. 学号:<span class="highlight-text">{{ student?.studentCode }}</span>
  19. </div>
  20. <div>
  21. 科目:<span class="highlight-text">
  22. {{ student?.subjectCode }}-{{ student?.subjectName }}
  23. </span>
  24. </div>
  25. <div>
  26. 客观分:<span class="highlight-text">{{
  27. student?.objectiveScore
  28. }}</span>
  29. </div>
  30. <div>
  31. 主观分:<span class="highlight-text">{{
  32. student?.subjectiveScore
  33. }}</span>
  34. </div>
  35. <div class="tw-flex tw-items-center tw-gap-2 tw-mx-8">
  36. <span
  37. v-for="(u, index) in student?.sheetUrls"
  38. :key="index"
  39. class="tw-cursor-pointer"
  40. :class="currentImage === index && 'highlight-text'"
  41. @click="currentImage = index"
  42. >
  43. {{ index + 1 }}
  44. </span>
  45. </div>
  46. </header>
  47. <div class="tw-flex" style="height: calc(100% - 56px)">
  48. <div
  49. style="flex: 0 1 420px; overflow: auto"
  50. class="tw-flex tw-flex-col tw-justify-between"
  51. >
  52. <div class="tw-m-2 tw-flex-1 tw-overflow-auto">
  53. <div v-if="pageType === 'DATA_CHECK'" class="tw-my-2 top-block">
  54. 是否缺考:
  55. <a-radio-group v-if="student" v-model:value="student.absent">
  56. <a-radio :value="true">是</a-radio>
  57. <a-radio :value="false">否</a-radio>
  58. </a-radio-group>
  59. </div>
  60. <div v-if="pageType === 'DATA_CHECK'" class="tw-my-2 top-block">
  61. 试卷类型:
  62. <a-input
  63. v-if="student"
  64. v-model:value="student.paperType"
  65. class="normal-input"
  66. :maxlength="1"
  67. />
  68. </div>
  69. <div v-if="student?.answers" class="tw-mt-4">
  70. <div
  71. v-for="group in answersComputed"
  72. :key="group.mainNumber"
  73. class="tw-mt-2"
  74. :class="{ hide: !group.subs?.filter((s) => s.exist).length }"
  75. >
  76. <h2>
  77. {{ group.mainNumber }}、{{ group.mainTitle }} ({{
  78. group.subs.length
  79. }})
  80. </h2>
  81. <div class="tw-flex tw-flex-wrap tw-gap-2">
  82. <div
  83. v-for="question in group.subs"
  84. :key="question.subNumber"
  85. :class="{ hide: !question.exist }"
  86. >
  87. <span class="question-number">{{ question.subNumber }} </span>
  88. <a-input
  89. class="normal-input"
  90. :class="{
  91. 'long-input': group.mainTitle.match(/单选|多选|多项|不定项/),
  92. 'middle-input': group.mainTitle.match(/判断/),
  93. }"
  94. :value="question.answer"
  95. :maxLength="
  96. group.mainTitle.match(/多选|多项|不定项/) ? 100 : 1
  97. "
  98. @keydown="onPreventAnswerKey"
  99. @input="changeAnswer($event, question)"
  100. @blur="changeAnswer($event, question, '#')"
  101. />
  102. </div>
  103. </div>
  104. </div>
  105. </div>
  106. </div>
  107. <div class="tw-flex tw-justify-between tw-bg-white tw-p-4">
  108. <a-button
  109. :disabled="!student?.upload"
  110. type="primary"
  111. shape="round"
  112. @click="saveStudentAnswer"
  113. >
  114. 保存
  115. </a-button>
  116. <div>
  117. <a-button
  118. shape="round"
  119. :disabled="currentIndex <= 1"
  120. class="tw-mr-4"
  121. @click="getPreviousStudent"
  122. >
  123. 上一份
  124. </a-button>
  125. <a-button
  126. shape="round"
  127. :disabled="currentIndex === allIds.length"
  128. @click="getNextStudent"
  129. >
  130. 下一份
  131. </a-button>
  132. </div>
  133. </div>
  134. </div>
  135. <div style="flex: 1" class="mark-body-container tw-relative">
  136. <ArrowLeftOutlined
  137. v-if="student && currentImage !== 0"
  138. class="tw-cursor-pointer tw-absolute"
  139. style="top: 45%; left: 20px; z-index: 1; font-size: 40px"
  140. title="上一张"
  141. @click="switchImageArrow({ left: true })"
  142. />
  143. <ArrowRightOutlined
  144. v-if="student && currentImage !== student.sheetUrls.length - 1"
  145. class="tw-cursor-pointer tw-absolute"
  146. style="top: 45%; right: 20px; z-index: 1; font-size: 40px"
  147. title="上一张"
  148. @click="switchImageArrow({ right: true })"
  149. />
  150. <div :style="{ width: answerPaperScale }">
  151. <img
  152. v-for="(item, index) in student?.sheetUrls"
  153. :key="item"
  154. class="tw-object-cover"
  155. :src="item"
  156. :style="{
  157. display: index === currentImage ? 'block' : 'none',
  158. transform:
  159. (rotateDegree ? 'translate( 0, calc(30vh))' : '') +
  160. `rotate(${rotateDegree}deg)`,
  161. }"
  162. @click="switchImage"
  163. @contextmenu="showBigImage"
  164. />
  165. </div>
  166. <ZoomPaper v-if="student" fixed showRotate @rotateRight="rotateRight" />
  167. </div>
  168. </div>
  169. </div>
  170. </template>
  171. <script lang="ts" setup>
  172. import { httpApp } from "@/plugins/axiosApp";
  173. import { message } from "ant-design-vue";
  174. import { onMounted, reactive } from "vue";
  175. import { useRoute } from "vue-router";
  176. import { CheckSetting, StudentInfo } from "./check";
  177. import "viewerjs/dist/viewer.css";
  178. import Viewer from "viewerjs";
  179. import { store } from "@/store/store";
  180. import ZoomPaper from "@/components/ZoomPaper.vue";
  181. import { useTimers } from "@/setups/useTimers";
  182. import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
  183. const { addTimeout } = useTimers();
  184. const route = useRoute();
  185. // 使用 location.search 替代
  186. // const checkType = route.query.checkType;
  187. const queryId = route.query.queryId as string;
  188. let pageType: "DATA_CHECK" | "HAND_CHECK" = "HAND_CHECK";
  189. if (queryId) {
  190. pageType = "DATA_CHECK";
  191. sessionStorage.setItem(queryId, localStorage.getItem(queryId) || "[]");
  192. localStorage.removeItem(queryId);
  193. }
  194. onMounted(async () => {
  195. await getSetting();
  196. if (setting.studentIds.length === 0) {
  197. void message.info("没有需要处理的考生,请返回。");
  198. return;
  199. }
  200. await getNextStudent();
  201. });
  202. let setting: CheckSetting = reactive({
  203. fileServer: "",
  204. studentIds: [],
  205. studentIdsDone: [],
  206. });
  207. const allIds = $computed(() => [
  208. ...setting.studentIdsDone,
  209. ...setting.studentIds,
  210. ]);
  211. let currentStudentId = $ref(-1);
  212. const currentIndex = $computed(() => allIds.indexOf(currentStudentId) + 1);
  213. let student: StudentInfo | null = $ref(null);
  214. /** 后台数据错误,停止整个页面的流程 */
  215. let dataError = $ref(false);
  216. const answersComputed = $computed(() => {
  217. let mains = student?.answers.map((v) => ({
  218. mainTitle: "",
  219. mainNumber: v.mainNumber,
  220. subs: [v],
  221. }));
  222. const mSet = new Set();
  223. mains = mains?.filter((v) => {
  224. if (!mSet.has(v.mainNumber)) {
  225. mSet.add(v.mainNumber);
  226. v.subs = [];
  227. return true;
  228. }
  229. });
  230. mains?.forEach((v) => {
  231. v.mainTitle = student?.titles[v.mainNumber] ?? "";
  232. v.subs =
  233. student?.answers.filter((v2) => v2.mainNumber === v.mainNumber) ?? [];
  234. });
  235. // console.log(mains);
  236. return mains;
  237. });
  238. async function getSetting() {
  239. let res: any;
  240. if (pageType === "DATA_CHECK") {
  241. const query: Array<{ name: string; value: string }> = JSON.parse(
  242. sessionStorage.getItem(queryId) || "[]"
  243. );
  244. const form = new FormData();
  245. for (const v of query) {
  246. form.append(v.name, v.value + "");
  247. }
  248. res = await httpApp.post("/admin/exam/check/answer/getSetting", form);
  249. } else {
  250. const form = new FormData();
  251. form.append("checkType", route.query.checkType as string);
  252. form.append("subjectCode", route.query.subjectCode as string);
  253. form.append("examSite", route.query.examSite as string);
  254. res = await httpApp.post(`/admin/exam/check/student/getSetting`, form);
  255. }
  256. setting.fileServer = res.data.fileServer;
  257. setting.studentIds = res.data.studentIds || [];
  258. }
  259. async function getNextStudent() {
  260. const wantedIndex = allIds.indexOf(currentStudentId);
  261. if (allIds[wantedIndex + 1]) {
  262. student = await getStudent(allIds[wantedIndex + 1]);
  263. }
  264. }
  265. async function getPreviousStudent() {
  266. const wantedIndex = allIds.indexOf(currentStudentId);
  267. student = await getStudent(allIds[wantedIndex - 1]);
  268. }
  269. async function getStudent(studentId: number) {
  270. const stu: StudentInfo = await (
  271. await httpApp.get(`/admin/exam/check/answer/info?studentId=${studentId}`)
  272. ).data;
  273. stu?.sheetUrls.forEach((v, i, a) => (a[i] = setting.fileServer + v));
  274. currentStudentId = stu.id;
  275. currentImage = 0;
  276. if (!stu.success) {
  277. void message.error(stu.message, 24 * 60 * 60);
  278. dataError = true;
  279. throw new Error("取学生信息出错: " + stu.message);
  280. }
  281. // for dev
  282. // stu.answers = [
  283. // { mainNumber: 1, subNumber: "1", answer: "A" },
  284. // { mainNumber: 1, subNumber: "2", answer: "B" },
  285. // { mainNumber: 2, subNumber: "1", answer: "#" },
  286. // ];
  287. // stu.titles = { 1: "单选题", 2: "多选题" };
  288. return stu;
  289. }
  290. const allowKey = [
  291. "Delete",
  292. "Backspace",
  293. "ArrowLeft",
  294. "ArrowRight",
  295. "#",
  296. "Shift",
  297. "[A-Za-z]",
  298. ];
  299. const allowKeyRef = new RegExp(allowKey.join("|"));
  300. function onPreventAnswerKey(e: KeyboardEvent) {
  301. console.log(e)
  302. if (!allowKeyRef.test(e.key)) {
  303. e.preventDefault();
  304. }
  305. }
  306. function changeAnswer(
  307. event: Event,
  308. question: StudentInfo["answers"][0],
  309. defaultValue?: string
  310. ) {
  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. async function saveStudentAnswer() {
  323. if (!student) return;
  324. const form = new FormData();
  325. form.append("studentId", student.id + "");
  326. const answers = student.answers.map((v) => v.answer || "#").join(",");
  327. // if (!answers.match(/^(#*,*[A-Z]*)+$/g)) {
  328. // void message.error("答案只能是#和大写英文字母");
  329. // return;
  330. // }
  331. form.append("answers", answers);
  332. const extra = pageType === "DATA_CHECK";
  333. extra && form.append("absent", student.absent + "");
  334. extra && form.append("paperType", student.paperType);
  335. if (extra) {
  336. if (!student.paperType.match(/^#|[A-Z]$/)) {
  337. void message.error("试卷类型只能是#和大写英文字母");
  338. return;
  339. }
  340. }
  341. const url = extra
  342. ? // 数据检查
  343. "/admin/exam/check/answer/save"
  344. : `/admin/exam/check/student/save`;
  345. const res = await httpApp
  346. .post(url, form)
  347. .catch(() => message.error("保存失败-接口调用失败"));
  348. if (!res.data) {
  349. void message.error("保存失败,请刷新页面。");
  350. } else {
  351. void message.success("保存成功");
  352. await getNextStudent();
  353. }
  354. if (setting.studentIds.length === 0) {
  355. void message.success("所有考生已处理完毕。");
  356. }
  357. }
  358. //#region : 显示大图,供查看和翻转
  359. let currentImage = $ref(0);
  360. function switchImageArrow({
  361. left = false,
  362. right = false,
  363. }: {
  364. left?: boolean;
  365. right?: boolean;
  366. }) {
  367. if (left) {
  368. if (currentImage > 0) {
  369. currentImage--;
  370. }
  371. }
  372. if (right) {
  373. if (currentImage < student!.sheetUrls.length - 1) {
  374. currentImage++;
  375. }
  376. }
  377. }
  378. function switchImage(event: MouseEvent) {
  379. const image = event.target as HTMLImageElement;
  380. const layerX: number = (event as any).layerX;
  381. if (layerX * 2 < image.width) {
  382. if (currentImage > 0) {
  383. currentImage--;
  384. }
  385. } else {
  386. if (currentImage < student!.sheetUrls.length - 1) {
  387. currentImage++;
  388. }
  389. }
  390. }
  391. const showBigImage = (event: MouseEvent) => {
  392. event.preventDefault();
  393. // console.log(event);
  394. let viewer: Viewer = null as unknown as Viewer;
  395. viewer && viewer.destroy();
  396. viewer = new Viewer((event.target as HTMLElement).parentElement!, {
  397. // inline: true,
  398. viewed() {
  399. viewer.zoomTo(1);
  400. },
  401. hidden() {
  402. viewer.destroy();
  403. },
  404. zIndex: 1000000,
  405. });
  406. viewer.show();
  407. };
  408. //#endregion : 显示大图,供查看和翻转
  409. //#region : 放大缩小和之后的滚动
  410. const answerPaperScale = $computed(() => {
  411. // 放大、缩小不影响页面之前的滚动条定位
  412. let percentWidth = 0;
  413. let percentTop = 0;
  414. const container = document.querySelector<HTMLDivElement>(
  415. ".mark-body-container"
  416. );
  417. if (container) {
  418. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  419. percentWidth = scrollLeft / scrollWidth;
  420. percentTop = scrollTop / scrollHeight;
  421. }
  422. addTimeout(() => {
  423. if (container) {
  424. const { scrollWidth, scrollHeight } = container;
  425. container.scrollTo({
  426. left: scrollWidth * percentWidth,
  427. top: scrollHeight * percentTop,
  428. });
  429. }
  430. }, 10);
  431. const scale = store.setting.uiSetting["answer.paper.scale"];
  432. return scale * 100 + "%";
  433. });
  434. //#endregion : 放大缩小和之后的滚动
  435. //#region rotateRight
  436. let rotateDegree = $ref(0);
  437. function rotateRight() {
  438. rotateDegree = (rotateDegree + 90) % 360;
  439. }
  440. //#endregion
  441. </script>
  442. <style scoped>
  443. .question-number {
  444. display: inline-block;
  445. width: 22px;
  446. text-align: right;
  447. font-size: 12px;
  448. font-weight: normal;
  449. color: #474966;
  450. line-height: 14px;
  451. margin-right: 0.5em;
  452. }
  453. .normal-input {
  454. width: 36px;
  455. height: 32px;
  456. background: #edf2fa;
  457. border-radius: 6px;
  458. border: 1px solid #c8ced8;
  459. font-size: 14px;
  460. font-weight: normal;
  461. color: #474966;
  462. text-align: center;
  463. }
  464. .normal-input.long-input {
  465. width: 94px;
  466. padding: 4px;
  467. }
  468. .normal-input.middle-input{
  469. width: 61px !important;
  470. padding: 4px;
  471. }
  472. .header-container {
  473. position: relative;
  474. height: 56px;
  475. line-height: 16px;
  476. background-color: var(--header-bg-color);
  477. color: rgba(255, 255, 255, 0.5);
  478. }
  479. .highlight-text {
  480. color: white;
  481. font-size: var(--app-title-font-size);
  482. }
  483. .mark-body-container {
  484. position: relative;
  485. min-height: calc(100vh - 56px);
  486. height: calc(100vh - 56px);
  487. overflow: auto;
  488. /* background-size: 8px 8px;
  489. background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
  490. linear-gradient(to bottom, transparent 4px, #e7e7e7 4px); */
  491. background-color: var(--app-container-bg-color);
  492. background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
  493. linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
  494. linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
  495. linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
  496. background-size: 20px 20px;
  497. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  498. transform: inherit;
  499. }
  500. .hide {
  501. display: none;
  502. visibility: hidden;
  503. opacity: 0;
  504. }
  505. .confirm-paper .top-block{
  506. background-color:#fff;
  507. height:50px;
  508. padding:0 20px;
  509. display:flex;
  510. align-items:center;
  511. border-radius:6px;
  512. }
  513. </style>