Bladeren bron

feat: 复核校验细节

zhangjie 9 maanden geleden
bovenliggende
commit
126dff4137

+ 2 - 1
src/render/ap/review.ts

@@ -11,6 +11,7 @@ import {
   ReviewProgressResult,
   ReviewTaskSaveParams,
   ReviewTaskHistoryResult,
+  ReviewWarningTaskExportParams,
 } from "./types/review";
 
 // 违纪导入
@@ -65,7 +66,7 @@ export const reviewTaskReset = (
 
 // 复核校验异常导出
 export const reviewWarningTaskExport = (
-  data: ExamSubjectParams
+  data: ReviewWarningTaskExportParams
 ): Promise<AxiosResponse<Blob>> =>
   request({
     url: "/api/admin/subject/image-check/failed/export",

+ 13 - 0
src/render/ap/types/review.ts

@@ -1,13 +1,20 @@
 import { PageResult } from "./common";
 
 // 复核校验
+export interface ReviewTaskItemPage {
+  index: number;
+  uri: string;
+}
 export interface ReviewTaskListItem {
   id: number;
+  name: string;
+  studentCode: string;
   subjectCode: string;
   subjectName: string;
   examNumber: string;
   markStatus: boolean;
   breachCount: number;
+  pages: ReviewTaskItemPage[];
 }
 
 export interface ReviewProgressResult {
@@ -21,3 +28,9 @@ export interface ReviewTaskSaveParams {
 }
 
 export type ReviewTaskHistoryResult = PageResult<ReviewTaskListItem>;
+
+export interface ReviewWarningTaskExportParams {
+  examId: number;
+  subjectCode: string;
+  type: "student" | "room";
+}

BIN
src/render/assets/imgs/room-icon.png


+ 33 - 0
src/render/components/MyQuote/index.vue

@@ -0,0 +1,33 @@
+<template>
+  <div :class="['my-quote', `is-${type}`]">
+    <template v-if="showIcon">
+      <slot name="icon">
+        <InfoCircleOutlined :rotate="180" />
+      </slot>
+    </template>
+
+    <slot>
+      {{ message }}
+    </slot>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { InfoCircleOutlined } from "@ant-design/icons-vue";
+
+defineOptions({
+  name: "MyQuote",
+});
+
+const props = withDefaults(
+  defineProps<{
+    message: string;
+    type: "primary" | "error" | "warning" | "default";
+    showIcon: boolean;
+  }>(),
+  {
+    type: "default",
+    showIcon: true,
+  }
+);
+</script>

+ 6 - 10
src/render/components/SelectCourse/index.vue

@@ -5,6 +5,7 @@
     :options="optionList"
     filter-option
     :multiple="false"
+    :field-names="fieldNames"
     style="width: 200px"
     v-bind="attrs"
     @change="onChange"
@@ -15,6 +16,7 @@
 <script setup lang="ts">
 import { ref, useAttrs, watch } from "vue";
 import { subjectList } from "@/ap/base";
+import { SubjectItem } from "@/ap/types/base";
 
 defineOptions({
   name: "SelectCourse",
@@ -32,26 +34,20 @@ const props = withDefaults(
 const emit = defineEmits(["update:modelValue", "change"]);
 const attrs = useAttrs();
 
-interface optionListItem {
-  value: string;
-  label: string;
-}
+const fieldNames = { label: "subjectName", value: "subjectCode" };
 
 const selected = ref("");
-const optionList = ref<optionListItem[]>([]);
+const optionList = ref<SubjectItem[]>([]);
 const search = async () => {
   optionList.value = [];
   const resData = await subjectList({ examId: 1 });
-
-  optionList.value = (resData || []).map((item) => {
-    return { ...item, value: item.subjectCode, label: item.subjectName };
-  });
+  optionList.value = resData || [];
 };
 // search();
 
 const onChange = () => {
   const selectedData = optionList.value.filter(
-    (item) => selected.value === item.value
+    (item) => selected.value === item.subjectCode
   );
   emit("update:modelValue", selected.value);
   emit("change", selectedData[0]);

+ 2 - 0
src/render/components/register.ts

@@ -22,6 +22,7 @@ import {
 import MyModal from "./MyModal/index.vue";
 import FooterInfo from "./FooterInfo/index.vue";
 import SelectCourse from "./SelectCourse/index.vue";
+import MyQuote from "./MyQuote/index.vue";
 import Accordion from "./Accordion/index.vue";
 
 use([
@@ -47,6 +48,7 @@ export default {
     Vue.component("MyModal", MyModal);
     Vue.component("FooterInfo", FooterInfo);
     Vue.component("SelectCourse", SelectCourse);
+    Vue.component("MyQuote", MyQuote);
     Vue.component("Accordion", Accordion);
   },
 };

+ 2 - 4
src/render/views/ResultExport/BreachImport.vue

@@ -1,12 +1,10 @@
 <template>
-  <a-alert
+  <my-quote
     message="注:系统已自动将所有考生违纪字段置为“0”,导入后会更新该字段,导入只更新导入的考生信息,其它考生不更新。"
     type="error"
-    show-icon
     style="margin-bottom: 16px"
   >
-    <template #icon><ExclamationCircleOutlined /></template>
-  </a-alert>
+  </my-quote>
 
   <a-table
     :columns="columns"

+ 2 - 4
src/render/views/ResultExport/StudentStatus.vue

@@ -1,12 +1,10 @@
 <template>
-  <a-alert
+  <my-quote
     message="注:系统已自动将所有考生违纪字段置为“0”,导入后会更新该字段,导入只更新导入的考生信息,其它考生不更新。"
     type="error"
-    show-icon
     style="margin-bottom: 16px"
   >
-    <template #icon><ExclamationCircleOutlined /></template>
-  </a-alert>
+  </my-quote>
 
   <a-table
     :columns="columns"

+ 44 - 0
src/render/views/Review/ExportTypeDialog.vue

@@ -0,0 +1,44 @@
+<template>
+  <a-modal
+    v-model:open="visible"
+    :width="456"
+    style="top: 10vh"
+    :footer="null"
+    title="选择导出类型"
+  >
+    <a-row :gutter="16">
+      <a-col :span="12">
+        <div class="type-box" @click="seleted('student')">
+          <img src="@/assets/imgs/scan_login_icon.png" alt="按考生导出" />
+          <p>按考生导出</p>
+          <p>EXPORT BY CANDIDATE</p>
+        </div>
+      </a-col>
+      <a-col :span="12">
+        <div class="type-box" @click="seleted('room')">
+          <img src="@/assets/imgs/scan_login_icon.png" alt="按考场导出" />
+          <p>按考场导出</p>
+          <p>EXPORT BY TEST ROOM</p>
+        </div>
+      </a-col>
+    </a-row>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import useModal from "@/hooks/useModal";
+
+defineOptions({
+  name: "ExportTypeDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const emit = defineEmits(["confirm"]);
+
+function seleted(type: "student" | "room") {
+  emit("confirm", type);
+}
+</script>

+ 87 - 0
src/render/views/Review/ResetConfirmDialog.vue

@@ -0,0 +1,87 @@
+<template>
+  <a-modal v-model:open="visible" :width="424" style="top: 10vh" @ok="confirm">
+    <template #title> 重置 </template>
+
+    <my-quote :message="quoteContent" type="error"></my-quote>
+
+    <a-form
+      ref="formRef"
+      :model="formData"
+      :rules="rules"
+      :label-col="{ style: { width: '110px' } }"
+    >
+      <a-form-item name="password" label="管理员密码">
+        <a-input
+          v-model:value="formData.password"
+          placeholder="请输入"
+        ></a-input>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref, watch } from "vue";
+import type { UnwrapRef } from "vue";
+import { message } from "ant-design-vue";
+
+import useModal from "@/hooks/useModal";
+import { useUserStore } from "@/store";
+import { reviewTaskReset } from "@/ap/review";
+import { SubjectItem } from "@/ap/types/base";
+
+defineOptions({
+  name: "ResetConfirmDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+const props = defineProps<{
+  subject: SubjectItem | null;
+}>();
+
+const emit = defineEmits(["confirm"]);
+
+const userStore = useUserStore();
+
+const quoteContent = computed(() => {
+  return `将重置${userStore.curExam.name}${
+    props.subject?.subjectCode || ""
+  }的复核校验任务,请输入当前管理员密码`;
+});
+
+const formRef = ref();
+const formData: UnwrapRef<{ password: string }> = reactive({
+  password: "",
+});
+const rules: FormRules<"password"> = {
+  password: [
+    {
+      required: true,
+      message: "请输入",
+      trigger: "change",
+    },
+  ],
+};
+
+/* confirm */
+async function confirm() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+
+  // TODO:校验管理员密码是否正确
+
+  const res = await reviewTaskReset({
+    examId: userStore.curExam.id,
+    subjectCode: props.subject?.subjectCode || "",
+  }).catch(() => false);
+  if (!res) return;
+
+  message.success("操作成功");
+
+  emit("confirm", formData);
+  close();
+}
+</script>

+ 34 - 34
src/render/views/Review/ReviewAction.vue

@@ -24,6 +24,7 @@
             v-model:value="searchCourseCode"
             placeholder="请选择科目"
             :options="courses"
+            :field-names="fieldNames"
             filter-option
             style="width: 200px"
           ></a-select>
@@ -39,6 +40,7 @@
             v-model:value="exportCourseCode"
             placeholder="请选择科目"
             :options="courses"
+            :field-names="fieldNames"
             filter-option
             style="width: 200px"
           ></a-select>
@@ -62,6 +64,7 @@
             v-model:value="resetCourseCode"
             placeholder="请选择科目"
             :options="courses"
+            :field-names="fieldNames"
             filter-option
             style="width: 200px"
           ></a-select>
@@ -75,7 +78,7 @@
       <a-collapse-panel>
         <template #header><PushpinFilled />复核标记 </template>
 
-        <a-radio-group v-model:value="historyResult" @change="onHistoryMark">
+        <a-radio-group v-model:value="historyResult" @change="onMark">
           <a-radio :value="1">正常</a-radio>
           <a-radio :value="0">异常</a-radio>
         </a-radio-group>
@@ -111,6 +114,9 @@
       />
     </div>
   </div>
+
+  <!-- ExportTypeDialog -->
+  <ExportTypeDialog ref="exportTypeDialogRef" @confirm="onExportConfirm" />
 </template>
 
 <script setup lang="ts">
@@ -124,12 +130,9 @@ import {
 } from "@ant-design/icons-vue";
 import { showConfirm } from "@/utils/uiUtils";
 
-import {
-  reviewWarningTaskExport,
-  reviewTaskHistory,
-  reviewTaskSave,
-} from "@/ap/review";
+import { reviewWarningTaskExport, reviewTaskHistory } from "@/ap/review";
 import { ReviewTaskListItem } from "@/ap/types/review";
+import { SubjectItem } from "@/ap/types/base";
 
 import { downloadByApi } from "@/utils/download";
 import useTable from "@/hooks/useTable";
@@ -137,6 +140,7 @@ import useLoading from "@/hooks/useLoading";
 import { useUserStore, useReviewStore } from "@/store";
 
 import SimplePagination from "@/components/SimplePagination/index.vue";
+import ExportTypeDialog from "./ExportTypeDialog.vue";
 
 defineOptions({
   name: "ReviewAction",
@@ -146,6 +150,8 @@ const emit = defineEmits(["search", "reset", "mark"]);
 const userStore = useUserStore();
 const reviewStore = useReviewStore();
 
+const fieldNames = { label: "subjectName", value: "subjectCode" };
+
 // tab
 const reviewKey = ref("1");
 
@@ -159,19 +165,10 @@ async function switchTab(key: "review" | "history") {
 }
 
 // course data
-interface optionListItem {
-  value: string;
-  label: string;
-}
-const courses = ref<optionListItem[]>([]);
+const courses = ref<SubjectItem[]>([]);
 async function getCourses() {
   const res = await subjectList({ examId: userStore.curExam.id });
-  courses.value = (res || []).map((item) => {
-    return {
-      value: item.subjectCode,
-      label: item.subjectName,
-    };
-  });
+  courses.value = res || [];
 }
 
 const searchCourseCode = ref("");
@@ -201,29 +198,31 @@ function onSearch() {
 }
 
 function onReset() {
-  emit("reset", resetCourseCode.value);
+  let subjectData: SubjectItem | null = null;
+  if (resetCourseCode.value) {
+    subjectData = courses.value.find(
+      (item) => item.subjectCode === resetCourseCode.value
+    );
+    subjectData = subjectData || null;
+  }
+  emit("reset", subjectData);
 }
 
 function onMark() {
-  emit("mark", result.value);
-}
-
-async function onHistoryMark() {
-  if (!reviewStore.curTask) return;
-
-  const res = await reviewTaskSave({
-    id: reviewStore.curTask.id,
-    result: historyResult.value,
-  });
-  reviewStore.setInfo({
-    curTask: Object.assign({}, reviewStore.curTask, {
-      markStatus: historyResult.value,
-    }),
-  });
+  emit(
+    "mark",
+    reviewStore.tabKey === "review" ? result.value : historyResult.value
+  );
 }
 
+// 导出
 const { loading: downloading, setLoading } = useLoading();
-async function onExport() {
+const exportTypeDialogRef = ref();
+function onExport() {
+  if (downloading.value) return;
+  exportTypeDialogRef.value?.open();
+}
+async function onExportConfirm(type: "student" | "room") {
   if (downloading.value) return;
 
   setLoading(true);
@@ -231,6 +230,7 @@ async function onExport() {
     reviewWarningTaskExport({
       examId: userStore.curExam.id,
       subjectCode: exportCourseCode.value,
+      type,
     })
   ).catch((e: Error) => {
     message.error(e.message || "下载失败,请重新尝试!");

+ 54 - 0
src/render/views/Review/ReviewImage.vue

@@ -0,0 +1,54 @@
+<template>
+  <div v-if="reviewStore.curTask" class="review-image">
+    <div
+      class="review-image-box"
+      v-for="(item, index) in reviewStore.curTask.pages"
+      :key="index"
+      :style="boxStyle"
+    >
+      <div class="review-image-item" :data-index="index" @scroll="onImgScroll">
+        <img :src="item.uri" alt="第一页" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useReviewStore } from "@/store";
+import { computed } from "vue";
+
+defineOptions({
+  name: "ReviewImage",
+});
+
+const reviewStore = useReviewStore();
+
+const boxStyle = computed(() => {
+  if (!reviewStore.curTask || !reviewStore.curTask.pages.length) return {};
+
+  return {
+    height: `${100 / reviewStore.curTask.pages.length}%`,
+  };
+});
+
+let curTargetIndex = "";
+function onImgScroll(e: Event) {
+  e.preventDefault();
+
+  const targetDom = e.target as HTMLElement;
+  const targetIndex = targetDom.getAttribute("data-index");
+  if (curTargetIndex && targetIndex !== curTargetIndex) return;
+
+  const curTargetIndex = targetIndex;
+  const siblingsDoms = (targetDom.parentElement as HTMLElement)
+    .querySelectorAll("review-image-item")
+    .forEach((elem) => {
+      if (elem.getAttribute("data-index") === curTargetIndex) return;
+      elem.scrollTop = targetDom.scrollTop;
+    });
+
+  setTimeout(() => {
+    curTargetIndex = "";
+  }, 100);
+}
+</script>

+ 27 - 0
src/render/views/Review/ReviewMarkPan.vue

@@ -0,0 +1,27 @@
+<template>
+  <div class="review-mark-pan">
+    <div class="pan-head"></div>
+    <div class="pan-body">
+      <p>{{ reviewStore.curTask?.examNumber }}</p>
+      <p>{{ reviewStore.curTask?.name }}</p>
+
+      <a-button block @click="onMark">正常 (Enter)</a-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useReviewStore } from "@/store";
+
+defineOptions({
+  name: "ReviewMarkPan",
+});
+
+const emit = defineEmits(["mark"]);
+
+const reviewStore = useReviewStore();
+
+function onMark() {
+  emit("mark", true);
+}
+</script>

+ 59 - 16
src/render/views/Review/index.vue

@@ -24,10 +24,21 @@
     </div>
 
     <!-- body -->
-    <div class="review-body"></div>
+    <div class="review-body">
+      <ReviewImage />
+    </div>
 
     <!-- action -->
     <ReviewAction @mark="onMark" @reset="onReset" @search="onSearch" />
+
+    <!-- reset confirm -->
+    <ResetConfirmDialog
+      ref="resetConfirmDialogRef"
+      :subject="resetSubject"
+      @confirm="onResetConfirm"
+    />
+    <!-- ReviewMarkPan -->
+    <ReviewMarkPan v-if="reviewStore.curTask" @mark="onMark" />
   </div>
 </template>
 
@@ -47,13 +58,18 @@ import {
 } from "@ant-design/icons-vue";
 import { message } from "ant-design-vue";
 
-import { showConfirm } from "@/utils/uiUtils";
 import { reviewTaskList, reviewTaskReset, reviewTaskSave } from "@/ap/review";
 import { ReviewTaskListItem } from "@/ap/types/review";
+import { SubjectItem } from "@/ap/types/base";
 
-import ReviewAction from "./ReviewAction.vue";
 import { useUserStore, useReviewStore } from "@/store";
 import useLoading from "@/hooks/useLoading";
+
+import ReviewAction from "./ReviewAction.vue";
+import ReviewImage from "./ReviewImage.vue";
+import ResetConfirmDialog from "./ResetConfirmDialog.vue";
+import ReviewMarkPan from "./ReviewMarkPan.vue";
+
 const userStore = useUserStore();
 const reviewStore = useReviewStore();
 
@@ -132,6 +148,14 @@ async function onMark(result: boolean) {
 
   try {
     await reviewTaskSave({ id: reviewStore.curTask.id, result });
+    reviewStore.setInfo({
+      curTask: Object.assign({}, reviewStore.curTask, {
+        markStatus: result,
+      }),
+    });
+
+    if (reviewStore.tabKey === "history") return;
+
     await getNextTask();
   } catch (error) {
     loading.value = false;
@@ -146,20 +170,15 @@ async function onSearch(subjectCode: string) {
   setCurTask();
 }
 
-async function onReset(subjectCode: string) {
-  const confirm = await showConfirm({
-    content: "确定要重置任务吗?",
-  }).catch(() => false);
-  if (confirm !== "confirm") return;
-
-  const res = await reviewTaskReset({
-    examId: userStore.curExam.id,
-    subjectCode,
-  }).catch(() => false);
-  if (!res) return;
+const resetConfirmDialogRef = ref();
+const resetSubject = ref<SubjectItem | null>(null);
+async function onReset(data: SubjectItem | null) {
+  resetSubject.value = data;
+  resetConfirmDialogRef.value?.open();
+}
 
-  message.success("操作成功");
-  await onSearch("");
+function onResetConfirm() {
+  onSearch("");
 }
 
 // watch
@@ -173,4 +192,28 @@ watch(
     }
   }
 );
+
+// 键盘事件
+function registKeyEvent() {
+  document.addEventListener("keydown", keyEventHandle);
+}
+function removeKeyEvent() {
+  document.removeEventListener("keydown", keyEventHandle);
+}
+
+function keyEventHandle(e: KeyboardEvent) {
+  if (e.code === "Enter") {
+    e.preventDefault();
+    onMark(true);
+    return;
+  }
+}
+
+onMounted(() => {
+  registKeyEvent();
+});
+
+onBeforeUnmount(() => {
+  removeKeyEvent();
+});
 </script>