ConfirmPaper.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. <template>
  2. <div v-if="!dataError" class="tw-h-screen">
  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"
  50. class="tw-flex tw-flex-col tw-justify-between"
  51. >
  52. <div class="tw-m-2">
  53. <div v-if="pageType === 'DATA_CHECK'">
  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">
  61. 试卷类型:
  62. <a-input
  63. v-if="student"
  64. v-model:value="student.paperType"
  65. :maxlength="1"
  66. style="width: 40px"
  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. >
  75. <h2>
  76. {{ group.mainNumber }}、{{ group.mainTitle }} ({{
  77. group.subs.length
  78. }})
  79. </h2>
  80. <div class="tw-flex tw-gap-4">
  81. <div v-for="question in group.subs" :key="question.subNumber">
  82. <span>{{ question.subNumber }}. </span>
  83. <a-input
  84. :value="question.answer"
  85. style="width: 40px"
  86. :maxLength="
  87. group.mainTitle.match(/多选|多项|不定项/) ? 100 : 1
  88. "
  89. @input="changeAnswer($event, question)"
  90. @blur="changeAnswer($event, question, '#')"
  91. />
  92. </div>
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. <div class="tw-flex tw-justify-between tw-bg-white tw-p-4">
  98. <a-button
  99. :disabled="!student?.upload"
  100. type="primary"
  101. shape="round"
  102. @click="saveStudentAnswer"
  103. >
  104. 保存
  105. </a-button>
  106. <div>
  107. <a-button
  108. shape="round"
  109. :disabled="currentIndex === 1"
  110. class="tw-mr-4"
  111. @click="getPreviousStudent"
  112. >
  113. 上一份
  114. </a-button>
  115. <a-button
  116. shape="round"
  117. :disabled="currentIndex === allIds.length"
  118. @click="getNextStudent"
  119. >
  120. 下一份
  121. </a-button>
  122. </div>
  123. </div>
  124. </div>
  125. <div style="flex: 1" class="mark-body-container tw-relative">
  126. <ArrowLeftOutlined
  127. v-if="student && currentImage !== 0"
  128. class="tw-cursor-pointer tw-absolute"
  129. style="top: 45%; left: 20px; z-index: 1; font-size: 40px"
  130. title="上一张"
  131. @click="switchImageArrow({ left: true })"
  132. />
  133. <ArrowRightOutlined
  134. v-if="student && currentImage !== student.sheetUrls.length - 1"
  135. class="tw-cursor-pointer tw-absolute"
  136. style="top: 45%; right: 20px; z-index: 1; font-size: 40px"
  137. title="上一张"
  138. @click="switchImageArrow({ right: true })"
  139. />
  140. <div :style="{ width: answerPaperScale }">
  141. <img
  142. v-for="(item, index) in student?.sheetUrls"
  143. :key="item"
  144. class="tw-object-cover"
  145. :src="item"
  146. :style="{
  147. display: index === currentImage ? 'block' : 'none',
  148. transform:
  149. (rotateDegree ? 'translate( 0, calc(30vh))' : '') +
  150. `rotate(${rotateDegree}deg)`,
  151. }"
  152. @click="switchImage"
  153. @contextmenu="showBigImage"
  154. />
  155. </div>
  156. <ZoomPaper v-if="student" fixed showRotate @rotateRight="rotateRight" />
  157. </div>
  158. </div>
  159. </div>
  160. </template>
  161. <script lang="ts" setup>
  162. import { httpApp } from "@/plugins/axiosApp";
  163. import { message } from "ant-design-vue";
  164. import { onMounted, reactive } from "vue";
  165. import { useRoute } from "vue-router";
  166. import { CheckSetting, StudentInfo } from "./check";
  167. import "viewerjs/dist/viewer.css";
  168. import Viewer from "viewerjs";
  169. import { store } from "@/store/store";
  170. import ZoomPaper from "@/components/ZoomPaper.vue";
  171. import { useTimers } from "@/setups/useTimers";
  172. import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons-vue";
  173. const { addTimeout } = useTimers();
  174. const route = useRoute();
  175. const checkType = route.query.checkType;
  176. const queryId = route.query.queryId as string;
  177. let pageType: "DATA_CHECK" | "HAND_CHECK" = "HAND_CHECK";
  178. if (queryId) {
  179. pageType = "DATA_CHECK";
  180. sessionStorage.setItem(queryId, localStorage.getItem(queryId) || "[]");
  181. localStorage.removeItem(queryId);
  182. }
  183. onMounted(async () => {
  184. await getSetting();
  185. if (setting.studentIds.length === 0) {
  186. void message.info("没有需要处理的考生,请返回。");
  187. return;
  188. }
  189. await getNextStudent();
  190. });
  191. let setting: CheckSetting = reactive({
  192. fileServer: "",
  193. studentIds: [],
  194. studentIdsDone: [],
  195. });
  196. const allIds = $computed(() => [
  197. ...setting.studentIdsDone,
  198. ...setting.studentIds,
  199. ]);
  200. let currentStudentId = $ref(-1);
  201. const currentIndex = $computed(() => allIds.indexOf(currentStudentId) + 1);
  202. let student: StudentInfo | null = $ref(null);
  203. /** 后台数据错误,停止整个页面的流程 */
  204. let dataError = $ref(false);
  205. const answersComputed = $computed(() => {
  206. let mains = student?.answers.map((v) => ({
  207. mainTitle: "",
  208. mainNumber: v.mainNumber,
  209. subs: [v],
  210. }));
  211. const mSet = new Set();
  212. mains = mains?.filter((v) => {
  213. if (!mSet.has(v.mainNumber)) {
  214. mSet.add(v.mainNumber);
  215. v.subs = [];
  216. return true;
  217. }
  218. });
  219. mains?.forEach((v) => {
  220. v.mainTitle = student?.titles[v.mainNumber] ?? "";
  221. v.subs =
  222. student?.answers.filter((v2) => v2.mainNumber === v.mainNumber) ?? [];
  223. });
  224. // console.log(mains);
  225. return mains;
  226. });
  227. async function getSetting() {
  228. let res: any;
  229. if (pageType === "DATA_CHECK") {
  230. const query: Array<{ name: string; value: string }> = JSON.parse(
  231. sessionStorage.getItem(queryId) || "[]"
  232. );
  233. const form = new FormData();
  234. for (const v of query) {
  235. form.append(v.name, v.value + "");
  236. }
  237. res = await httpApp.post("/admin/exam/check/answer/getSetting", form);
  238. } else {
  239. res = await httpApp.get(
  240. `/admin/exam/check/student/getSetting?checkType=${checkType}`
  241. );
  242. }
  243. setting.fileServer = res.data.fileServer;
  244. setting.studentIds = res.data.studentIds;
  245. }
  246. async function getNextStudent() {
  247. const wantedIndex = allIds.indexOf(currentStudentId);
  248. student = await getStudent(allIds[wantedIndex + 1]);
  249. }
  250. async function getPreviousStudent() {
  251. const wantedIndex = allIds.indexOf(currentStudentId);
  252. student = await getStudent(allIds[wantedIndex - 1]);
  253. }
  254. async function getStudent(studentId: number) {
  255. const stu: StudentInfo = await (
  256. await httpApp.get(`/admin/exam/check/answer/info?studentId=${studentId}`)
  257. ).data;
  258. stu?.sheetUrls.forEach((v, i, a) => (a[i] = setting.fileServer + v));
  259. currentStudentId = stu.id;
  260. currentImage = 0;
  261. if (!stu.success) {
  262. void message.error(stu.message, 24 * 60 * 60);
  263. dataError = true;
  264. throw new Error("取学生信息出错: " + stu.message);
  265. }
  266. // for dev
  267. // stu.answers = [
  268. // { mainNumber: 1, subNumber: "1", answer: "A" },
  269. // { mainNumber: 1, subNumber: "2", answer: "B" },
  270. // { mainNumber: 2, subNumber: "1", answer: "#" },
  271. // ];
  272. // stu.titles = { 1: "单选题", 2: "多选题" };
  273. return stu;
  274. }
  275. function changeAnswer(
  276. event: Event,
  277. question: StudentInfo["answers"][0],
  278. defaultValue?: string
  279. ) {
  280. // console.log(question, event.target.value);
  281. student!.answers = student!.answers.map((v) => {
  282. if (
  283. v.mainNumber === question.mainNumber &&
  284. v.subNumber === question.subNumber
  285. ) {
  286. v.answer =
  287. (<HTMLInputElement>event.target!).value.toUpperCase().trim() ||
  288. defaultValue ||
  289. "";
  290. }
  291. return v;
  292. });
  293. }
  294. async function saveStudentAnswer() {
  295. if (!student) return;
  296. const form = new FormData();
  297. form.append("studentId", student.id + "");
  298. const answers = student.answers.map((v) => v.answer || "#").join(",");
  299. if (!answers.match(/^(#*,*[A-Z]*)+$/g)) {
  300. void message.error("答案只能是#和大写英文字母");
  301. return;
  302. }
  303. form.append("answers", answers);
  304. const extra = pageType === "DATA_CHECK";
  305. extra && form.append("absent", student.absent + "");
  306. extra && form.append("paperType", student.paperType);
  307. if (extra) {
  308. if (!student.paperType.match(/^#|[A-Z]$/)) {
  309. void message.error("试卷类型只能是#和大写英文字母");
  310. return;
  311. }
  312. }
  313. const url = extra
  314. ? // 数据检查
  315. "/admin/exam/check/answer/save"
  316. : `/admin/exam/check/student/save`;
  317. const res = await httpApp
  318. .post(url, form)
  319. .catch(() => message.error("保存失败-接口调用失败"));
  320. if (!res.data) {
  321. void message.error("保存失败,请刷新页面。");
  322. } else {
  323. void message.success("保存成功");
  324. }
  325. if (setting.studentIds.length === 0) {
  326. void message.success("所有考生已处理完毕。");
  327. }
  328. }
  329. //#region : 显示大图,供查看和翻转
  330. let currentImage = $ref(0);
  331. function switchImageArrow({
  332. left = false,
  333. right = false,
  334. }: {
  335. left?: boolean;
  336. right?: boolean;
  337. }) {
  338. if (left) {
  339. if (currentImage > 0) {
  340. currentImage--;
  341. }
  342. }
  343. if (right) {
  344. if (currentImage < student!.sheetUrls.length - 1) {
  345. currentImage++;
  346. }
  347. }
  348. }
  349. function switchImage(event: MouseEvent) {
  350. const image = event.target as HTMLImageElement;
  351. const layerX: number = (event as any).layerX;
  352. if (layerX * 2 < image.width) {
  353. if (currentImage > 0) {
  354. currentImage--;
  355. }
  356. } else {
  357. if (currentImage < student!.sheetUrls.length - 1) {
  358. currentImage++;
  359. }
  360. }
  361. }
  362. const showBigImage = (event: MouseEvent) => {
  363. event.preventDefault();
  364. // console.log(event);
  365. let viewer: Viewer = null as unknown as Viewer;
  366. viewer && viewer.destroy();
  367. viewer = new Viewer((event.target as HTMLElement).parentElement!, {
  368. // inline: true,
  369. viewed() {
  370. viewer.zoomTo(1);
  371. },
  372. hidden() {
  373. viewer.destroy();
  374. },
  375. zIndex: 1000000,
  376. });
  377. viewer.show();
  378. };
  379. //#endregion : 显示大图,供查看和翻转
  380. //#region : 放大缩小和之后的滚动
  381. const answerPaperScale = $computed(() => {
  382. // 放大、缩小不影响页面之前的滚动条定位
  383. let percentWidth = 0;
  384. let percentTop = 0;
  385. const container = document.querySelector<HTMLDivElement>(
  386. ".mark-body-container"
  387. );
  388. if (container) {
  389. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  390. percentWidth = scrollLeft / scrollWidth;
  391. percentTop = scrollTop / scrollHeight;
  392. }
  393. addTimeout(() => {
  394. if (container) {
  395. const { scrollWidth, scrollHeight } = container;
  396. container.scrollTo({
  397. left: scrollWidth * percentWidth,
  398. top: scrollHeight * percentTop,
  399. });
  400. }
  401. }, 10);
  402. const scale = store.setting.uiSetting["answer.paper.scale"];
  403. return scale * 100 + "%";
  404. });
  405. //#endregion : 放大缩小和之后的滚动
  406. //#region rotateRight
  407. let rotateDegree = $ref(0);
  408. function rotateRight() {
  409. rotateDegree = (rotateDegree + 90) % 360;
  410. }
  411. //#endregion
  412. </script>
  413. <style scoped>
  414. .header-container {
  415. position: relative;
  416. height: 56px;
  417. line-height: 16px;
  418. background-color: var(--header-bg-color);
  419. color: rgba(255, 255, 255, 0.5);
  420. }
  421. .highlight-text {
  422. color: white;
  423. font-size: var(--app-title-font-size);
  424. }
  425. .mark-body-container {
  426. position: relative;
  427. min-height: calc(100vh - 56px);
  428. height: calc(100vh - 56px);
  429. overflow: auto;
  430. /* background-size: 8px 8px;
  431. background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
  432. linear-gradient(to bottom, transparent 4px, #e7e7e7 4px); */
  433. background-color: var(--app-container-bg-color);
  434. background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
  435. linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
  436. linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
  437. linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
  438. background-size: 20px 20px;
  439. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  440. transform: inherit;
  441. }
  442. </style>