Kaynağa Gözat

feat: 导入下载

zhangjie 10 ay önce
ebeveyn
işleme
9e613ae7a3

+ 1 - 0
package.json

@@ -43,6 +43,7 @@
     "@types/lodash-es": "^4.17.12",
     "@types/mockjs": "^1.0.10",
     "@types/node": "^20.6.1",
+    "@types/spark-md5": "^3.0.4",
     "@vitejs/plugin-vue": "^4.3.4",
     "@vitejs/plugin-vue-jsx": "3.0.1",
     "@vue/compiler-sfc": "^3.3.4",

+ 6 - 0
pnpm-lock.yaml

@@ -11,6 +11,7 @@ specifiers:
   '@types/lodash-es': ^4.17.12
   '@types/mockjs': ^1.0.10
   '@types/node': ^20.6.1
+  '@types/spark-md5': ^3.0.4
   '@vitejs/plugin-vue': ^4.3.4
   '@vitejs/plugin-vue-jsx': 3.0.1
   '@vue/compiler-sfc': ^3.3.4
@@ -58,6 +59,7 @@ specifiers:
 dependencies:
   '@ant-design/icons-vue': 7.0.1_vue@3.5.0
   '@qmth/ui': 1.0.15_typescript@5.5.4
+  '@types/spark-md5': 3.0.4
   '@vueuse/core': 10.11.1_vue@3.5.0
   axios: 1.7.7
   core-js: 3.38.1
@@ -2637,6 +2639,10 @@ packages:
       '@types/node': 20.16.3
     dev: true
 
+  /@types/spark-md5/3.0.4:
+    resolution: {integrity: sha512-qtOaDz+IXiNndPgYb6t1YoutnGvFRtWSNzpVjkAPCfB2UzTyybuD4Tjgs7VgRawum3JnJNRwNQd4N//SvrHg1Q==}
+    dev: false
+
   /@types/verror/1.10.10:
     resolution: {integrity: sha512-l4MM0Jppn18hb9xmM6wwD1uTdShpf9Pn80aXTStnK1C94gtPvJcV2FrDmbOQUAQfJ1cKZHktkQUDwEqaAKXMMg==}
     dev: true

+ 18 - 0
src/render/ap/resultExport.ts

@@ -1,4 +1,6 @@
+import { AxiosResponse } from "axios";
 import { request } from "@/utils/request";
+
 import {
   ExamParams,
   ExamPageParams,
@@ -22,6 +24,14 @@ export const breachList = (data: ExamParams): Promise<BreachListItem[]> =>
     data,
   });
 
+export const breachTemplateDownload = (): Promise<AxiosResponse<Blob>> =>
+  request({
+    url: "/api/admin/student/breach/template",
+    method: "post",
+    data: {},
+    responseType: "blob",
+  });
+
 // 考生状态
 export const studentStatusList = (
   data: ExamParams
@@ -32,6 +42,14 @@ export const studentStatusList = (
     data,
   });
 
+export const statusTemplateDownload = (): Promise<AxiosResponse<Blob>> =>
+  request({
+    url: "/api/admin/student/cust-status/template",
+    method: "post",
+    data: {},
+    responseType: "blob",
+  });
+
 // 评卷点
 export const markSiteListPage = (
   data: ExamPageParams

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

@@ -8,6 +8,7 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     AAlert: typeof import('@qmth/ui')['Alert']
+    AButton: typeof import('@qmth/ui')['Button']
     AForm: typeof import('@qmth/ui')['Form']
     AFormItem: typeof import('@qmth/ui')['FormItem']
     AInput: typeof import('@qmth/ui')['Input']
@@ -15,6 +16,7 @@ declare module 'vue' {
     AInputPassword: typeof import('@qmth/ui')['InputPassword']
     AModal: typeof import('@qmth/ui')['Modal']
     APopconfirm: typeof import('@qmth/ui')['Popconfirm']
+    AProgress: typeof import('@qmth/ui')['Progress']
     ARadio: typeof import('@qmth/ui')['Radio']
     ARadioGroup: typeof import('@qmth/ui')['RadioGroup']
     ASelect: typeof import('@qmth/ui')['Select']
@@ -23,6 +25,7 @@ declare module 'vue' {
     ATabPane: typeof import('@qmth/ui')['TabPane']
     ATabs: typeof import('@qmth/ui')['Tabs']
     ATag: typeof import('@qmth/ui')['Tag']
+    AUpload: typeof import('@qmth/ui')['Upload']
     QmButton: typeof import('@qmth/ui')['QmButton']
     QmConfigProvider: typeof import('@qmth/ui')['QmConfigProvider']
     QmLowForm: typeof import('@qmth/ui')['QmLowForm']

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

@@ -0,0 +1,427 @@
+<template>
+  <a-modal
+    v-model:open="visible"
+    :width="422"
+    :title="title"
+    :mask-closable="false"
+    :esc-to-close="false"
+  >
+    <slot></slot>
+    <a-form :label-col="{ style: { width: '80px' } }" label-align="left">
+      <a-form-item label="选择文件">
+        <a-upload
+          v-if="visible"
+          ref="uploadRef"
+          :action="uploadUrl"
+          :headers="headers"
+          :accept="accept"
+          :data="uploadDataDict"
+          :show-upload-list="false"
+          :custom-request="customRequest"
+          :disabled="disabled || loading"
+          :before-upload="handleBeforeUpload"
+          @change="handleFileChange"
+        >
+          <a-button>
+            <template #icon><ImportOutlined /></template>点击导入
+          </a-button>
+        </a-upload>
+
+        <a-progress v-if="loading" :percent="uploadProgress" />
+
+        <template #help>
+          <p
+            v-if="result.message && !result.success"
+            class="tips-info tips-error"
+          >
+            {{ result.message }}
+          </p>
+        </template>
+      </a-form-item>
+      <a-form-item label="下载模板">
+        <a-button :loading="downloading" @click="onDownloadTemplate">
+          <template #icon><DownloadOutlined /></template>{{ downloadBtnTitle }}
+        </a-button>
+      </a-form-item>
+    </a-form>
+
+    <template v-if="!autoUpload" #footer>
+      <a-button @click="close">取消</a-button>
+      <a-button
+        type="primary"
+        :disabled="loading || !canUpload"
+        @click="confirm"
+        >确认</a-button
+      >
+    </template>
+  </a-modal>
+</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";
+
+import useModal from "@/hooks/useModal";
+import { downloadByUrl } from "@/utils/tool";
+
+defineOptions({
+  name: "ImportDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+interface PromiseFunc {
+  (): Promise<void>;
+}
+
+const props = withDefaults(
+  defineProps<{
+    title: string;
+    uploadUrl: string;
+    format?: string[];
+    uploadData?: Record<string, any>;
+    maxSize?: number;
+    autoUpload?: boolean;
+    disabled?: boolean;
+    uploadFileAlias?: string;
+    downloadBtnTitle?: string;
+    downloadUrl?: string;
+    downloadFilename?: string;
+    downloadHandle?: PromiseFunc;
+    beforeSubmitHandle?: PromiseFunc;
+  }>(),
+  {
+    title: "文件上传",
+    uploadUrl: "",
+    format: () => ["xls", "xlsx"],
+    uploadData: () => {
+      return {};
+    },
+    maxSize: 20 * 1024 * 1024,
+    uploadFileAlias: "file",
+    autoUpload: true,
+    disabled: false,
+    downloadBtnTitle: "导入模板",
+  }
+);
+
+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();
+});
+
+const downloading = ref(false);
+async function onDownloadTemplate() {
+  if (props.downloadHandle) {
+    if (downloading.value) return;
+    downloading.value = true;
+    await props.downloadHandle().catch(() => false);
+    downloading.value = false;
+    return;
+  }
+
+  if (props.downloadUrl) {
+    downloadByUrl(props.downloadUrl, props.downloadFilename || "");
+    return;
+  }
+}
+
+async function confirm() {
+  loading.value = true;
+  if (props.beforeSubmitHandle) {
+    let handleResult = true;
+    await props.beforeSubmitHandle().catch(() => {
+      handleResult = false;
+    });
+    if (!handleResult) return;
+  }
+  uploadRef.value?.submit();
+}
+
+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;
+
+  if (props.autoUpload) {
+    loading.value = true;
+    return true;
+  }
+
+  return false;
+};
+
+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);
+}
+
+/* init modal */
+watch(
+  () => visible.value,
+  (val) => {
+    if (val) {
+      modalOpenHandle();
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+
+function modalOpenHandle() {
+  canUpload.value = false;
+  result.value = {
+    success: true,
+    message: "",
+  };
+  headers.value = { md5: "" };
+  loading.value = false;
+  uploadDataDict.value = {};
+  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>

+ 68 - 0
src/render/components/SelectCourse/index.vue

@@ -0,0 +1,68 @@
+<template>
+  <a-select
+    v-model:value="selected"
+    :placeholder="placeholder"
+    :options="optionList"
+    filter-option
+    :multiple="false"
+    v-bind="attrs"
+    @change="onChange"
+  >
+  </a-select>
+</template>
+
+<script setup lang="ts">
+import { ref, useAttrs, watch } from "vue";
+import { subjectList } from "@/ap/base";
+
+defineOptions({
+  name: "SelectCourse",
+});
+
+const props = withDefaults(
+  defineProps<{
+    modelValue: string;
+    placeholder?: string;
+  }>(),
+  {
+    placeholder: "请选择",
+  }
+);
+const emit = defineEmits(["update:modelValue", "change"]);
+const attrs = useAttrs();
+
+interface optionListItem {
+  value: string;
+  label: string;
+}
+
+const selected = ref("");
+const optionList = ref<optionListItem[]>([]);
+const search = async () => {
+  optionList.value = [];
+  const resData = await subjectList({ examId: "" });
+
+  optionList.value = (resData || []).map((item) => {
+    return { ...item, value: item.subjectCode, label: item.subjectName };
+  });
+};
+search();
+
+const onChange = () => {
+  const selectedData = optionList.value.filter(
+    (item) => selected.value === item.value
+  );
+  emit("update:modelValue", selected.value);
+  emit("change", selectedData[0]);
+};
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    selected.value = val;
+  },
+  {
+    immediate: true,
+  }
+);
+</script>

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

@@ -21,6 +21,7 @@ import {
 
 import MyModal from "./MyModal/index.vue";
 import FooterInfo from "./FooterInfo/index.vue";
+import SelectCourse from "./SelectCourse/index.vue";
 
 use([
   CanvasRenderer,
@@ -44,5 +45,6 @@ export default {
   install(Vue: any) {
     Vue.component("MyModal", MyModal);
     Vue.component("FooterInfo", FooterInfo);
+    Vue.component("SelectCourse", SelectCourse);
   },
 };

+ 8 - 13
src/render/utils/crypto.ts

@@ -69,23 +69,18 @@ export const getMD5 = (content: string) => {
 //   });
 // };
 
-export const getFileMD5 = (dataFile: File) => {
-  return new Promise((rs, rj) => {
-    var fileReader = new FileReader();
-    var spark = new SparkMD5(); //创建md5对象(基于SparkMD5)
-    if (dataFile.size > 1024 * 1024 * 10) {
-      var data1 = dataFile.slice(0, 1024 * 1024 * 10); //将文件进行分块 file.slice(start,length)
-      fileReader.readAsBinaryString(data1); //将文件读取为二进制码
-    } else {
-      fileReader.readAsBinaryString(dataFile);
-    }
+export const getFileMD5 = (dataFile: File): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    const fileReader = new FileReader();
+    const spark = new SparkMD5(); //创建md5对象(基于SparkMD5)
+    fileReader.readAsBinaryString(dataFile);
     fileReader.onload = function (e: any) {
       spark.appendBinary(e.target.result);
-      var md5 = spark.end();
-      rs(md5);
+      const md5 = spark.end();
+      resolve(md5);
     };
     fileReader.onerror = function (err) {
-      rj(err);
+      reject(err);
     };
   });
 };

+ 170 - 0
src/render/utils/download.ts

@@ -0,0 +1,170 @@
+import { AxiosResponse } from "axios";
+import { objTypeOf, blobToText, objAssign } from "./tool";
+
+const parseDownloadFilename = (dispositionInfo: string): string => {
+  if (!dispositionInfo) return "";
+  const matchs = dispositionInfo.match(/filename=(.*)/) || [];
+  const filename = matchs[1];
+  return filename ? decodeURI(filename) : "";
+};
+
+/**
+ * 下载url
+ * @param {String} url 文件下载地址
+ * @param {String}} filename 文件名
+ */
+export function downloadByUrl(url: string, filename?: string): void {
+  const tempLink = document.createElement("a");
+  tempLink.style.display = "none";
+  tempLink.href = url;
+  const fileName = filename || url.split("/").pop()?.split("?")[0];
+  tempLink.setAttribute("download", fileName || "");
+  if (tempLink.download === "undefined") {
+    tempLink.setAttribute("target", "_blank");
+  }
+  document.body.appendChild(tempLink);
+  tempLink.click();
+  document.body.removeChild(tempLink);
+  window.URL.revokeObjectURL(url);
+}
+
+/**
+ * 下载blob
+ * @param {Blob} data blob对象
+ * @param {String} filename 文件名
+ */
+export function downloadByBlob(data: Blob, filename: string): void {
+  const blobUrl = URL.createObjectURL(data);
+  downloadByUrl(blobUrl, filename);
+}
+
+type ApiFunc = () => Promise<AxiosResponse<Blob>>;
+
+/**
+ * 通过api下载文件
+ * @param {AxiosPromise} fetchFunc 下载接口
+ * @param {String}} fileName 文件名
+ * @returns Promise<Boolean>
+ */
+export async function downloadByApi<T extends ApiFunc>(
+  axiosFetchFunc: T,
+  fileName?: string
+) {
+  let errorInfo = null;
+  const res = await axiosFetchFunc().catch((e) => {
+    errorInfo = e;
+  });
+
+  // 展示后台错误信息
+  if (errorInfo && objTypeOf(errorInfo) === "blob") {
+    const textRes = await blobToText(errorInfo as Blob).catch(() => false);
+    if (!textRes) return Promise.reject(new Error("下载失败!"));
+    const resJson: { message: string } = JSON.parse(textRes as string);
+    return Promise.reject(resJson.message);
+  }
+
+  const result = res as AxiosResponse<Blob>;
+  const filename =
+    fileName || parseDownloadFilename(result.headers["content-disposition"]);
+  downloadByBlob(new Blob([result.data]), filename);
+  return true;
+}
+
+/**
+ * 下载图片
+ * @param {String} url 图片地址
+ * @param {String} filename 下载的文件名
+ * @returns Promise<Boolean>
+ */
+export function downloadByImgUrl(
+  url: string,
+  filename: string
+): Promise<boolean> {
+  return new Promise((resolve, reject) => {
+    const img = new Image();
+    // 跨域支持,需要服务端辅助配置
+    // img.crossOrigin = "";
+    img.onload = () => {
+      const canvas = document.createElement("canvas");
+      const ctx = canvas.getContext("2d");
+
+      if (!canvas || !ctx) {
+        reject(new Error("不支持下载!"));
+        return;
+      }
+
+      canvas.width = img.width;
+      canvas.height = img.height;
+      ctx.drawImage(img, 0, 0);
+      canvas.toBlob((blob) => {
+        if (blob) {
+          downloadByBlob(blob, filename);
+          resolve(true);
+          return;
+        }
+        reject(new Error("blob empty"));
+      });
+    };
+    img.onerror = (e) => {
+      reject(e);
+    };
+    img.src = url;
+  });
+}
+
+interface XhrDownloadOption {
+  type: "get" | "post";
+  url: string;
+  data: Record<string, any>;
+  fileName: string;
+  header: Record<string, any>;
+  responseType: XMLHttpRequestResponseType;
+}
+
+/**
+ * 文件流下载
+ * @param {Object} option 文件下载设置
+ */
+export function xhrDownload(option: Partial<XhrDownloadOption>) {
+  const defOpt = {
+    type: "get",
+    url: "",
+    data: {} as Record<string, any>,
+    fileName: "",
+    header: {} as Record<string, any>,
+    responseType: "" as XMLHttpRequestResponseType,
+  };
+  const opt = objAssign(defOpt, option);
+
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open(opt.type.toUpperCase(), opt.url, true);
+    if (opt.responseType) xhr.responseType = opt.responseType;
+
+    // header set
+    if (opt.header && objTypeOf(opt.header) === "object") {
+      Object.keys(opt.header).forEach((key) => {
+        xhr.setRequestHeader(key, opt.header[key]);
+      });
+    }
+
+    function xhrOnload() {
+      if (xhr.readyState === 4 && xhr.status === 200) {
+        resolve(xhr.response);
+      } else {
+        reject(new Error("请求错误!"));
+      }
+    }
+    xhr.onload = xhrOnload;
+
+    if (opt.type.toUpperCase() === "POST") {
+      const fromData = new FormData();
+      Object.keys(opt.data).forEach((key) => {
+        fromData.append(key, opt.data[key]);
+      });
+      xhr.send(fromData);
+    } else {
+      xhr.send();
+    }
+  });
+}

+ 29 - 0
src/render/utils/tool.ts

@@ -1,3 +1,5 @@
+import { AxiosResponse } from "axios";
+
 const storagePrefix = "cet_";
 /**
  * LocalStorage
@@ -60,6 +62,33 @@ export const generateId = function () {
   );
 };
 
+/**
+ * 判断对象类型
+ * @param {*} obj 对象
+ */
+export function objTypeOf(obj: any): string {
+  if (obj === null) return "null";
+  if (Array.isArray(obj)) return "array";
+  if (obj instanceof Date) return "date";
+  if (obj instanceof RegExp) return "regExp";
+  if (obj instanceof Blob) return "blob";
+  if (typeof obj === "object") return "object";
+  return typeof obj;
+}
+
+export function blobToText(blob: Blob): Promise<string | ArrayBuffer | null> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.readAsText(blob, "utf-8");
+    reader.onload = () => {
+      resolve(reader.result);
+    };
+    reader.onerror = () => {
+      reject();
+    };
+  });
+}
+
 /* 日期格式化 */
 export const dateFormat = (
   date: any,

+ 39 - 5
src/render/views/ResultExport/BreachImport.vue

@@ -18,19 +18,33 @@
   >
     <template #bodyCell="{ column, index }">
       <template v-if="column.dataIndex === 'operation'">
-        <qm-button type="text" @click="onImport(index)">导入</qm-button>
+        <qm-button type="link" @click="onImport(index)">导入</qm-button>
       </template>
     </template>
   </a-table>
+
+  <!-- ImportDialog -->
+  <ImportDialog
+    ref="importDialogRef"
+    title="违纪导入"
+    upload-url="/api/admin/student/breach/import"
+    :upload-data="uploadData"
+    download-btn-title="违纪导入模版"
+    :download-handle="downloadHandle"
+  />
 </template>
 
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref, onMounted } from "vue";
 import { ExclamationCircleOutlined } from "@ant-design/icons-vue";
 import type { TableProps } from "ant-design-vue";
+import { message } from "ant-design-vue";
+
 import { BreachListItem } from "@/ap/types/resultExport";
-import { breachList } from "@/ap/resultExport";
-import { onMounted } from "vue";
+import { breachList, breachTemplateDownload } from "@/ap/resultExport";
+import { downloadByApi } from "@/utils/download";
+
+import ImportDialog from "@/components/ImportDialog/index.vue";
 
 defineOptions({
   name: "BreachImport",
@@ -65,11 +79,31 @@ async function getData() {
   dataList.value = res || [];
 }
 
+const importDialogRef = ref();
+const uploadData = ref<Record<string, string>>({});
 async function onImport(index: number) {
-  console.log(index);
+  const record = dataList.value[index];
+  uploadData.value = {
+    examId: "",
+    subjectCode: "",
+  };
+  importDialogRef.value?.open();
+}
+
+async function downloadHandle() {
+  const res = await downloadByApi(() => breachTemplateDownload()).catch(
+    (e: Error) => {
+      message.error(e.message || "下载失败,请重新尝试!");
+    }
+  );
+  if (!res) return;
+  message.success("下载成功!");
 }
 
 onMounted(() => {
+  dataList.value = [
+    { subjectCode: "8956145235", subjectName: "语法基础", breachCount: 12 },
+  ];
   // getData()
 });
 </script>

+ 1 - 4
src/render/views/ResultExport/ModifyMarkSite.vue

@@ -15,10 +15,7 @@
       :label-col="{ style: { width: '160px' } }"
     >
       <a-form-item name="subjectCode" label="科目代码">
-        <a-input
-          v-model:value="formData.subjectCode"
-          placeholder="请"
-        ></a-input>
+        <select-course v-model:value="formData.subjectCode"></select-course>
       </a-form-item>
       <a-form-item name="paperType" label="条码值">
         <a-input

+ 1 - 5
src/render/views/ResultExport/ModifySiteCode.vue

@@ -102,10 +102,6 @@ watch(
 
 /* init modal */
 function modalOpenHandle() {
-  if (props.rowData.id) {
-    objModifyAssign(formData, props.rowData);
-  } else {
-    objModifyAssign(formData, defaultFormData);
-  }
+  objModifyAssign(formData, defaultFormData);
 }
 </script>

+ 49 - 5
src/render/views/ResultExport/StudentStatus.vue

@@ -18,19 +18,40 @@
   >
     <template #bodyCell="{ column, index }">
       <template v-if="column.dataIndex === 'operation'">
-        <qm-button type="text" @click="onImport(index)">导入</qm-button>
+        <qm-button type="link" @click="onImport(index)">导入</qm-button>
       </template>
     </template>
   </a-table>
+
+  <!-- ImportDialog -->
+  <ImportDialog
+    ref="importDialogRef"
+    title="考生状态导入"
+    upload-url="/api/admin/student/cust-status/import"
+    :upload-data="uploadData"
+    download-btn-title="考生状态导入模版"
+    :download-handle="downloadHandle"
+  >
+    <a-form-item
+      label="科目"
+      :label-col="{ style: { width: '80px' } }"
+      label-align="left"
+      >{{ curRow.subjectName }}</a-form-item
+    >
+  </ImportDialog>
 </template>
 
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref, onMounted } from "vue";
 import { ExclamationCircleOutlined } from "@ant-design/icons-vue";
 import type { TableProps } from "ant-design-vue";
+import { message } from "ant-design-vue";
+
 import { StudentStatusListItem } from "@/ap/types/resultExport";
-import { studentStatusList } from "@/ap/resultExport";
-import { onMounted } from "vue";
+import { studentStatusList, statusTemplateDownload } from "@/ap/resultExport";
+import { downloadByApi } from "@/utils/download";
+
+import ImportDialog from "@/components/ImportDialog/index.vue";
 
 defineOptions({
   name: "StudentStatus",
@@ -60,17 +81,40 @@ const columns: TableProps["columns"] = [
   },
 ];
 
+const curRow = ref({} as StudentStatusListItem);
+
 async function getData() {
   const res = await studentStatusList({ examId: "" });
   dataList.value = res || [];
 }
 
+const importDialogRef = ref();
+const uploadData = ref<Record<string, string>>({});
 async function onImport(index: number) {
   const record = dataList.value[index];
-  console.log(record);
+  curRow.value = record;
+  uploadData.value = {
+    examId: "",
+    subjectCode: "",
+  };
+  importDialogRef.value?.open();
+}
+
+async function downloadHandle() {
+  const res = await downloadByApi(() => statusTemplateDownload()).catch(
+    (e: Error) => {
+      message.error(e.message || "下载失败,请重新尝试!");
+    }
+  );
+  if (!res) return;
+  message.success("下载成功!");
 }
 
 onMounted(() => {
+  dataList.value = [
+    { subjectCode: "123456789", subjectName: "数学理论", custStatusCount: 89 },
+  ];
+
   // getData()
 });
 </script>

+ 0 - 4
types/spark-md5.d.ts

@@ -1,4 +0,0 @@
-declare module "spark-md5" {
-  const SparkMD5: any;
-  export default SparkMD5;
-}