ConfirmPaper.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  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; 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'">
  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-flex-wrap 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. // 使用 location.search 替代
  176. // const checkType = route.query.checkType;
  177. const queryId = route.query.queryId as string;
  178. let pageType: "DATA_CHECK" | "HAND_CHECK" = "HAND_CHECK";
  179. if (queryId) {
  180. pageType = "DATA_CHECK";
  181. sessionStorage.setItem(queryId, localStorage.getItem(queryId) || "[]");
  182. localStorage.removeItem(queryId);
  183. }
  184. onMounted(async () => {
  185. await getSetting();
  186. if (setting.studentIds.length === 0) {
  187. void message.info("没有需要处理的考生,请返回。");
  188. return;
  189. }
  190. await getNextStudent();
  191. });
  192. let setting: CheckSetting = reactive({
  193. fileServer: "",
  194. studentIds: [],
  195. studentIdsDone: [],
  196. });
  197. const allIds = $computed(() => [
  198. ...setting.studentIdsDone,
  199. ...setting.studentIds,
  200. ]);
  201. let currentStudentId = $ref(-1);
  202. const currentIndex = $computed(() => allIds.indexOf(currentStudentId) + 1);
  203. let student: StudentInfo | null = $ref(null);
  204. /** 后台数据错误,停止整个页面的流程 */
  205. let dataError = $ref(false);
  206. const answersComputed = $computed(() => {
  207. let mains = student?.answers.map((v) => ({
  208. mainTitle: "",
  209. mainNumber: v.mainNumber,
  210. subs: [v],
  211. }));
  212. const mSet = new Set();
  213. mains = mains?.filter((v) => {
  214. if (!mSet.has(v.mainNumber)) {
  215. mSet.add(v.mainNumber);
  216. v.subs = [];
  217. return true;
  218. }
  219. });
  220. mains?.forEach((v) => {
  221. v.mainTitle = student?.titles[v.mainNumber] ?? "";
  222. v.subs =
  223. student?.answers.filter((v2) => v2.mainNumber === v.mainNumber) ?? [];
  224. });
  225. // console.log(mains);
  226. return mains;
  227. });
  228. async function getSetting() {
  229. let res: any;
  230. if (pageType === "DATA_CHECK") {
  231. const query: Array<{ name: string; value: string }> = JSON.parse(
  232. sessionStorage.getItem(queryId) || "[]"
  233. );
  234. const form = new FormData();
  235. for (const v of query) {
  236. form.append(v.name, v.value + "");
  237. }
  238. res = await httpApp.post("/admin/exam/check/answer/getSetting", form);
  239. } else {
  240. const form = new FormData();
  241. form.append("checkType", route.query.checkType as string);
  242. form.append("subjectCode", route.query.subjectCode as string);
  243. form.append("examSite", route.query.examSite as string);
  244. res = await httpApp.post(`/admin/exam/check/student/getSetting`, form);
  245. }
  246. setting.fileServer = res.data.fileServer;
  247. setting.studentIds = res.data.studentIds;
  248. }
  249. async function getNextStudent() {
  250. const wantedIndex = allIds.indexOf(currentStudentId);
  251. if (allIds[wantedIndex + 1]) {
  252. student = await getStudent(allIds[wantedIndex + 1]);
  253. }
  254. }
  255. async function getPreviousStudent() {
  256. const wantedIndex = allIds.indexOf(currentStudentId);
  257. student = await getStudent(allIds[wantedIndex - 1]);
  258. }
  259. async function getStudent(studentId: number) {
  260. const stu: StudentInfo = await (
  261. await httpApp.get(`/admin/exam/check/answer/info?studentId=${studentId}`)
  262. ).data;
  263. stu?.sheetUrls.forEach((v, i, a) => (a[i] = setting.fileServer + v));
  264. currentStudentId = stu.id;
  265. currentImage = 0;
  266. if (!stu.success) {
  267. void message.error(stu.message, 24 * 60 * 60);
  268. dataError = true;
  269. throw new Error("取学生信息出错: " + stu.message);
  270. }
  271. // for dev
  272. // stu.answers = [
  273. // { mainNumber: 1, subNumber: "1", answer: "A" },
  274. // { mainNumber: 1, subNumber: "2", answer: "B" },
  275. // { mainNumber: 2, subNumber: "1", answer: "#" },
  276. // ];
  277. // stu.titles = { 1: "单选题", 2: "多选题" };
  278. return stu;
  279. }
  280. function changeAnswer(
  281. event: Event,
  282. question: StudentInfo["answers"][0],
  283. defaultValue?: string
  284. ) {
  285. // console.log(question, event.target.value);
  286. student!.answers = student!.answers.map((v) => {
  287. if (
  288. v.mainNumber === question.mainNumber &&
  289. v.subNumber === question.subNumber
  290. ) {
  291. v.answer =
  292. (<HTMLInputElement>event.target!).value.toUpperCase().trim() ||
  293. defaultValue ||
  294. "";
  295. }
  296. return v;
  297. });
  298. }
  299. async function saveStudentAnswer() {
  300. if (!student) return;
  301. const form = new FormData();
  302. form.append("studentId", student.id + "");
  303. const answers = student.answers.map((v) => v.answer || "#").join(",");
  304. if (!answers.match(/^(#*,*[A-Z]*)+$/g)) {
  305. void message.error("答案只能是#和大写英文字母");
  306. return;
  307. }
  308. form.append("answers", answers);
  309. const extra = pageType === "DATA_CHECK";
  310. extra && form.append("absent", student.absent + "");
  311. extra && form.append("paperType", student.paperType);
  312. if (extra) {
  313. if (!student.paperType.match(/^#|[A-Z]$/)) {
  314. void message.error("试卷类型只能是#和大写英文字母");
  315. return;
  316. }
  317. }
  318. const url = extra
  319. ? // 数据检查
  320. "/admin/exam/check/answer/save"
  321. : `/admin/exam/check/student/save`;
  322. const res = await httpApp
  323. .post(url, form)
  324. .catch(() => message.error("保存失败-接口调用失败"));
  325. if (!res.data) {
  326. void message.error("保存失败,请刷新页面。");
  327. } else {
  328. void message.success("保存成功");
  329. await getNextStudent()
  330. }
  331. if (setting.studentIds.length === 0) {
  332. void message.success("所有考生已处理完毕。");
  333. }
  334. }
  335. //#region : 显示大图,供查看和翻转
  336. let currentImage = $ref(0);
  337. function switchImageArrow({
  338. left = false,
  339. right = false,
  340. }: {
  341. left?: boolean;
  342. right?: boolean;
  343. }) {
  344. if (left) {
  345. if (currentImage > 0) {
  346. currentImage--;
  347. }
  348. }
  349. if (right) {
  350. if (currentImage < student!.sheetUrls.length - 1) {
  351. currentImage++;
  352. }
  353. }
  354. }
  355. function switchImage(event: MouseEvent) {
  356. const image = event.target as HTMLImageElement;
  357. const layerX: number = (event as any).layerX;
  358. if (layerX * 2 < image.width) {
  359. if (currentImage > 0) {
  360. currentImage--;
  361. }
  362. } else {
  363. if (currentImage < student!.sheetUrls.length - 1) {
  364. currentImage++;
  365. }
  366. }
  367. }
  368. const showBigImage = (event: MouseEvent) => {
  369. event.preventDefault();
  370. // console.log(event);
  371. let viewer: Viewer = null as unknown as Viewer;
  372. viewer && viewer.destroy();
  373. viewer = new Viewer((event.target as HTMLElement).parentElement!, {
  374. // inline: true,
  375. viewed() {
  376. viewer.zoomTo(1);
  377. },
  378. hidden() {
  379. viewer.destroy();
  380. },
  381. zIndex: 1000000,
  382. });
  383. viewer.show();
  384. };
  385. //#endregion : 显示大图,供查看和翻转
  386. //#region : 放大缩小和之后的滚动
  387. const answerPaperScale = $computed(() => {
  388. // 放大、缩小不影响页面之前的滚动条定位
  389. let percentWidth = 0;
  390. let percentTop = 0;
  391. const container = document.querySelector<HTMLDivElement>(
  392. ".mark-body-container"
  393. );
  394. if (container) {
  395. const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = container;
  396. percentWidth = scrollLeft / scrollWidth;
  397. percentTop = scrollTop / scrollHeight;
  398. }
  399. addTimeout(() => {
  400. if (container) {
  401. const { scrollWidth, scrollHeight } = container;
  402. container.scrollTo({
  403. left: scrollWidth * percentWidth,
  404. top: scrollHeight * percentTop,
  405. });
  406. }
  407. }, 10);
  408. const scale = store.setting.uiSetting["answer.paper.scale"];
  409. return scale * 100 + "%";
  410. });
  411. //#endregion : 放大缩小和之后的滚动
  412. //#region rotateRight
  413. let rotateDegree = $ref(0);
  414. function rotateRight() {
  415. rotateDegree = (rotateDegree + 90) % 360;
  416. }
  417. //#endregion
  418. </script>
  419. <style scoped>
  420. .header-container {
  421. position: relative;
  422. height: 56px;
  423. line-height: 16px;
  424. background-color: var(--header-bg-color);
  425. color: rgba(255, 255, 255, 0.5);
  426. }
  427. .highlight-text {
  428. color: white;
  429. font-size: var(--app-title-font-size);
  430. }
  431. .mark-body-container {
  432. position: relative;
  433. min-height: calc(100vh - 56px);
  434. height: calc(100vh - 56px);
  435. overflow: auto;
  436. /* background-size: 8px 8px;
  437. background-image: linear-gradient(to right, #e7e7e7 4px, transparent 4px),
  438. linear-gradient(to bottom, transparent 4px, #e7e7e7 4px); */
  439. background-color: var(--app-container-bg-color);
  440. background-image: linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
  441. linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
  442. linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
  443. linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
  444. background-size: 20px 20px;
  445. background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
  446. transform: inherit;
  447. }
  448. </style>