Prechádzať zdrojové kódy

feat: 数据检查功能区

zhangjie 9 mesiacov pred
rodič
commit
3fb0472fa7

+ 1 - 0
src/render/components.d.ts

@@ -33,6 +33,7 @@ declare module 'vue' {
     ATabPane: typeof import('@qmth/ui')['TabPane']
     ATabs: typeof import('@qmth/ui')['Tabs']
     ATag: typeof import('@qmth/ui')['Tag']
+    ATextarea: typeof import('@qmth/ui')['Textarea']
     AUpload: typeof import('@qmth/ui')['Upload']
     QmButton: typeof import('@qmth/ui')['QmButton']
     QmConfigProvider: typeof import('@qmth/ui')['QmConfigProvider']

+ 12 - 3
src/render/components/ScanImage/index.vue

@@ -27,8 +27,10 @@
       </div>
     </div>
     <div class="img-guide">
-      <div class="img-guide-icon is-left"><LeftOutlined /></div>
-      <div class="img-guide-icon is-right"><RightOutlined /></div>
+      <div class="img-guide-icon is-left" @click="onPrev"><LeftOutlined /></div>
+      <div class="img-guide-icon is-right" @click="onNext">
+        <RightOutlined />
+      </div>
     </div>
     <div class="img-actions">
       <ul>
@@ -75,7 +77,7 @@ const props = defineProps<{
   recogData: ImageRecogData[];
 }>();
 
-const emit = defineEmits(["area-click"]);
+const emit = defineEmits(["area-click", "next", "prev"]);
 
 const userStore = useUserStore();
 
@@ -254,6 +256,13 @@ function onMoveImg({ left, top }: PosSize) {
   imageSize.value.top = top;
 }
 
+function onPrev() {
+  emit("prev");
+}
+function onNext() {
+  emit("next");
+}
+
 // change image
 function onChangeImage() {
   // TODO:

+ 29 - 0
src/render/constants/enumerate.ts

@@ -0,0 +1,29 @@
+export const DEFAULT_LABEL = "--";
+
+export const DATA_CHECK_TYPE = {
+  1: "缺考有作答",
+  2: "客观题无作答,主观题有作答;",
+  3: "不缺考,无条码,有作答;(正常或待审核考生,条码为空,有作答)",
+  4: "不缺考,无条码,无作答;(正常或待审核考生,条码为空,没有作答)",
+  5: "条码异常(识别的条码没与库中匹配上)",
+  6: "缺考有条码",
+};
+
+export const PAPER_TYPE_STATUS = {
+  OK: "正常",
+  BLANK: "空白",
+  ERROR: "异常",
+};
+
+export const EXAM_STATUS = {
+  OK: "正常",
+  ABSENT: "缺考",
+  UNCHECK: "待审核",
+};
+
+export const IMAGE_TYPE = {
+  ORIGIN: "原图",
+  SLICE: "裁切图",
+};
+
+export type ImageType = keyof typeof IMAGE_TYPE;

+ 50 - 0
src/render/hooks/dictOption.ts

@@ -0,0 +1,50 @@
+import { ref } from "vue";
+import {
+  DEFAULT_LABEL,
+  DATA_CHECK_TYPE,
+  PAPER_TYPE_STATUS,
+  EXAM_STATUS,
+  IMAGE_TYPE,
+} from "@/constants/enumerate";
+
+const dicts = {
+  DATA_CHECK_TYPE,
+  PAPER_TYPE_STATUS,
+  EXAM_STATUS,
+  IMAGE_TYPE,
+};
+
+type DictTypeType = keyof typeof dicts;
+
+/**
+ * 将字典数据转成为option列表数据
+ * @param data 字典数据
+ * @returns option列表
+ */
+export function dictToOption(dict: Record<any, string>) {
+  const options = [] as Array<{
+    value: number | string | boolean;
+    label: string;
+  }>;
+  Object.keys(dict).forEach((k) => {
+    options.push({
+      value: k,
+      label: dict[k],
+    });
+  });
+  return options;
+}
+
+export default function useDictOption(dictType: DictTypeType) {
+  const optionList = ref<SelectOption[]>(dictToOption(dicts[dictType] || {}));
+
+  function getLabel(val: string): string {
+    // @ts-ignore
+    return dicts[dictType] ? dicts[dictType][val] : DEFAULT_LABEL;
+  }
+
+  return {
+    optionList,
+    getLabel,
+  };
+}

+ 47 - 0
src/render/styles/antui-reset.less

@@ -42,7 +42,54 @@
   }
 }
 
+// ant-collapse
+.ant-collapse {
+  border-radius: 0;
+
+  .ant-collapse-item {
+    border-radius: 0;
+    border: none;
+
+    .ant-collapse-header {
+      height: 34px;
+      line-height: 22px;
+      padding: 5px 16px;
+      border-top: 1px solid @border-color1;
+      border-bottom: 1px solid @border-color1;
+      background: linear-gradient(180deg, #ffffff 0%, #f2f3f5 100%);
+
+      .ant-collapse-arrow {
+        color: @text-color3;
+      }
+    }
+
+    .ant-collapse-header-text {
+      .anticon {
+        color: #bfbfbf;
+        margin-right: 8px;
+      }
+    }
+
+    .ant-collapse-content {
+      background-color: #fff;
+
+      .ant-collapse-content-box {
+        padding: 10px 14px 10px 14px;
+      }
+    }
+  }
+}
+
 // .ant-btn
+.ant-btn {
+  padding-left: 12px;
+  padding-right: 12px;
+
+  &.ant-simple {
+    padding-left: 0;
+    padding-right: 0;
+  }
+}
 .ant-btn-success {
   color: #fff;
   background-color: @success-color;

+ 28 - 31
src/render/styles/pages.less

@@ -566,45 +566,14 @@
 
   // ant
   .ant-collapse {
-    border-radius: 0;
-
-    &-item {
-      border-radius: 0 !important;
-      border: none !important;
-    }
-
-    &-header {
-      height: 34px;
-      line-height: 22px !important;
-      padding: 5px 16px !important;
-      border-top: 1px solid @border-color1;
-      border-bottom: 1px solid @border-color1;
-      background: linear-gradient(180deg, #ffffff 0%, #f2f3f5 100%);
-    }
-
-    &-header-text {
-      .anticon {
-        color: #bfbfbf;
-        margin-right: 8px;
-      }
-    }
-
     &-expand-icon {
       display: none !important;
     }
 
-    &-content {
-      background-color: #fff !important;
-    }
-
     &-content-box {
       padding: 10px 0 10px 16px !important;
     }
   }
-  .ant-btn {
-    padding-left: 12px;
-    padding-right: 12px;
-  }
 }
 
 .task-list {
@@ -851,6 +820,34 @@
     overflow-x: hidden;
     overflow-y: auto;
     background-color: #fff;
+
+    .exam-number {
+      position: relative;
+      width: 195px;
+      display: inline-block;
+      vertical-align: middle;
+
+      .ant-input {
+        padding-right: 58px;
+      }
+
+      .number-suffix {
+        position: absolute;
+        color: @text-color3;
+        height: 22px;
+        line-height: 22px;
+        top: 5px;
+        right: 5px;
+        z-index: 8;
+      }
+    }
+
+    .ant-form-item {
+      margin-bottom: 8px;
+    }
+    .ant-form-item-label > label {
+      color: @text-color2;
+    }
   }
   .check-body {
     flex-grow: 2;

+ 288 - 0
src/render/views/DataCheck/CheckAction.vue

@@ -0,0 +1,288 @@
+<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="科目">
+            <a-select
+              v-model:value="searchSubjectCode"
+              placeholder="请选择"
+              :options="courses"
+              :field-names="fieldNames"
+              filter-option
+              style="width: 150px"
+            ></a-select>
+          </a-form-item>
+          <a-form-item label="查找条件">
+            <a-select
+              v-model:value="searchDataCheckType"
+              placeholder="请选择"
+              :options="dataCheckOptions"
+            ></a-select>
+          </a-form-item>
+        </a-form>
+        <div class="box-justify">
+          <div></div>
+          <div>
+            <a-button class="m-r-8px" type="primary" @click="onSearch"
+              >查询</a-button
+            >
+            <a-button @click="onExport('common')">导出</a-button>
+          </div>
+        </div>
+      </a-collapse-panel>
+      <a-collapse-panel key="2">
+        <template #header><FilterFilled />搜索条件 (自定义)</template>
+
+        <a-form :label-col="{ style: { width: '83px' } }">
+          <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-form-item label="查找条件">
+            <a-input
+              v-model:value="searchModel.name"
+              placeholder="请输入"
+              style="width: 150px"
+            ></a-input>
+          </a-form-item>
+          <a-form-item label="科目">
+            <a-select
+              v-model:value="searchCustomSubjectCode"
+              placeholder="请选择"
+              :options="courses"
+              :field-names="fieldNames"
+              filter-option
+              style="width: 150px"
+            ></a-select>
+          </a-form-item>
+          <a-row>
+            <a-col :span="12">
+              <a-form-item label="客观题作答">
+                <a-select
+                  v-model:value="searchModel.questionFilled"
+                  placeholder="请选择"
+                  :options="booleanOptions"
+                  style="width: 85px"
+                ></a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="12">
+              <a-form-item label="主观题作答">
+                <a-select
+                  v-model:value="searchModel.subjectiveFilled"
+                  placeholder="请选择"
+                  :options="booleanOptions"
+                  style="width: 85px"
+                ></a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="12">
+              <a-form-item label="有作答">
+                <a-select
+                  v-model:value="searchModel.subjectiveFilled"
+                  placeholder="请选择"
+                  :options="booleanOptions"
+                  style="width: 85px"
+                ></a-select>
+              </a-form-item>
+            </a-col>
+            <a-col :span="12">
+              <a-form-item label="试卷类型">
+                <a-select
+                  v-model:value="searchModel.paperTypeStatus"
+                  placeholder="请选择"
+                  :options="paperTypeOptions"
+                  style="width: 85px"
+                ></a-select>
+              </a-form-item>
+            </a-col>
+          </a-row>
+          <div class="box-justify">
+            <a-form-item label="缺考">
+              <a-radio-group
+                v-model:value="searchModel.incomplete"
+                name="incomplete"
+                :options="booleanOptions"
+              >
+              </a-radio-group>
+            </a-form-item>
+
+            <div>
+              <a-button class="m-r-8px" type="primary" @click="onCustomSearch"
+                >查询</a-button
+              >
+              <a-button @click="onExport('custom')">导出</a-button>
+            </div>
+          </div>
+        </a-form>
+      </a-collapse-panel>
+      <a-collapse-panel key="3">
+        <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="4">
+        <template #header><IdcardFilled />题卡信息 </template>
+      </a-collapse-panel>
+    </a-collapse>
+  </div>
+
+  <!-- ExportTypeDialog -->
+  <ExportTypeDialog ref="exportTypeDialogRef" @confirm="onExportConfirm" />
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from "vue";
+import { subjectList } from "@/ap/base";
+import {
+  FilterFilled,
+  PictureFilled,
+  IdcardFilled,
+} from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+
+import { useUserStore } from "@/store";
+import { DataCheckListFilter } from "@/ap/types/dataCheck";
+import { SubjectItem } from "@/ap/types/base";
+import { dataCheckStudentExport, dataCheckRoomExport } from "@/ap/dataCheck";
+import useDictOption from "@/hooks/dictOption";
+import useLoading from "@/hooks/useLoading";
+import type { ImageType } from "@/constants/enumerate";
+import { downloadByApi } from "@/utils/download";
+
+import ExportTypeDialog from "../Review/ExportTypeDialog.vue";
+
+defineOptions({
+  name: "CheckAction",
+});
+const emit = defineEmits(["search", "imageTypeChange"]);
+
+const userStore = useUserStore();
+const { optionList: dataCheckOptions } = useDictOption("DATA_CHECK_TYPE");
+const { optionList: paperTypeOptions } = useDictOption("PAPER_TYPE_STATUS");
+const { optionList: imageTypeOptions } = useDictOption("IMAGE_TYPE");
+const booleanOptions = ref([
+  {
+    label: "是",
+    value: true,
+  },
+  {
+    label: "否",
+    value: false,
+  },
+]);
+const panelKey = ref(["1", "2", "3", "4"]);
+
+// course data
+const courses = ref<SubjectItem[]>([]);
+async function getCourses() {
+  const res = await subjectList({ examId: userStore.curExam.id });
+  courses.value = res || [];
+}
+const fieldNames = { label: "subjectName", value: "subjectCode" };
+
+// search
+const initSearchModel = {
+  examId: userStore.curExam.id,
+  status: "",
+  examStatus: "",
+  examNumber: "",
+  studentCode: "",
+  name: "",
+  packageCode: "",
+  campusCode: "",
+  subjectCode: "",
+  examSite: "",
+  examRoom: "",
+  province: "",
+  paperTypeStatus: "",
+  device: "",
+  absentSuspect: null,
+  omrAbsent: null,
+  assigned: null,
+  incomplete: null,
+  questionFilled: null,
+  subjectiveFilled: null,
+  withOmrDetail: null,
+};
+const searchModel = reactive<DataCheckListFilter>({ ...initSearchModel });
+const searchSubjectCode = ref("");
+const searchCustomSubjectCode = ref("");
+const searchDataCheckType = ref();
+const imageType = ref("" as ImageType);
+const actionType = ref("common");
+
+const examNumberCountCont = computed(() => {
+  const examNumbers = (searchModel.examNumber || "")
+    .split("\n")
+    .filter((item) => item);
+  return `${examNumbers.length}/100`;
+});
+
+function getSearchModel() {
+  return { ...searchModel, subjectCode: searchSubjectCode.value };
+}
+function getCustomSearchModel() {
+  return {
+    ...searchModel,
+    subjectCode: searchCustomSubjectCode.value,
+  };
+}
+function onSearch() {
+  emit("search", getSearchModel());
+}
+function onCustomSearch() {
+  emit("search", getCustomSearchModel());
+}
+
+function onImageTypeChange() {
+  emit("imageTypeChange", imageType.value);
+}
+
+// 导出
+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" ? dataCheckStudentExport : dataCheckRoomExport;
+  const params =
+    actionType.value === "common" ? getSearchModel() : getCustomSearchModel();
+
+  const res = await downloadByApi(() => func(params)).catch((e: Error) => {
+    message.error(e.message || "下载失败,请重新尝试!");
+  });
+  setLoading(false);
+
+  if (!res) return;
+  message.success("导出成功!");
+}
+</script>

+ 5 - 2
src/render/views/DataCheck/index.vue

@@ -17,12 +17,14 @@
     <div class="check-body">
       <ScanImage v-if="curRecogData.length" :recog-data="curRecogData" />
     </div>
-    <div class="check-action"></div>
+
+    <CheckAction />
   </div>
 </template>
 
 <script setup lang="ts">
 import { ref, reactive, onMounted } from "vue";
+import { message } from "ant-design-vue";
 import { CaretLeftOutlined, CaretRightOutlined } from "@ant-design/icons-vue";
 
 import useTable from "@/hooks/useTable";
@@ -33,6 +35,8 @@ import { useUserStore } from "@/store";
 
 import SimplePagination from "@/components/SimplePagination/index.vue";
 import ScanImage from "@/components/ScanImage/index.vue";
+import CheckAction from "./CheckAction.vue";
+
 import recogSampleData from "@/utils/recog/data.json";
 import { parseDetailSize } from "@/utils/recog/recog";
 
@@ -80,7 +84,6 @@ recogSampleData.question.forEach((gGroup) => {
     curRecogData.value.push(parseDetailSize(qRecog, "question", qRecog.index));
   });
 });
-console.log(curRecogData.value);
 
 // TODO: 测试数据
 dataList.value = "#"

+ 1 - 1
src/render/views/RecognizeCheck/RecognizeArbitrate.vue

@@ -360,7 +360,7 @@ function selectOption(option: string) {
     return;
   }
 
-  const result = curArbitrateTaskDetail.value.result.filter(
+  let result = curArbitrateTaskDetail.value.result.filter(
     (item) => item === "#"
   );
   if (result.includes(option)) {

+ 2 - 0
src/render/views/Review/ReviewAction.vue

@@ -147,6 +147,8 @@ import {
   RightSquareFilled,
   PushpinFilled,
 } from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+
 import { showConfirm } from "@/utils/uiUtils";
 
 import { reviewWarningTaskExport, reviewTaskHistory } from "@/ap/review";

+ 5 - 0
types/global.d.ts

@@ -14,4 +14,9 @@ declare global {
   }
 
   type FormRules<T extends string> = Partial<Record<T, Rule[]>>;
+
+  interface SelectOption {
+    value: string | number | boolean;
+    label: string;
+  }
 }