Ver código fonte

Merge branch 'dev_2.0.0' of http://git.qmth.com.cn/scan-central/client-admin into dev_2.0.0

刘洋 9 meses atrás
pai
commit
325d364a18
37 arquivos alterados com 1397 adições e 327 exclusões
  1. 91 0
      src/render/ap/absentCheck.ts
  2. 21 3
      src/render/ap/base.ts
  3. 4 2
      src/render/ap/dataCheck.ts
  4. 0 17
      src/render/ap/examStatusCheck.ts
  5. 6 6
      src/render/ap/imageCheck.ts
  6. 31 0
      src/render/ap/types/absentCheck.ts
  7. 1 3
      src/render/ap/types/base.ts
  8. 0 10
      src/render/ap/types/examStatusCheck.ts
  9. BIN
      src/render/assets/imgs/bg-compare.png
  10. BIN
      src/render/assets/imgs/bg-overwrite.png
  11. 224 0
      src/render/components/ImportBtn/index.vue
  12. 0 97
      src/render/components/ImportDialog/index.vue
  13. 4 4
      src/render/components/SelectCourse/index.vue
  14. 0 73
      src/render/components/SliceImage/index.vue
  15. 8 0
      src/render/router/routes.ts
  16. 20 0
      src/render/store/modules/dataCheck/index.ts
  17. 1 1
      src/render/utils/request.ts
  18. 257 0
      src/render/views/AbsentCheck/CheckAction.vue
  19. 178 0
      src/render/views/AbsentCheck/ImportTypeDialog.vue
  20. 108 0
      src/render/views/AbsentCheck/ResetDialog.vue
  21. 191 0
      src/render/views/AbsentCheck/index.vue
  22. 1 1
      src/render/views/Audit/ImageCheck/index.vue
  23. 1 1
      src/render/views/Audit/InTime/index.vue
  24. 1 0
      src/render/views/CurExam/index.vue
  25. 1 1
      src/render/views/DataCheck/CheckAction.vue
  26. 0 0
      src/render/views/DataCheck/ScanImage/FillAreaSetDialog.vue
  27. 0 0
      src/render/views/DataCheck/ScanImage/RecogEditDialog.vue
  28. 0 0
      src/render/views/DataCheck/ScanImage/data/paper.jpg
  29. 86 29
      src/render/views/DataCheck/ScanImage/index.vue
  30. 3 2
      src/render/views/DataCheck/SliceImage/CutImageDialog.vue
  31. 135 0
      src/render/views/DataCheck/SliceImage/index.vue
  32. 9 57
      src/render/views/DataCheck/index.vue
  33. 1 0
      src/render/views/DataCheck/types.ts
  34. 9 9
      src/render/views/ImageCheck/index.vue
  35. 2 6
      src/render/views/RecognizeCheck/RecognizeImage.vue
  36. 1 4
      src/render/views/Review/ReviewImage.vue
  37. 2 1
      types/app.d.ts

+ 91 - 0
src/render/ap/absentCheck.ts

@@ -0,0 +1,91 @@
+import { request } from "@/utils/request";
+
+import {
+  AbsentCheckListParams,
+  AbsentCheckListFilter,
+  AbsentCheckListResult,
+  ExamStatusSaveParams,
+  ExamStatusSaveResult,
+  AbsentResetParams,
+} from "./types/absentCheck";
+import { RequestActionResult } from "./types/common";
+
+// 缺考校验列表
+export const absentCheckList = (
+  data: AbsentCheckListParams
+): Promise<AbsentCheckListResult> =>
+  request({
+    url: "/api/admin/check/exam-status/list",
+    method: "post",
+    data,
+  });
+
+// 提交
+export const examStatusSave = (
+  data: ExamStatusSaveParams
+): Promise<ExamStatusSaveResult> =>
+  request({
+    url: "/api/admin/check/exam-status/save",
+    method: "post",
+    data,
+  });
+
+// 按考生导出缺考校验
+export const absentCheckStudentExport = (
+  data: AbsentCheckListFilter
+): Promise<AxiosResponse<Blob>> =>
+  request({
+    url: "/api/admin/check/exam-status/student/export",
+    method: "post",
+    data,
+    responseType: "blob",
+  });
+
+// 按考场导出缺考校验
+export const absentCheckRoomExport = (
+  data: AbsentCheckListFilter
+): Promise<AxiosResponse<Blob>> =>
+  request({
+    url: "/api/admin/check/exam-status/exam-room/export",
+    method: "post",
+    data,
+    responseType: "blob",
+  });
+
+// 缺考校验导入模版
+export const absentTemplateDownload = (): Promise<AxiosResponse<Blob>> =>
+  request({
+    url: "/api/admin/check/exam-status/template",
+    method: "post",
+    data,
+    responseType: "blob",
+  });
+
+// 缺考校验导入进度
+export const absentImportStatus = (
+  examId: number
+): Promise<{ synching: boolean }> =>
+  request({
+    url: "/api/admin/check/exam-status/import/status",
+    method: "post",
+    data,
+  });
+
+// 缺考校验导入进度
+export const absentReset = (
+  data: AbsentResetParams
+): Promise<{ synching: boolean }> =>
+  request({
+    url: "/api/admin/check/exam-status/reset",
+    method: "post",
+    data,
+  });
+
+export const absentResetStatus = (
+  examId: number
+): Promise<{ synching: boolean }> =>
+  request({
+    url: "/api/admin/check/exam-status/reset/status",
+    method: "post",
+    data,
+  });

+ 21 - 3
src/render/ap/base.ts

@@ -21,7 +21,7 @@ export const examList = (): Promise<Exam[]> =>
   });
 
 // 原图上传
-export const uploadSheet = (
+export const updateSheet = (
   data: UploadSheetParams
 ): Promise<UploadFileResult> => {
   const formData = new FormData();
@@ -32,12 +32,30 @@ export const uploadSheet = (
     }
   }
   return request({
-    url: "/api/scan/batch/sheet/upload",
+    url: "/api/admin/scan/answer/sheet/update",
     method: "post",
     data: formData,
   });
 };
 // 裁切图上传
+export const updateSlice = (
+  data: UploadSliceParams
+): Promise<UploadFileResult> => {
+  const formData = new FormData();
+  for (const key in data) {
+    if (Object.prototype.hasOwnProperty.call(data, key)) {
+      const val = data[key];
+      formData.append(key, val);
+    }
+  }
+  return request({
+    url: "/api/admin/scan/answer/slice/update",
+    method: "post",
+    data: formData,
+  });
+};
+
+// 裁切图修改
 export const uploadSlice = (
   data: UploadSliceParams
 ): Promise<UploadFileResult> => {
@@ -49,7 +67,7 @@ export const uploadSlice = (
     }
   }
   return request({
-    url: "/api/scan/batch/slice/upload",
+    url: "/api/admin/scan/answer/slice/upload",
     method: "post",
     data: formData,
   });

+ 4 - 2
src/render/ap/dataCheck.ts

@@ -23,21 +23,23 @@ export const dataCheckList = (
 // 按考生导出答题卡扫描详情
 export const dataCheckStudentExport = (
   data: DataCheckListFilter
-): Promise<{ url: string }> =>
+): Promise<AxiosResponse<Blob>> =>
   request({
     url: "/api/admin/scan/answer/student/export",
     method: "post",
     data,
+    responseType: "blob",
   });
 
 // 按考场导出答题卡扫描详情
 export const dataCheckRoomExport = (
   data: DataCheckListFilter
-): Promise<{ url: string }> =>
+): Promise<AxiosResponse<Blob>> =>
   request({
     url: "/api/admin/scan/answer/exam-room/export",
     method: "post",
     data,
+    responseType: "blob",
   });
 
 // 按类型修改卷型、识别结果等

+ 0 - 17
src/render/ap/examStatusCheck.ts

@@ -1,17 +0,0 @@
-import { request } from "@/utils/request";
-
-import {
-  ExamStatusSaveParams,
-  ExamStatusSaveResult,
-} from "./types/examStatusCheck";
-import { RequestActionResult } from "./types/common";
-
-// 提交
-export const examStatusSave = (
-  data: ExamStatusSaveParams
-): Promise<ExamStatusSaveResult> =>
-  request({
-    url: "/api/admin/check/exam-status/save",
-    method: "post",
-    data,
-  });

+ 6 - 6
src/render/ap/imageCheck.ts

@@ -17,26 +17,26 @@ export const imageCheckList = (
   data: ExamPageParams
 ): Promise<ImageCheckListPageResult> =>
   request({
-    url: "/api/admin/subject/image-check/list",
+    url: "/api/admin/check/image/list",
     method: "post",
-    data,
+    params: data,
   });
 
 export const imageCheckFailedList = (
   data: ImageCheckFailedParams
 ): Promise<ImageCheckFailedListPageResult> =>
   request({
-    url: "/api/admin/subject/image-check/failed/page",
+    url: "/api/admin/check/image/failed/page",
     method: "post",
-    data,
+    params: data,
   });
 
 export const imageCheckFailedExport = (
   data: ImageCheckFailedParams
 ): Promise<AxiosResponse<Blob>> =>
   request({
-    url: "/api/admin/subject/image-check/failed/export",
+    url: "/api/admin/check/image/failed/export",
     method: "post",
-    data,
+    params: data,
     responseType: "blob",
   });

+ 31 - 0
src/render/ap/types/absentCheck.ts

@@ -0,0 +1,31 @@
+import { PageResult, PageParams, ExamSubjectParams } from "./common";
+import { DataCheckListItem, ExamStatus } from "./dataCheck";
+
+export interface AbsentCheckListFilter {
+  examId: number;
+  examNumber: string;
+  studentCode: string;
+  name: string;
+  subjectCode: string;
+  examStatus: ExamStatus;
+}
+
+export type AbsentCheckListParams = PageParams<AbsentCheckListFilter>;
+
+export type AbsentCheckListResult = PageResult<DataCheckListItem>;
+
+export interface ExamStatusSaveParams {
+  id: number;
+  examStatus: ExamStatus;
+}
+
+export interface ExamStatusSaveResult {
+  examStatus: ExamStatus;
+}
+
+export type ImportType = "COMPARE" | "OVERRIDE";
+
+export interface AbsentResetParams {
+  examId: number;
+  examNumberFillCount: number;
+}

+ 1 - 3
src/render/ap/types/base.ts

@@ -11,9 +11,7 @@ export interface SubjectItem {
 // }
 
 export interface UploadSheetParams {
-  batchId: number;
-  examNumber: string;
-  paperNumber: number;
+  paperId: number;
   pageIndex: number;
   file: File;
   md5: string;

+ 0 - 10
src/render/ap/types/examStatusCheck.ts

@@ -1,10 +0,0 @@
-import { ExamStatus } from "./dataCheck";
-
-export interface ExamStatusSaveParams {
-  id: number;
-  examStatus: ExamStatus;
-}
-
-export interface ExamStatusSaveResult {
-  examStatus: ExamStatus;
-}

BIN
src/render/assets/imgs/bg-compare.png


BIN
src/render/assets/imgs/bg-overwrite.png


+ 224 - 0
src/render/components/ImportBtn/index.vue

@@ -0,0 +1,224 @@
+<template>
+  <a-upload
+    :action="uploadUrl"
+    :headers="headers"
+    :accept="accept"
+    :data="uploadDataDict"
+    :show-upload-list="false"
+    :custom-request="customRequest"
+    :disabled="disabled || loading"
+    :before-upload="handleBeforeUpload"
+    @change="handleFileChange"
+  >
+    <slot>
+      <a-button>
+        <template #icon><ImportOutlined /></template>点击导入
+      </a-button>
+    </slot>
+  </a-upload>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from "vue";
+import { DownloadOutlined, ImportOutlined } from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+import type { UploadProps } from "ant-design-vue";
+
+import type { AxiosError, AxiosProgressEvent } from "axios";
+import { request } from "@/utils/request";
+import { getFileMD5 } from "@/utils/crypto";
+
+defineOptions({
+  name: "ImportBtn",
+});
+
+const props = withDefaults(
+  defineProps<{
+    uploadUrl: string;
+    format?: string[];
+    uploadData?: Record<string, any>;
+    maxSize?: number;
+    disabled?: boolean;
+    uploadFileAlias?: string;
+  }>(),
+  {
+    uploadUrl: "",
+    format: () => ["xls", "xlsx"],
+    uploadData: () => {
+      return {};
+    },
+    maxSize: 20 * 1024 * 1024,
+    uploadFileAlias: "file",
+    disabled: false,
+  }
+);
+
+// interface ResultData {
+//   success: boolean;
+//   message: string;
+// }
+// type UploadSuccessData = ResultData & UploadResultType;
+// const emit = defineEmits<{
+//   uploading: [];
+//   uploadError: [value: ResultData];
+//   uploadSuccess: [value: UploadSuccessData];
+//   validError: [value: ResultData];
+// }>();
+
+const emit = defineEmits([
+  "uploading",
+  "uploadError",
+  "uploadSuccess",
+  "validError",
+]);
+
+const uploadRef = ref();
+const canUpload = ref(false);
+const uploadDataDict = ref({});
+const headers = ref({ md5: "" });
+const result = ref({ success: true, message: "" });
+const loading = ref(false);
+const uploadProgress = ref(0);
+const curFileUid = ref("");
+
+const accept = computed(() => {
+  return props.format.map((el) => `.${el}`).join();
+});
+
+function checkFileFormat(fileType: string) {
+  const fileFormat = fileType.split(".").pop()?.toLocaleLowerCase();
+  return props.format.some((item) => item.toLocaleLowerCase() === fileFormat);
+}
+
+const handleFileChange: UploadProps["onChange"] = ({ file, fileList }) => {
+  if (!fileList.length) {
+    curFileUid.value = "";
+    result.value = {
+      success: true,
+      message: "",
+    };
+    return;
+  }
+
+  if (curFileUid.value !== file.uid) {
+    result.value = {
+      success: true,
+      message: "",
+    };
+  }
+  curFileUid.value = file.uid;
+  canUpload.value = file.status === "uploading";
+};
+
+const handleBeforeUpload: UploadProps["beforeUpload"] = async (file) => {
+  uploadDataDict.value = {
+    ...props.uploadData,
+    filename: file.name,
+  };
+
+  if (file.size > props.maxSize) {
+    handleExceededSize();
+    return Promise.reject(result.value);
+  }
+
+  if (!checkFileFormat(file.name)) {
+    handleFormatError();
+    return Promise.reject(result.value);
+  }
+
+  const md5 = await getFileMD5(file);
+  headers.value.md5 = md5;
+
+  loading.value = true;
+};
+
+interface UploadResultType {
+  hasError: boolean;
+  failRecords: Array<{
+    msg: string;
+    lineNum: number;
+  }>;
+}
+
+const customRequest: UploadProps["customRequest"] = (option) => {
+  const { file, data } = option;
+
+  const formData = new FormData();
+  const paramData: Record<string, any> = data || {};
+  Object.entries(paramData).forEach(([k, v]) => {
+    formData.append(k, v);
+  });
+  formData.append(props.uploadFileAlias, file as File);
+  emit("uploading");
+
+  (
+    request({
+      url: option.action as string,
+      data: formData,
+      headers: option.headers,
+      onUploadProgress: (data: AxiosProgressEvent) => {
+        uploadProgress.value = data.total
+          ? Math.floor((100 * data.loaded) / data.total)
+          : 0;
+      },
+    }) as Promise<UploadResultType>
+  )
+    .then((res) => {
+      // 所有excel导入的特殊处理
+      if (res.hasError) {
+        const failRecords = res.failRecords;
+        const message = failRecords
+          .map((item) => `第${item.lineNum}行:${item.msg}`)
+          .join("。");
+
+        handleError(message);
+        return;
+      }
+      handleSuccess(res);
+    })
+    .catch((error: AxiosError<{ message: string }> | null) => {
+      handleError(error?.response?.data?.message);
+    });
+};
+
+function handleError(message: string | undefined) {
+  canUpload.value = false;
+  loading.value = false;
+  result.value = {
+    success: false,
+    message: message || "上传错误",
+  };
+  emit("uploadError", result.value);
+}
+function handleSuccess(data: UploadResultType) {
+  canUpload.value = false;
+  loading.value = false;
+  result.value = {
+    success: true,
+    message: "上传成功!",
+  };
+  emit("uploadSuccess", {
+    ...result.value,
+    data,
+  });
+}
+
+function handleFormatError() {
+  const content = `只支持文件格式为${props.format.join("/")}`;
+  result.value = {
+    success: false,
+    message: content,
+  };
+  loading.value = false;
+  emit("validError", result.value);
+}
+function handleExceededSize() {
+  const content = `文件大小不能超过${Math.floor(props.maxSize / 1024)}M`;
+  result.value = {
+    success: false,
+    message: content,
+  };
+  loading.value = false;
+  emit("validError", result.value);
+}
+</script>

+ 0 - 97
src/render/components/ImportDialog/index.vue

@@ -330,100 +330,3 @@ function modalOpenHandle() {
   curFileUid.value = "";
 }
 </script>
-
-<style lang="less">
-.import-box {
-  .import-temp {
-    display: flex;
-    justify-content: space-between;
-    margin-bottom: 10px;
-
-    > span {
-      flex-grow: 0;
-      flex-shrink: 0;
-      height: 20px;
-      line-height: 20px;
-      display: block;
-    }
-
-    .temp-btn {
-      flex-grow: 2;
-      text-align: left;
-
-      > a {
-        flex-grow: 2;
-        line-height: 20px;
-        color: var(--color-primary);
-
-        &:hover {
-          text-decoration: underline;
-          opacity: 0.8;
-        }
-      }
-    }
-    .arco-btn {
-      line-height: 20px;
-      height: auto;
-      padding: 0;
-      background: transparent;
-      border: none;
-
-      &:hover {
-        text-decoration: underline;
-        opacity: 0.8;
-      }
-    }
-  }
-  .arco-upload-drag {
-    padding: 40px 0;
-    > div:first-child {
-      height: 54px;
-      background-image: url(assets/images/upload-icon.png);
-      background-size: auto 100%;
-      background-repeat: no-repeat;
-      background-position: center;
-      margin-bottom: 16px;
-    }
-    svg {
-      display: none;
-    }
-  }
-
-  .arco-upload-list-item {
-    margin-top: 8px !important;
-    background-color: var(--color-fill-1);
-    border-radius: var(--border-radius-small);
-    .arco-upload-list-item-operation {
-      margin: 0 12px;
-    }
-
-    .svg-icon {
-      vertical-align: -2px;
-    }
-    .arco-upload-list-item-file-icon {
-      margin-right: 6px;
-      color: inherit;
-    }
-
-    &.arco-upload-list-item-error {
-      .arco-upload-list-item-file-icon {
-        color: var(--color-danger);
-      }
-    }
-  }
-  .arco-upload-progress {
-    > * {
-      display: none;
-    }
-    .arco-upload-icon-success {
-      display: block;
-    }
-  }
-
-  .tips-info {
-    max-height: 100px;
-    overflow: hidden;
-    margin-top: 5px;
-  }
-}
-</style>

+ 4 - 4
src/render/components/SelectCourse/index.vue

@@ -25,14 +25,14 @@ defineOptions({
 const props = withDefaults(
   defineProps<{
     examId: number;
-    modelValue: string;
+    value: string;
     placeholder?: string;
   }>(),
   {
     placeholder: "请选择",
   }
 );
-const emit = defineEmits(["update:modelValue", "change"]);
+const emit = defineEmits(["update:value", "change"]);
 const attrs = useAttrs();
 
 const fieldNames = { label: "subjectName", value: "subjectCode" };
@@ -50,12 +50,12 @@ const onChange = () => {
   const selectedData = optionList.value.filter(
     (item) => selected.value === item.subjectCode
   );
-  emit("update:modelValue", selected.value);
+  emit("update:value", selected.value);
   emit("change", selectedData[0]);
 };
 
 watch(
-  () => props.modelValue,
+  () => props.value,
   (val) => {
     selected.value = val;
   },

+ 0 - 73
src/render/components/SliceImage/index.vue

@@ -1,73 +0,0 @@
-<template>
-  <div class="slice-image">
-    <div
-      v-for="item in studentSlice.papers"
-      :key="item.number"
-      class="image-paper"
-    >
-      <div
-        v-for="(page, pindex) in item.pages"
-        :key="pindex"
-        class="image-page"
-      >
-        <h3 class="image-page-title">
-          {{ getPageTitle(item.number, pindex) }}
-        </h3>
-        <div
-          v-for="(url, sindex) in page.sliceUri"
-          :key="sindex"
-          class="image-item"
-        >
-          <img :src="url" :alt="sindex + 1" />
-          <div class="image-action">
-            <a-button class="image-change">
-              <template #icon><PictureFilled /></template>
-            </a-button>
-            <a-button
-              class="image-slice"
-              @click="onEditSlice(item.number, pindex, sindex)"
-            >
-              <template #icon><NumberOutlined /></template>
-            </a-button>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-
-  <!-- CutImageDialog -->
-  <CutImageDialog
-    ref="cutImageDialogRef"
-    :sheet-url="sheetUrl"
-    :slice-selection="curSliceSelection"
-  />
-</template>
-
-<script setup lang="ts">
-import { NumberOutlined, PictureFilled } from "@ant-design/icons-vue";
-import CutImageDialog from "./CutImageDialog.vue";
-import { ref } from "vue";
-
-defineOptions({
-  name: "SliceImage",
-});
-
-const props = defineProps<{
-  sheetUrl: string;
-  studentSlice: StudentSliceImgs;
-}>();
-
-const curSliceSelection = ref<AreaSize>();
-
-function getPageTitle(paperNumber, pageIndex) {
-  return `卡${paperNumber}${pageIndex === 0 ? "正面" : "反面"}`;
-}
-
-const cutImageDialogRef = ref();
-function onEditSlice(paperNumber: number, pageIndex: number, index: number) {
-  // TODO:get slice selection
-  curSliceSelection.value = undefined;
-
-  cutImageDialogRef.value?.open();
-}
-</script>

+ 8 - 0
src/render/router/routes.ts

@@ -65,6 +65,14 @@ const routes: RouteRecordRaw[] = [
           },
         ],
       },
+      {
+        path: "absent-check",
+        name: "AbsentCheck",
+        component: () => import("@/views/AbsentCheck/index.vue"),
+        meta: {
+          title: "缺考校验",
+        },
+      },
       {
         path: "data-check",
         name: "DataCheck",

+ 20 - 0
src/render/store/modules/dataCheck/index.ts

@@ -16,6 +16,16 @@ interface DataCheckState {
 
 type UpdateFieldParams = Pick<DataCheckOmrFieldEditParams, "field" | "value">;
 
+interface UpdateSheetData {
+  paperIndex: number;
+  paperId?: number;
+  pageIndex: number;
+  uri: string;
+}
+interface UpdateSliceData extends UpdateSheetData {
+  index: number;
+}
+
 export const useDataCheckStore = defineStore("dataCheck", {
   state: (): DataCheckState => ({
     imageType: "ORIGIN",
@@ -49,6 +59,16 @@ export const useDataCheckStore = defineStore("dataCheck", {
       };
       await dataCheckOmrFieldEdit(params).catch(() => {});
     },
+    modifySliceUri(data: UpdateSliceData) {
+      if (!this.curStudent) return;
+      const { uri, pageIndex, index, paperIndex } = data;
+      this.curStudent.papers[paperIndex].pages[pageIndex].sliceUri[index] = uri;
+    },
+    modifySheetUri(data: UpdateSheetData) {
+      if (!this.curStudent) return;
+      const { uri, pageIndex, paperIndex } = data;
+      this.curStudent.papers[paperIndex].pages[pageIndex].sheetUri = uri;
+    },
   },
   persist: {
     storage: sessionStorage,

+ 1 - 1
src/render/utils/request.ts

@@ -24,7 +24,7 @@ function getDeviceId() {
 function setAuth(config: any) {
   let userSession = sessionStorage.getItem("user");
   if (userSession && !config.noAuth) {
-    let user = JSON.parse(userSession).user?.userInfo;
+    let user = JSON.parse(userSession).userInfo;
     if (!user) {
       return;
     }

+ 257 - 0
src/render/views/AbsentCheck/CheckAction.vue

@@ -0,0 +1,257 @@
+<template>
+  <div class="check-action">
+    <a-collapse
+      v-model:activeKey="panelKey"
+      :bordered="false"
+      expandIconPosition="end"
+    >
+      <a-collapse-panel key="1">
+        <template #header><FilterFilled />搜索条件 </template>
+        <a-form :label-col="{ style: { width: '83px' } }">
+          <a-form-item label="科目">
+            <select-course
+              v-model:value="searchModel.subjectCode"
+              :exam-id="userStore.curExam.id"
+              style="width: 150px"
+            ></select-course>
+          </a-form-item>
+          <a-form-item label="查找条件">
+            <a-select
+              v-model:value="searchDataCheckType"
+              placeholder="请选择"
+              :options="dataCheckOptions"
+            ></a-select>
+          </a-form-item>
+          <a-form-item label="准考证号">
+            <div class="exam-number">
+              <a-textarea
+                v-model:value="searchModel.examNumber"
+                placeholder="请输入"
+                :auto-size="{ minRows: 1, maxRows: 1 }"
+              ></a-textarea>
+              <div class="number-suffix">{{ examNumberCountCont }}</div>
+            </div>
+            <a-button class="ant-simple m-l-8px" type="link">查看全部</a-button>
+          </a-form-item>
+
+          <a-row>
+            <a-col :span="16">
+              <a-form-item label="姓名">
+                <a-input
+                  v-model:value="searchModel.name"
+                  placeholder="请输入"
+                  style="width: 150px"
+                ></a-input>
+              </a-form-item>
+            </a-col>
+            <a-col :span="8">
+              <a-form-item>
+                <a-button class="m-r-8px" type="primary" @click="onSearch"
+                  >查询</a-button
+                >
+                <a-button @click="onExport">导出</a-button>
+              </a-form-item>
+            </a-col>
+          </a-row>
+        </a-form>
+      </a-collapse-panel>
+      <a-collapse-panel key="2">
+        <template #header><PictureFilled />图片类别 </template>
+
+        <a-radio-group v-model:value="imageType" @change="onImageTypeChange">
+          <a-radio
+            v-for="item in imageTypeOptions"
+            :key="item.value"
+            :value="item.value"
+            >{{ item.label }}</a-radio
+          >
+        </a-radio-group>
+      </a-collapse-panel>
+      <a-collapse-panel key="3">
+        <template #header><IdcardFilled />题卡信息 </template>
+
+        <QuestionPanel
+          v-model:questions="questions"
+          :info="questionInfo"
+          :simple="isSliceImage"
+          @change="onQuestionsChange"
+          @exam-status-change="onExamStatusChange"
+        />
+      </a-collapse-panel>
+      <a-collapse-panel key="4">
+        <template #header><SettingFilled />缺考数据设置 </template>
+
+        <a-button type="primary" :rotate="90" @click="onImport">
+          <template #icon><SelectOutlined /></template>导入缺考名单
+        </a-button>
+        <a-button :rotate="270" @click="onReset">
+          <template #icon><RedoOutlined /></template>重新生成缺考数据
+        </a-button>
+      </a-collapse-panel>
+    </a-collapse>
+  </div>
+
+  <!-- ExportTypeDialog -->
+  <ExportTypeDialog ref="exportTypeDialogRef" @confirm="onExportConfirm" />
+  <!-- ResetDialog -->
+  <ResetDialog ref="resetDialogRef" />
+  <!-- ImportTypeDialog -->
+  <ImportTypeDialog ref="importTypeDialogRef" />
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from "vue";
+import {
+  FilterFilled,
+  PictureFilled,
+  IdcardFilled,
+  SettingFilled,
+  SelectOutlined,
+  RedoOutlined,
+} from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+
+import { useUserStore, useDataCheckStore } from "@/store";
+import { AbsentCheckListFilter, ImportType } from "@/ap/types/absentCheck";
+import { ExamStatus } from "@/ap/types/dataCheck";
+
+import {
+  absentCheckStudentExport,
+  absentCheckRoomExport,
+  examStatusSave,
+} from "@/ap/absentCheck";
+import useDictOption from "@/hooks/dictOption";
+import useLoading from "@/hooks/useLoading";
+import { downloadByApi } from "@/utils/download";
+
+import ExportTypeDialog from "../Review/ExportTypeDialog.vue";
+import ResetDialog from "./ResetDialog.vue";
+import ImportTypeDialog from "./ImportTypeDialog.vue";
+import QuestionPanel from "../DataCheck/QuestionPanel.vue";
+
+defineOptions({
+  name: "CheckAction",
+});
+
+const emit = defineEmits(["search"]);
+
+const userStore = useUserStore();
+const dataCheckStore = useDataCheckStore();
+
+const { optionList: dataCheckOptions } = useDictOption("DATA_CHECK_TYPE");
+const { optionList: imageTypeOptions } = useDictOption("IMAGE_TYPE");
+const panelKey = ref(["1", "2", "3", "4"]);
+
+// search
+const initSearchModel = {
+  examId: userStore.curExam.id,
+  examNumber: "",
+  studentCode: "",
+  name: "",
+  subjectCode: "",
+  examStatus: "",
+};
+const searchModel = reactive<AbsentCheckListFilter>({ ...initSearchModel });
+const searchDataCheckType = ref();
+const imageType = ref(dataCheckStore.imageType);
+const actionType = ref("common");
+
+// imageType
+const isSliceImage = computed(() => {
+  return dataCheckStore.imageType === "SLICE";
+});
+
+const examNumberCountCont = computed(() => {
+  const examNumbers = (searchModel.examNumber || "")
+    .split("\n")
+    .filter((item) => item);
+  return `${examNumbers.length}/100`;
+});
+
+function onSearch() {
+  emit("search", searchModel);
+}
+
+function onImageTypeChange() {
+  dataCheckStore.setInfo({
+    imageType: imageType.value,
+  });
+}
+
+// question panel
+const questionInfo = computed(() => {
+  return {
+    examNumber: dataCheckStore.curStudent?.examNumber,
+    name: dataCheckStore.curStudent?.name,
+    examSite: dataCheckStore.curStudent?.examSite,
+    seatNumber: dataCheckStore.curStudent?.seatNumber,
+    paperType: dataCheckStore.curStudent?.paperType,
+  };
+});
+
+const questions = ref([]);
+watch(
+  () => dataCheckStore.curPageIndex,
+  (val, oldval) => {
+    if (val !== oldval) {
+      questions.value = [...dataCheckStore.curPage?.question];
+    }
+  }
+);
+
+async function onQuestionsChange() {
+  if (!dataCheckStore.curPage) return;
+  dataCheckStore.curPage.question = [...questions.value];
+
+  await dataCheckStore
+    .updateField({
+      field: "QUESTION",
+      value: questions.value,
+    })
+    .catch(() => {});
+}
+
+async function onExamStatusChange(val: ExamStatus) {
+  if (!dataCheckStore.curStudent) return;
+
+  await examStatusSave({ id: dataCheckStore.curStudent.id, examStatus: val });
+  dataCheckStore.curStudent.examStatus = val;
+}
+
+// 导出
+const { loading: downloading, setLoading } = useLoading();
+const exportTypeDialogRef = ref();
+function onExport(type: string) {
+  if (downloading.value) return;
+  actionType.value = type;
+  exportTypeDialogRef.value?.open();
+}
+
+async function onExportConfirm(type: "student" | "room") {
+  if (downloading.value) return;
+
+  setLoading(true);
+  const func =
+    type === "student" ? absentCheckStudentExport : absentCheckRoomExport;
+
+  const res = await downloadByApi(() => func(searchModel)).catch((e: Error) => {
+    message.error(e.message || "下载失败,请重新尝试!");
+  });
+  setLoading(false);
+
+  if (!res) return;
+  message.success("导出成功!");
+}
+
+// 导入
+const importTypeDialogRef = ref();
+function onImport() {
+  importTypeDialogRef.value?.open();
+}
+
+// 重置
+const resetDialogRef = ref();
+function onReset() {
+  resetDialogRef.value?.open();
+}
+</script>

+ 178 - 0
src/render/views/AbsentCheck/ImportTypeDialog.vue

@@ -0,0 +1,178 @@
+<template>
+  <a-modal
+    v-model:open="visible"
+    :width="456"
+    style="top: 10vh"
+    :footer="null"
+    title="导入缺考名单"
+  >
+    <a-row :gutter="16">
+      <a-col :span="12">
+        <import-btn
+          :upload-url="uploadUrl"
+          :upload-data="updateData"
+          :disabled="importing"
+          @upload-success="checkUploadStatus"
+          @valid-error="onValidError"
+        >
+          <div class="type-box" @click="seleted('COMPARE')">
+            <img src="@/assets/imgs/bg-compare.png" alt="交叉对比" />
+            <p>交叉对比</p>
+            <p>CROSS-CONTRAST</p>
+          </div>
+        </import-btn>
+      </a-col>
+      <a-col :span="12">
+        <import-btn
+          :upload-url="uploadUrl"
+          :upload-data="updateData"
+          :disabled="importing"
+          @upload-success="startLoopSync"
+          @valid-error="onValidError"
+        >
+          <div class="type-box" @click="seleted('OVERRIDE')">
+            <img src="@/assets/imgs/bg-overwrite.png" alt="覆盖数据" />
+            <p>覆盖数据</p>
+            <p>OVERWRITE THE DATA</p>
+          </div>
+        </import-btn>
+      </a-col>
+    </a-row>
+    <div class="m-t-16px">
+      <a-button type="primary" :loading="loading" @click="onDownloadTemp">
+        <template #icon><DownloadOutlined /></template>
+        下载缺考名单模版
+      </a-button>
+    </div>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import {
+  FilterFilled,
+  PictureFilled,
+  IdcardFilled,
+  DownloadOutlined,
+} from "@ant-design/icons-vue";
+import { computed, ref } from "vue";
+import useModal from "@/hooks/useModal";
+import { ImportType } from "@/ap/types/absentCheck";
+import { absentImportStatus } from "@/ap/absentCheck";
+import { absentTemplateDownload } from "@/ap/absentCheck";
+import { downloadByApi } from "@/utils/download";
+import useLoading from "@/hooks/useLoading";
+import useLoop from "@/hooks/useLoop";
+
+import ImportBtn from "@/components/ImportBtn/index.vue";
+import { useUserStore } from "@/store";
+import { message } from "@qmth/ui";
+
+defineOptions({
+  name: "ImportTypeDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const userStore = useUserStore();
+
+const uploadUrl = "/api/admin/scan/answer/sheet/update";
+const uploadMode = ref("" as ImportType);
+
+const updateData = computed(() => {
+  return {
+    examId: userStore.curExam.id,
+    mode: uploadMode.value,
+  };
+});
+
+const { loading: importing, setLoading: setImportLoading } = useLoading();
+
+function seleted(type: ImportType) {
+  console.log(type);
+  uploadMode.value = type;
+  setImportLoading(true);
+  // emit("confirm", type);
+}
+
+const { start: startLoopSync, stop: stopLoopSync } = useLoop(
+  checkUploadStatus,
+  1000
+);
+
+async function checkUploadStatus() {
+  const res = await absentImportStatus(userStore.curExam.id).catch(() => {});
+
+  if (res) {
+    if (!res.synching) {
+      stopLoopSync();
+      setImportLoading(false);
+      message.success("导入成功!");
+      close();
+    }
+  } else {
+    setImportLoading(false);
+    stopLoopSync();
+    message.error("导入错误!");
+  }
+}
+
+function onValidError(data: { message: string }) {
+  message.error(message);
+  setImportLoading(false);
+}
+
+// 下载模板
+const { loading, setLoading } = useLoading();
+async function onDownloadTemp() {
+  if (loading.value) return;
+
+  setLoading(true);
+  const res = await downloadByApi(() => absentTemplateDownload()).catch(
+    (e: Error) => {
+      message.error(e.message || "下载失败,请重新尝试!");
+    }
+  );
+  setLoading(false);
+  if (!res) return;
+  message.success("下载成功!");
+}
+</script>
+
+<style lang="less" scoped>
+.type-box {
+  height: 140px;
+  border-radius: 6px;
+  padding: 26px;
+  border: 1px solid @border-color1;
+  text-align: center;
+  cursor: pointer;
+
+  &:hover {
+    background: #f3f4f6;
+  }
+
+  > img {
+    display: block;
+    height: 35px;
+    width: 32px;
+    margin: 0 auto;
+    margin-bottom: 12px;
+  }
+
+  > p:nth-of-type(1) {
+    height: 24px;
+    font-weight: 500;
+    font-size: 16px;
+    color: #14161a;
+    line-height: 24px;
+  }
+  > p:nth-of-type(2) {
+    height: 18px;
+    font-size: 10px;
+    color: #8c8c8c;
+    line-height: 18px;
+  }
+}
+</style>

+ 108 - 0
src/render/views/AbsentCheck/ResetDialog.vue

@@ -0,0 +1,108 @@
+<template>
+  <a-modal
+    v-model:open="visible"
+    :width="456"
+    style="top: 10vh"
+    title="重新生成缺考数据"
+  >
+    <div>
+      <h3>生成条件</h3>
+      <p>1.客观题未作答+主观题有作答的考生;</p>
+      <p>2.客观题未作答+主观题有作答+卡1或卡2任一题卡准考证号</p>
+      <p>
+        填涂≧
+        <a-input-number
+          v-model:value="examNumberFillCount"
+          :min="1"
+          :max="999"
+          :precision="0"
+          :controls="false"
+        ></a-input-number>
+        位+有条码的考生。
+      </p>
+    </div>
+
+    <template #footer>
+      <div class="box-justify">
+        <div></div>
+
+        <div>
+          <a-button type="text" @click="close">取消</a-button>
+          <a-button type="primary" @click="confirm">确认</a-button>
+        </div>
+      </div>
+    </template>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from "vue";
+import useModal from "@/hooks/useModal";
+import { absentResetStatus, absentReset } from "@/ap/absentCheck";
+import { absentTemplateDownload } from "@/ap/absentCheck";
+import useLoading from "@/hooks/useLoading";
+import useLoop from "@/hooks/useLoop";
+
+import { useUserStore } from "@/store";
+import { message } from "@qmth/ui";
+
+defineOptions({
+  name: "ImportTypeDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const userStore = useUserStore();
+
+const examNumberFillCount = ref<number>();
+
+const { loading, setLoading } = useLoading();
+
+const { start: startLoopSync, stop: stopLoopSync } = useLoop(
+  checkUploadStatus,
+  1000
+);
+
+async function checkUploadStatus() {
+  const res = await absentResetStatus(userStore.curExam.id).catch(() => {});
+
+  if (res) {
+    if (!res.synching) {
+      stopLoopSync();
+      setLoading(false);
+      message.success("缺考校验重新生成成功!");
+      close();
+    }
+  } else {
+    setLoading(false);
+    stopLoopSync();
+    message.error("缺考校验重新生成错误!");
+  }
+}
+
+async function confirm() {
+  if (!examNumberFillCount.value) {
+    message.error("请输入位数");
+    return;
+  }
+
+  if (loading.value) return;
+  setLoading(true);
+
+  const res = await absentReset({
+    examId: userStore.curExam.id,
+    examNumberFillCount: examNumberFillCount.value,
+  }).catch(() => {});
+
+  if (!res) {
+    setLoading(false);
+    return;
+  }
+
+  startLoopSync();
+}
+</script>
+
+<style lang="less" scoped></style>

+ 191 - 0
src/render/views/AbsentCheck/index.vue

@@ -0,0 +1,191 @@
+<template>
+  <div class="data-check">
+    <div class="check-menu">
+      <div class="check-menu-body">
+        <ul>
+          <li
+            v-for="item in studentList"
+            :key="item.id"
+            @click="onSelectStudent(item)"
+          >
+            {{ item.examNumber }}
+          </li>
+        </ul>
+      </div>
+      <div class="check-menu-page">
+        <SimplePagination
+          :total="total"
+          :page-size="pageSize"
+          @change="onChangeListPage"
+        />
+      </div>
+    </div>
+    <div class="check-body">
+      <ScanImage
+        v-if="dataCheckStore.curPage && isOriginImage && recogList.length"
+        :key="dataCheckStore.curPage.kid"
+        :img-src="dataCheckStore.curPage.sheetUri"
+        :recog-data="recogList"
+        @prev="onPrevPage"
+        @next="onNextPage"
+        @recog-block-modified="onRecogEditConfirm"
+      />
+      <SliceImage v-if="dataCheckStore.curPage && !isOriginImage" />
+    </div>
+
+    <CheckAction @search="onSearch" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed } from "vue";
+import { message } from "ant-design-vue";
+import { CaretLeftOutlined, CaretRightOutlined } from "@ant-design/icons-vue";
+
+import { AbsentCheckListFilter } from "@/ap/types/absentCheck";
+import { DataCheckListItem } from "@/ap/types/dataCheck";
+import { absentCheckList } from "@/ap/absentCheck";
+import { StudentPage } from "./types";
+import { useDataCheckStore } from "@/store";
+
+import SimplePagination from "@/components/SimplePagination/index.vue";
+import ScanImage from "../DataCheck/ScanImage/index.vue";
+import SliceImage from "../DataCheck/SliceImage/index.vue";
+import CheckAction from "./CheckAction.vue";
+
+defineOptions({
+  name: "AbsentCheck",
+});
+
+const dataCheckStore = useDataCheckStore();
+
+let searchModel = {} as AbsentCheckListFilter;
+const pageNumber = ref(1);
+const pageSize = ref(20);
+const total = ref(0);
+const studentList = ref<DataCheckListItem[]>([]);
+const dataList = ref<StudentPage[]>([]);
+const loading = ref(false);
+
+async function getList() {
+  loading.value = true;
+  const datas = {
+    ...searchModel,
+    pageNumber: pageNumber.value,
+    pageSize: pageSize.value,
+  };
+  const res = await absentCheckList(datas).catch(() => null);
+  loading.value = false;
+  if (!res) return;
+
+  total.value = res.totalCount;
+  studentList.value = res.result;
+
+  parseStudentPageList(res.result);
+}
+
+function parseStudentPageList(students: DataCheckListItem[]) {
+  dataList.value = [];
+
+  students.forEach((student, studentIndex) => {
+    student.papers.forEach((paper, paperIndex) => {
+      if (!paper.pages) return;
+      paper.pages.forEach((page, pageIndex) => {
+        dataList.value.push({
+          ...page,
+          paperId: paper.id as number,
+          pageIndex,
+          paperIndex,
+          studentIndex,
+          studentId: student.id,
+          examId: searchModel.examId,
+          kid: `${student.id}-${studentIndex}-${paperIndex}-${pageIndex}`,
+        });
+      });
+    });
+  });
+}
+
+function onSelectStudent(record: DataCheckListItem) {
+  const pageIndex = dataList.value.findIndex(
+    (item) => item.studentId === record.id
+  );
+  if (pageIndex === -1) return;
+
+  selectPage(pageIndex);
+}
+
+// imageType
+const isOriginImage = computed(() => {
+  return dataCheckStore.imageType === "ORIGIN";
+});
+
+// table
+async function onChangeListPage(index: number) {
+  pageNumber.value = index;
+  await getList();
+  selectPage(0);
+}
+async function onSearch(datas: AbsentCheckListFilter) {
+  searchModel = { ...datas };
+  pageNumber.value = 1;
+  await getList();
+  selectPage(0);
+}
+
+// page
+const curStudentInfo = ref({
+  examNumber: "",
+  name: "",
+  examSite: "",
+  seatNumber: "",
+  paperType: "",
+});
+const curPageQuestions = ref<string[]>([]);
+
+function selectPage(index: number) {
+  dataCheckStore.setInfo({
+    curPage: dataList.value[index],
+    curPageIndex: index,
+  });
+
+  if (!dataCheckStore.curPage) return;
+
+  const curStudent = studentList.value[
+    dataCheckStore.curPage.studentIndex
+  ] as DataCheckListItem;
+  dataCheckStore.setInfo({ curStudent });
+}
+
+async function onPrevPage() {
+  if (dataCheckStore.curPageIndex <= 0) {
+    if (pageNumber.value === 1) {
+      message.error("没有上一张了");
+      return;
+    }
+
+    pageNumber.value--;
+    await getList();
+    selectPage(dataList.value.length - 1);
+    return;
+  }
+
+  selectPage(dataCheckStore.curPageIndex - 1);
+}
+
+async function onNextPage() {
+  if (dataCheckStore.curPageIndex >= dataList.value.length - 1) {
+    if (pageNumber.value >= total.value) {
+      message.error("没有下一张了");
+      return;
+    }
+
+    pageNumber.value++;
+    await getList();
+    selectPage(0);
+    return;
+  }
+
+  selectPage(dataCheckStore.curPageIndex + 1);
+}
+</script>

+ 1 - 1
src/render/views/Audit/ImageCheck/index.vue

@@ -51,7 +51,7 @@
     </div>
     <div class="audit-body">
       <!-- <img src="" alt=""> -->
-      <img src="@/components/ScanImage/data/paper.jpg" />
+      <img src="@/assets/imgs/paper.jpg" />
     </div>
     <div class="audit-topinfo">
       <a-space :size="6">

+ 1 - 1
src/render/views/Audit/InTime/index.vue

@@ -75,7 +75,7 @@
             v-for="(page, pindex) in paper.pages"
             :key="pindex"
             class="paper-img"
-            src="@/components/ScanImage/data/paper.jpg"
+            src="@/assets/imgs/paper.jpg"
           />
           <!-- <img
             v-for="(page, pindex) in paper.pages"

+ 1 - 0
src/render/views/CurExam/index.vue

@@ -194,6 +194,7 @@
               <div
                 class="flex items-center cursor-pointer"
                 :style="{ color: token.colorPrimary }"
+                @click="toPage('AbsentCheck')"
               >
                 <span>进入</span>
                 <RightOutlined />

+ 1 - 1
src/render/views/DataCheck/CheckAction.vue

@@ -181,7 +181,7 @@ import { useUserStore, useDataCheckStore } from "@/store";
 import { DataCheckListFilter, ExamStatus } from "@/ap/types/dataCheck";
 import { SubjectItem } from "@/ap/types/base";
 import { dataCheckStudentExport, dataCheckRoomExport } from "@/ap/dataCheck";
-import { examStatusSave } from "@/ap/examStatusCheck";
+import { examStatusSave } from "@/ap/absentCheck";
 import useDictOption from "@/hooks/dictOption";
 import useLoading from "@/hooks/useLoading";
 import { ImageType, booleanOptionList } from "@/constants/enumerate";

+ 0 - 0
src/render/components/ScanImage/FillAreaSetDialog.vue → src/render/views/DataCheck/ScanImage/FillAreaSetDialog.vue


+ 0 - 0
src/render/components/ScanImage/RecogEditDialog.vue → src/render/views/DataCheck/ScanImage/RecogEditDialog.vue


+ 0 - 0
src/render/components/ScanImage/data/paper.jpg → src/render/views/DataCheck/ScanImage/data/paper.jpg


+ 86 - 29
src/render/components/ScanImage/index.vue → src/render/views/DataCheck/ScanImage/index.vue

@@ -9,6 +9,7 @@
       }"
     >
       <img ref="imgRef" src="./data/paper.jpg" alt="p" @load="initImageSize" />
+      <!-- <img ref="imgRef" :src="curPage?.sheetUri" alt="原图" @load="initImageSize" /> -->
       <div class="img-recogs">
         <div
           v-for="(item, index) in recogBlocks"
@@ -40,7 +41,16 @@
         <li @click="onSetRecogStyle"><BgColorsOutlined /></li>
       </ul>
     </div>
-    <div class="img-change" @click="onChangeImage"><PictureFilled /></div>
+    <import-btn
+      upload-url="/api/admin/scan/answer/sheet/update"
+      :format="['jpg', 'png', 'jpeg']"
+      :upload-data="updateSheetData"
+      @upload-success="updateSheetSuccess"
+    >
+      <a-button class="img-change">
+        <template #icon><PictureFilled /></template>
+      </a-button>
+    </import-btn>
   </div>
 
   <!-- FillAreaSetDialog -->
@@ -63,29 +73,39 @@ import {
   RightOutlined,
   PictureFilled,
 } from "@ant-design/icons-vue";
-import { computed, nextTick, ref, watch } from "vue";
+import { computed, nextTick, ref } from "vue";
 import { objAssign } from "@/utils/tool";
 import { vEleMoveDirective } from "@/directives/eleMove";
-import { ImageRecogData, RecogBlock } from "@/utils/recog/recog";
-import { useUserStore } from "@/store";
+import {
+  parseRecogData,
+  parseDetailSize,
+  RecognizeArea,
+  RecogBlock,
+} from "@/utils/recog/recog";
+import { useUserStore, useDataCheckStore } from "@/store";
 
 import FillAreaSetDialog from "./FillAreaSetDialog.vue";
 import RecogEditDialog from "./RecogEditDialog.vue";
-
-import { omit } from "lodash-es";
+import ImportBtn from "@/components/ImportBtn/index.vue";
 
 defineOptions({
   name: "ScanImage",
 });
 
-const props = defineProps<{
-  imgSrc: string;
-  recogData: ImageRecogData[];
-}>();
-
-const emit = defineEmits(["recogBlockModified", "next", "prev"]);
+const emit = defineEmits(["next", "prev"]);
 
 const userStore = useUserStore();
+const dataCheckStore = useDataCheckStore();
+
+const curPage = computed(() => dataCheckStore.curPage);
+const updateSheetData = computed(() => {
+  if (!curPage.value) return {};
+
+  return {
+    paperId: curPage.value.paperId,
+    pageIndex: curPage.value.pageIndex,
+  };
+});
 
 const elRef = ref();
 const imgRef = ref();
@@ -125,7 +145,7 @@ function initImageSize() {
   imageSize.value = objAssign(imageSize.value, imgSize);
 
   nextTick(() => {
-    parseRecogBlocks();
+    updateRecogList();
   });
 }
 
@@ -174,6 +194,35 @@ function getImageSizePos({
   return imageSize;
 }
 
+// recog data
+const recogList = ref<RecognizeArea[]>([]);
+function updateRecogList() {
+  if (!dataCheckStore.curPage) return;
+
+  const regdata = parseRecogData(dataCheckStore.curPage.recogData);
+  if (!regdata) return;
+
+  recogList.value = [] as RecognizeArea[];
+  let index = 0;
+  regdata.question.forEach((gGroup) => {
+    gGroup.fill_result.forEach((qRecog) => {
+      const result = dataCheckStore.curPage.question[index - 1] || "";
+      qRecog.index = ++index;
+      // TODO: 解析其他数据
+
+      const fileResult = result ? result.split("") : [];
+      const recogItem = parseDetailSize(
+        qRecog,
+        "question",
+        qRecog.index,
+        fileResult
+      );
+      recogList.value.push(recogItem);
+    });
+  });
+
+  parseRecogBlocks();
+}
 // recogBlocks
 const recogBlocks = ref<RecogBlock[]>([]);
 const curRecogBlock = ref<RecogBlock | null>(null);
@@ -185,7 +234,7 @@ function parseRecogBlocks() {
     userStore.recogFillSet;
   const curBorderWidth = Math.max(1, borderWidth * rate);
 
-  recogBlocks.value = props.recogData.map((item) => {
+  recogBlocks.value = recogList.value.map((item) => {
     const nitem: RecogBlock = { ...item };
     nitem.areaImg = "";
 
@@ -228,18 +277,29 @@ function parseRecogBlocks() {
 
 // area click
 const recogEditDialogRef = ref();
-async function onAreaClick(data: RecogBlock) {
+function onAreaClick(data: RecogBlock) {
   curRecogBlock.value = data;
   // TODO:build area src img
-  await nextTick();
-  recogEditDialogRef.value?.open();
+  nextTick(() => {
+    recogEditDialogRef.value?.open();
+  });
 }
 
 async function onRecogEditConfirm(result: string[]) {
   if (!curRecogBlock.value) return;
 
-  curRecogBlock.value.result = result;
-  emit("recogBlockModified", curRecogBlock.value);
+  const data = curRecogBlock.value;
+
+  if (data.type === "question") {
+    const index = data.index - 1;
+    dataCheckStore.curPage.question.splice(index, 1, data.result.join(""));
+    await dataCheckStore.updateField({
+      field: data.type,
+      value: dataCheckStore.curPage.question,
+    });
+    curRecogBlock.value.result = result;
+  }
+  // TODO:
 }
 
 // img action
@@ -277,8 +337,13 @@ function onNext() {
 }
 
 // change image
-function onChangeImage() {
-  // TODO:
+function updateSheetSuccess(data: { url: string }) {
+  if (!curPage.value) return;
+  dataCheckStore.modifySheetUri({
+    paperIndex: curPage.value.paperIndex,
+    pageIndex: curPage.value.pageIndex,
+    uri: data.url,
+  });
 }
 
 // set recog style
@@ -286,14 +351,6 @@ const fillAreaSetDialogRef = ref();
 function onSetRecogStyle() {
   fillAreaSetDialogRef.value?.open();
 }
-
-// watch
-watch(
-  () => props.recogData,
-  (val) => {
-    if (val && imgRef.value) parseRecogBlocks();
-  }
-);
 </script>
 
 <style lang="less" scoped>

+ 3 - 2
src/render/components/SliceImage/CutImageDialog.vue → src/render/views/DataCheck/SliceImage/CutImageDialog.vue

@@ -35,7 +35,7 @@ import { SaveOutlined, CloseOutlined } from "@ant-design/icons-vue";
 import useModal from "@/hooks/useModal";
 import { objAssign } from "@/utils/tool";
 
-import ElementResize from "../ElementResize/index.vue";
+import ElementResize from "@/components/ElementResize/index.vue";
 
 defineOptions({
   name: "CutImageDialog",
@@ -182,8 +182,9 @@ async function confirm() {
     console.error(e);
   });
   if (!file) return;
-
   console.log(file);
+  emit("confirm", file);
+  close();
 }
 
 function getSliceImage(

+ 135 - 0
src/render/views/DataCheck/SliceImage/index.vue

@@ -0,0 +1,135 @@
+<template>
+  <div class="slice-image">
+    <div v-for="item in curStudent.papers" :key="item.id" class="image-paper">
+      <div
+        v-for="(page, pindex) in item.pages"
+        :key="pindex"
+        class="image-page"
+      >
+        <h3 class="image-page-title">
+          {{ getPageTitle(item.number, pindex) }}
+        </h3>
+        <div
+          v-for="(url, sindex) in page.sliceUri"
+          :key="sindex"
+          class="image-item"
+        >
+          <img :src="url" :alt="sindex + 1" />
+          <div class="image-action">
+            <import-btn
+              upload-url="/api/admin/scan/answer/slice/update"
+              :format="['jpg', 'png', 'jpeg']"
+              :upload-data="curSliceInfo"
+              @upload-success="updateSliceSuccess"
+            >
+              <a-button
+                class="image-change"
+                @click="onUpdateSlice(item.number, pindex, sindex)"
+              >
+                <template #icon><PictureFilled /></template>
+              </a-button>
+            </import-btn>
+            <a-button
+              class="image-slice"
+              @click="onEditSlice(item.number, pindex, sindex)"
+            >
+              <template #icon><NumberOutlined /></template>
+            </a-button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- CutImageDialog -->
+  <CutImageDialog
+    ref="cutImageDialogRef"
+    :sheet-url="sheetUrl"
+    :slice-selection="curSliceSelection"
+    @confirm="cutImageModified"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from "vue";
+import { NumberOutlined, PictureFilled } from "@ant-design/icons-vue";
+import CutImageDialog from "./CutImageDialog.vue";
+import { uploadSlice } from "@/ap/base";
+import { getFileMD5 } from "@/utils/crypto";
+import { useDataCheckStore } from "@/store";
+
+defineOptions({
+  name: "SliceImage",
+});
+
+const dataCheckStore = useDataCheckStore();
+
+const curStudent = computed(() => {
+  return dataCheckStore.curStudent;
+});
+
+const curSliceSelection = ref<AreaSize>();
+const curSliceInfo = ref({
+  paperIndex: 0,
+  paperId: 0,
+  pageIndex: 0,
+  index: 0,
+});
+
+function getPageTitle(paperNumber, pageIndex) {
+  return `卡${paperNumber}${pageIndex === 0 ? "正面" : "反面"}`;
+}
+
+const cutImageDialogRef = ref();
+function onEditSlice(paperNumber: number, pageIndex: number, index: number) {
+  const paper = curStudent.value.papers[paperNumber - 1];
+  if (!paper) return;
+
+  curSliceInfo.value = {
+    paperIndex: paperNumber - 1,
+    pageIndex,
+    paperId: paper.id as number,
+    index,
+  };
+  curSliceSelection.value = undefined;
+  cutImageDialogRef.value?.open();
+}
+async function cutImageModified(file: File) {
+  const md5 = await getFileMD5(file);
+
+  const { paperId, pageIndex, index } = curSliceInfo.value;
+  const datas = {
+    paperId,
+    pageIndex,
+    index,
+    file,
+    md5,
+  };
+  const res = await uploadSlice(datas).catch(() => {});
+  if (!res) return;
+
+  dataCheckStore.modifySliceUri({
+    ...curSliceInfo.value,
+    uri: res.uri,
+  });
+}
+
+function onUpdateSlice(paperNumber: number, pageIndex: number, index: number) {
+  const paper = curStudent.value.papers[paperNumber - 1];
+  if (!paper) return;
+
+  curSliceInfo.value = {
+    paperIndex: paperNumber - 1,
+    pageIndex,
+    paperId: paper.id as number,
+    index,
+  };
+}
+
+function updateSliceSuccess(data: { url: string }) {
+  dataCheckStore.modifySliceUri({
+    ...curSliceInfo.value,
+    uri: data.url,
+  });
+}
+</script>

+ 9 - 57
src/render/views/DataCheck/index.vue

@@ -30,8 +30,7 @@
         @next="onNextPage"
         @recog-block-modified="onRecogEditConfirm"
       />
-      <!-- TODO: slice image show -->
-      <div v-if="dataCheckStore.curPage && !isOriginImage"></div>
+      <SliceImage v-if="dataCheckStore.curPage && !isOriginImage" />
     </div>
 
     <CheckAction @search="onSearch" />
@@ -45,21 +44,13 @@ import { CaretLeftOutlined, CaretRightOutlined } from "@ant-design/icons-vue";
 
 import { DataCheckListFilter, DataCheckListItem } from "@/ap/types/dataCheck";
 import { dataCheckList } from "@/ap/dataCheck";
-
-import SimplePagination from "@/components/SimplePagination/index.vue";
-import ScanImage from "@/components/ScanImage/index.vue";
-import CheckAction from "./CheckAction.vue";
-import { ImageType } from "@/constants/enumerate";
 import { StudentPage } from "./types";
 import { useDataCheckStore } from "@/store";
 
-import {
-  parseRecogData,
-  parseDetailSize,
-  RecognizeArea,
-  RecogBlock,
-} from "@/utils/recog/recog";
-import { objAssign } from "@/utils/tool";
+import SimplePagination from "@/components/SimplePagination/index.vue";
+import ScanImage from "./ScanImage/index.vue";
+import SliceImage from "./SliceImage/index.vue";
+import CheckAction from "./CheckAction.vue";
 
 defineOptions({
   name: "DataCheck",
@@ -101,6 +92,7 @@ function parseStudentPageList(students: DataCheckListItem[]) {
       paper.pages.forEach((page, pageIndex) => {
         dataList.value.push({
           ...page,
+          paperId: paper.id as number,
           pageIndex,
           paperIndex,
           studentIndex,
@@ -158,9 +150,10 @@ function selectPage(index: number) {
 
   if (!dataCheckStore.curPage) return;
 
-  const curStudent = studentList.value[dataCheckStore.curPage.studentIndex];
+  const curStudent = studentList.value[
+    dataCheckStore.curPage.studentIndex
+  ] as DataCheckListItem;
   dataCheckStore.setInfo({ curStudent });
-  updateRecogList();
 }
 
 async function onPrevPage() {
@@ -194,45 +187,4 @@ async function onNextPage() {
 
   selectPage(dataCheckStore.curPageIndex + 1);
 }
-
-// recog data
-const recogList = ref<RecognizeArea[]>([]);
-function updateRecogList() {
-  if (!dataCheckStore.curPage) return;
-
-  const regdata = parseRecogData(dataCheckStore.curPage.recogData);
-  if (!regdata) return;
-
-  recogList.value = [] as RecognizeArea[];
-  let index = 0;
-  regdata.question.forEach((gGroup) => {
-    gGroup.fill_result.forEach((qRecog) => {
-      const result = dataCheckStore.curPage.question[index - 1] || "";
-      qRecog.index = ++index;
-      // TODO: 解析其他数据
-
-      const fileResult = result ? result.split("") : [];
-      const recogItem = parseDetailSize(
-        qRecog,
-        "question",
-        qRecog.index,
-        fileResult
-      );
-      recogList.value.push(recogItem);
-    });
-  });
-}
-
-async function onRecogEditConfirm(data: RecogBlock) {
-  if (data.type === "question") {
-    const index = data.index - 1;
-    dataCheckStore.curPage.question.splice(index, 1, data.result.join(""));
-    await dataCheckStore.updateField({
-      field: data.type,
-      value: dataCheckStore.curPage.question,
-    });
-  }
-
-  updateRecogList();
-}
 </script>

+ 1 - 0
src/render/views/DataCheck/types.ts

@@ -4,6 +4,7 @@ export interface StudentPage extends PaperPageItem {
   examId: number;
   studentId: number;
   studentIndex: number;
+  paperId: number;
   paperIndex: number;
   pageIndex: number;
   pagePageIndex: number;

+ 9 - 9
src/render/views/ImageCheck/index.vue

@@ -85,14 +85,14 @@ function onViewFailed(index: number) {
 }
 
 onMounted(() => {
-  dataList.value = [
-    {
-      subjectCode: "8956145235",
-      subjectName: "语法基础",
-      imageCheckProgress: 89,
-      failedCount: 10,
-    },
-  ];
-  // getData()
+  // dataList.value = [
+  //   {
+  //     subjectCode: "8956145235",
+  //     subjectName: "语法基础",
+  //     imageCheckProgress: 89,
+  //     failedCount: 10,
+  //   },
+  // ];
+  getData();
 });
 </script>

+ 2 - 6
src/render/views/RecognizeCheck/RecognizeImage.vue

@@ -1,14 +1,10 @@
 <template>
   <div ref="arbitrateImgRef" class="arbitrate-img" @scroll="onImgScroll">
-    <img
-      src="@/components/ScanImage/data/paper.jpg"
-      alt="扫描结果"
-      @load="onImgLoad"
-    />
+    <img src="@/assets/imgs/paper.jpg" alt="扫描结果" @load="onImgLoad" />
     <!-- <img :src="imgSrc" alt="扫描结果" @load="onImgLoad" /> -->
   </div>
   <div ref="imgThumbRef" class="arbitrate-img-thumb">
-    <img src="@/components/ScanImage/data/paper.jpg" alt="扫描结果" />
+    <img src="@/assets/imgs/paper.jpg" alt="扫描结果" />
     <!-- <img :src="imgSrc" alt="扫描结果" /> -->
     <div
       class="arbitrate-img-area"

+ 1 - 4
src/render/views/Review/ReviewImage.vue

@@ -12,10 +12,7 @@
         @scroll="onImgScroll"
       >
         <!-- TODO:测试数据 -->
-        <img
-          src="@/components/ScanImage/data/paper.jpg"
-          :alt="`第${index + 1}页`"
-        />
+        <img src="@/assets/imgs/paper.jpg" :alt="`第${index + 1}页`" />
         <!-- <img :src="item.pages[0].sheetUri" :alt="`第${index + 1}页`" /> -->
       </div>
     </div>

+ 2 - 1
types/app.d.ts

@@ -12,9 +12,10 @@ interface PageBaseParams {
   pageSize: number;
 }
 // SliceImage
-interface StudentSliceImgs {
+interface StudentSliceData {
   id: number;
   papers: Array<{
+    id: number;
     number: number;
     pages: Array<{
       sliceUri: string[];