|
@@ -0,0 +1,281 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import {
|
|
|
+ offlineExamUploadFileTypesApi,
|
|
|
+ offlineExamBatchSubmitPaperApi,
|
|
|
+} from "@/api/offlineExam";
|
|
|
+import { OfflineExam } from "@/types/student-client";
|
|
|
+import { computed } from "vue";
|
|
|
+import { CloudUpload, EllipsisHorizontalCircle, Add } from "@vicons/ionicons5";
|
|
|
+import { UploadInst, UploadFileInfo } from "naive-ui";
|
|
|
+import { fileFormatCheck } from "./fileFormatCheck";
|
|
|
+import { fileMD5 } from "@/utils/md5";
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ course: OfflineExam;
|
|
|
+}>();
|
|
|
+const emit = defineEmits<{
|
|
|
+ (e: "modified"): void;
|
|
|
+}>();
|
|
|
+
|
|
|
+let modalIsShow = $ref(false);
|
|
|
+function close() {
|
|
|
+ modalIsShow = false;
|
|
|
+}
|
|
|
+function open() {
|
|
|
+ modalIsShow = true;
|
|
|
+}
|
|
|
+function modalOpened() {
|
|
|
+ uploadFileList = [];
|
|
|
+ loading = false;
|
|
|
+ void getUploadFileTypes();
|
|
|
+}
|
|
|
+
|
|
|
+interface FileChangeOption {
|
|
|
+ file: UploadFileInfo;
|
|
|
+ fileList: Array<UploadFileInfo>;
|
|
|
+ event?: Event;
|
|
|
+}
|
|
|
+
|
|
|
+// upload option
|
|
|
+const maxUploadImageCount = 6;
|
|
|
+const isImageType = computed(() => selectedFileType === "IMAGE");
|
|
|
+const uploadListType = computed(() =>
|
|
|
+ isImageType.value ? "image-card" : "text"
|
|
|
+);
|
|
|
+const uploadFileMaxCount = computed(() =>
|
|
|
+ isImageType.value ? maxUploadImageCount : 1
|
|
|
+);
|
|
|
+const uploadFileMaxSize = computed(() => {
|
|
|
+ return isImageType.value ? 5 : 30;
|
|
|
+});
|
|
|
+
|
|
|
+const UploadRef = $ref<UploadInst | null>(null);
|
|
|
+let uploadFileList = $ref<UploadFileInfo[]>([]);
|
|
|
+let loading = $ref(false);
|
|
|
+let selectedFileType = $ref("");
|
|
|
+let serverFileTypes = $ref<string[]>([]);
|
|
|
+async function getUploadFileTypes() {
|
|
|
+ if (!props.course.examId) return;
|
|
|
+ const res = await offlineExamUploadFileTypesApi(props.course.examId);
|
|
|
+
|
|
|
+ serverFileTypes = res.data.OFFLINE_UPLOAD_FILE_TYPE
|
|
|
+ ? JSON.parse(res.data.OFFLINE_UPLOAD_FILE_TYPE)
|
|
|
+ : [];
|
|
|
+ selectedFileType = serverFileTypes[0];
|
|
|
+}
|
|
|
+function fileTypeChange() {
|
|
|
+ void UploadRef?.clear();
|
|
|
+ uploadFileList = [];
|
|
|
+}
|
|
|
+
|
|
|
+async function fileChange(options: FileChangeOption) {
|
|
|
+ // console.log(options.file, options.fileList);
|
|
|
+ if (options.file.status !== "pending") return;
|
|
|
+
|
|
|
+ // upload组件事件触发顺序:change,update:fileList
|
|
|
+ // update:fileList触发之时,会修改uploadFileList。
|
|
|
+ // 想要控制校验不合格的文件显示,只能异步校验后,删除校验不合格的文件。
|
|
|
+ const valid = await checkFile(options.file).catch(() => false);
|
|
|
+ const fileIndex = uploadFileList.findIndex(
|
|
|
+ (item) => item.name === options.file.name
|
|
|
+ );
|
|
|
+ if (fileIndex !== -1) {
|
|
|
+ uploadFileList.splice(fileIndex, 1);
|
|
|
+ }
|
|
|
+ console.log(valid);
|
|
|
+ if (!valid) return;
|
|
|
+ uploadFileList.push(options.file);
|
|
|
+}
|
|
|
+
|
|
|
+async function checkFile(file: UploadFileInfo) {
|
|
|
+ // size
|
|
|
+ const fileMaxSize = uploadFileMaxSize.value * 1024 * 1024;
|
|
|
+ if (file.file && file.file.size > fileMaxSize) {
|
|
|
+ const msg = `${file.name}太大,作答文件不能超过${uploadFileMaxSize.value}MB!`;
|
|
|
+ $message.error(msg);
|
|
|
+ return Promise.reject(msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ // suffix format
|
|
|
+ const suffix = file.name.split(".").pop() || "";
|
|
|
+ if (suffix && suffix.toLowerCase() !== suffix) {
|
|
|
+ const msg = `${file.name}文件名后缀必须是小写!`;
|
|
|
+ $message.error(msg);
|
|
|
+ return Promise.reject(msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ // strict format
|
|
|
+ let errorMsg = "";
|
|
|
+ const result = await fileFormatCheck(
|
|
|
+ file.file as File,
|
|
|
+ selectedFileType
|
|
|
+ ).catch((error: string) => {
|
|
|
+ errorMsg = error;
|
|
|
+ $message.error(error);
|
|
|
+ });
|
|
|
+ if (!result) return Promise.reject(errorMsg);
|
|
|
+
|
|
|
+ return Promise.resolve(true);
|
|
|
+}
|
|
|
+
|
|
|
+function dialogConfirm(content: string) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ $dialog.create({
|
|
|
+ content,
|
|
|
+ maskClosable: false,
|
|
|
+ closable: false,
|
|
|
+ closeOnEsc: false,
|
|
|
+ negativeText: "取消",
|
|
|
+ positiveText: "确定",
|
|
|
+ onPositiveClick: () => {
|
|
|
+ resolve("confirm");
|
|
|
+ },
|
|
|
+ onNegativeClick: () => {
|
|
|
+ reject("cancel");
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function toSubmit() {
|
|
|
+ if (loading) return;
|
|
|
+
|
|
|
+ // check
|
|
|
+ if (!selectedFileType) {
|
|
|
+ return $message.error("请先选择上传文件类型!");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!uploadFileList.length) return $message.error("请先选择上传文件!");
|
|
|
+
|
|
|
+ const checkFuncs = uploadFileList.map((item) => checkFile(item));
|
|
|
+ const fileValid = await Promise.all(checkFuncs).catch(() => [false]);
|
|
|
+
|
|
|
+ if (fileValid.some((item) => !item)) return;
|
|
|
+
|
|
|
+ if (props.course.offlineFiles && props.course.offlineFiles.length) {
|
|
|
+ const confirm = await dialogConfirm("已有作答附件,是否覆盖?");
|
|
|
+ if (confirm !== "confirm") return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // upload
|
|
|
+ loading = true;
|
|
|
+ let params = new FormData();
|
|
|
+ params.append("examRecordDataId", props.course.examRecordDataId + "");
|
|
|
+ params.append("fileType", selectedFileType);
|
|
|
+
|
|
|
+ for (const file of uploadFileList) {
|
|
|
+ params.append("fileArray", file.file as Blob);
|
|
|
+ if (file.file) {
|
|
|
+ const fileMd5Str = await fileMD5(file.file);
|
|
|
+ params.append("fileMd5Array", fileMd5Str);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await offlineExamBatchSubmitPaperApi(params).catch(() => {
|
|
|
+ logger({
|
|
|
+ cnl: ["local", "server"],
|
|
|
+ pgn: "离线考试",
|
|
|
+ act: "上传离线考试附件",
|
|
|
+ stk: "",
|
|
|
+ dtl: "上传离线考试附件失败",
|
|
|
+ });
|
|
|
+ });
|
|
|
+ loading = false;
|
|
|
+ if (!res) return;
|
|
|
+
|
|
|
+ $message.success("上传成功!");
|
|
|
+ close();
|
|
|
+ emit("modified");
|
|
|
+}
|
|
|
+
|
|
|
+// expose
|
|
|
+defineExpose({
|
|
|
+ open,
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <n-modal
|
|
|
+ v-model:show="modalIsShow"
|
|
|
+ preset="dialog"
|
|
|
+ title="上传文件"
|
|
|
+ :showIcon="false"
|
|
|
+ :maskClosable="false"
|
|
|
+ :closeOnEsc="false"
|
|
|
+ class="qm-modal"
|
|
|
+ style="width: 600px"
|
|
|
+ :onAfterEnter="modalOpened"
|
|
|
+ >
|
|
|
+ <div>
|
|
|
+ <n-form
|
|
|
+ size="medium"
|
|
|
+ labelPlacement="left"
|
|
|
+ labelWidth="120px"
|
|
|
+ :showFeedback="false"
|
|
|
+ >
|
|
|
+ <n-form-item label="课程名称:">
|
|
|
+ <p class="course-name">{{ course.courseName }}</p>
|
|
|
+ </n-form-item>
|
|
|
+ <n-form-item class="qm-mb-10" label="上传文件类型:">
|
|
|
+ <n-radio-group
|
|
|
+ v-model:value="selectedFileType"
|
|
|
+ name="radiogroup"
|
|
|
+ @update:value="fileTypeChange"
|
|
|
+ >
|
|
|
+ <n-space>
|
|
|
+ <n-radio
|
|
|
+ v-for="item in serverFileTypes"
|
|
|
+ :key="item"
|
|
|
+ :value="item"
|
|
|
+ >
|
|
|
+ {{ item === "IMAGE" ? "图片" : item }}
|
|
|
+ </n-radio>
|
|
|
+ </n-space>
|
|
|
+ </n-radio-group>
|
|
|
+ </n-form-item>
|
|
|
+ <n-form-item label="上传文件:">
|
|
|
+ <n-upload
|
|
|
+ ref="UploadRef"
|
|
|
+ v-model:fileList="uploadFileList"
|
|
|
+ :disabled="loading"
|
|
|
+ :listType="uploadListType"
|
|
|
+ :multiple="isImageType"
|
|
|
+ :max="uploadFileMaxCount"
|
|
|
+ :defaultUpload="false"
|
|
|
+ @change="fileChange"
|
|
|
+ >
|
|
|
+ <n-icon v-if="isImageType" :component="Add" size="26"></n-icon>
|
|
|
+ <n-button v-else type="success">选择文件</n-button>
|
|
|
+ </n-upload>
|
|
|
+ </n-form-item>
|
|
|
+ </n-form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #action>
|
|
|
+ <n-button
|
|
|
+ type="success"
|
|
|
+ :disabled="!uploadFileList.length || loading"
|
|
|
+ :loading="loading"
|
|
|
+ @click="toSubmit"
|
|
|
+ >
|
|
|
+ <template #icon>
|
|
|
+ <n-icon :component="CloudUpload"></n-icon>
|
|
|
+ </template>
|
|
|
+ 确认上传</n-button
|
|
|
+ >
|
|
|
+ <n-button type="success" @click="close">
|
|
|
+ <template #icon>
|
|
|
+ <n-icon :component="EllipsisHorizontalCircle"></n-icon>
|
|
|
+ </template>
|
|
|
+ 取消上传</n-button
|
|
|
+ >
|
|
|
+ </template>
|
|
|
+ </n-modal>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.course-name {
|
|
|
+ color: var(--app-color-error);
|
|
|
+ font-size: var(--app-font-size-large);
|
|
|
+}
|
|
|
+</style>
|