Browse Source

离线考试

zhangjie 3 years ago
parent
commit
3d5d347931

+ 2 - 0
package.json

@@ -20,6 +20,7 @@
     "axios": "^0.26.1",
     "axios-progress-bar": "^1.2.0",
     "axios-retry": "^3.2.4",
+    "js-md5": "^0.7.3",
     "js-sls-logger": "^2.0.1",
     "lodash-es": "^4.17.21",
     "moment": "^2.29.1",
@@ -33,6 +34,7 @@
     "vue-router": "^4.0.14"
   },
   "devDependencies": {
+    "@types/js-md5": "^0.4.3",
     "@types/lodash-es": "^4.17.6",
     "@types/node": "^17.0.22",
     "@types/ua-parser-js": "^0.7.36",

+ 49 - 0
src/api/offlineExam.ts

@@ -0,0 +1,49 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { OfflineExam } from "@/types/student-client";
+
+/** 获取答题纸模板链接 */
+export async function getAnswersUrlApi(rootOrgId: number) {
+  return httpApp.get<string>(`/api/ecs_core/org/getAnswersUrl/${rootOrgId}`);
+}
+/** 获取离线考试科目 */
+export async function getOfflineCourseApi() {
+  return httpApp.get<any, { data: OfflineExam[] }>(
+    "/api/branch_ecs_oe_admin/offlineExam/getOfflineCourse"
+  );
+}
+/** 下载离线考试试卷 */
+export async function exportOfflinePaperApi(paperId: string) {
+  return httpApp.get<Blob>(
+    `/api/branch_ecs_ques/paper/export/${paperId}/PAPER/offLine`,
+    {
+      responseType: "blob",
+    }
+  );
+}
+/** 开始离线考试 */
+export async function startOfflineExamApi(examStudentId: number) {
+  return httpApp.get<any>(
+    `/api/branch_ecs_oe_admin/offlineExam/startOfflineExam`,
+    {
+      params: { examStudentId },
+    }
+  );
+}
+
+type UploadFileType = {
+  OFFLINE_UPLOAD_FILE_TYPE: string;
+};
+/** 获取离线考试上传文件类型 */
+export async function offlineExamUploadFileTypesApi(examId: number) {
+  return httpApp.get<any, { data: UploadFileType }>(
+    `/api/ecs_exam_work/exam/getExamPropertyFromCacheByStudentSession/${examId}/OFFLINE_UPLOAD_FILE_TYPE`
+  );
+}
+
+export async function offlineExamBatchSubmitPaperApi(data: FormData) {
+  return httpApp.post<any>(
+    `/api/branch_ecs_oe_admin/offlineExam/batchSubmitPaper`,
+    data,
+    { headers: { "Content-Type": "multipart/form-data" } }
+  );
+}

+ 12 - 13
src/components/MainLayout/MainLayout.vue

@@ -35,14 +35,14 @@ const bindUserQrcodeUrl = computed(() => {
 let appDownloadUrl = $ref("");
 let appEnable = $ref(false);
 // 页面初始化
-const initData = async () => {
+async function initData() {
   const res1 = await appEnableApi(user.rootOrgId);
   appEnable = res1.data;
   if (appEnable) {
     const res2 = await appDownloadApi();
     appDownloadUrl = res2.data;
   }
-};
+}
 // 菜单相关
 interface MenuOption {
   id: string;
@@ -55,7 +55,7 @@ let menuOptions = $ref<MenuOption[]>([]);
 let curMenuOption = $ref<MenuOption | undefined>();
 let breadcrumbs = $ref<MenuSubItem[]>([]);
 
-const getMenus = async () => {
+async function getMenus() {
   const res = await studentClientMenuApi(store.user.rootOrgId);
   const menuDict = getMenuDict();
   menuOptions = res.data
@@ -67,21 +67,20 @@ const getMenus = async () => {
         routeName: menuDict[item.routeCode.toUpperCase()],
       };
     });
-};
-const swithMenu = (menu: MenuOption) => {
-  // console.log(menu);
+}
+function swithMenu(menu: MenuOption) {
   console.log(menu.routeName);
   void router.push({ name: menu.routeName });
-};
+}
 
 // route change
-const routerChange = () => {
+function routerChange() {
   breadcrumbs = parseMapLine(route.name as string);
   curMenu = breadcrumbs[0]?.routeName;
   curMenuOption = menuOptions.find((item) => item.routeName === curMenu);
-};
+}
 
-const toModifyPwd = () => {
+function toModifyPwd() {
   logger({
     cnl: ["console"],
     pgn: curMenuOption?.name,
@@ -90,8 +89,8 @@ const toModifyPwd = () => {
   });
 
   void router.push({ name: "ChangePassword" });
-};
-const toLogout = () => {
+}
+function toLogout() {
   logger({
     cnl: ["console", "server"],
     pgn: curMenuOption?.name,
@@ -99,7 +98,7 @@ const toLogout = () => {
     stk: "退出登录",
   });
   void router.push({ name: "UserLogin" });
-};
+}
 
 onMounted(async () => {
   void initData();

+ 4 - 4
src/components/MainLayout/menuConfig.ts

@@ -47,15 +47,15 @@ export const menuMap: MenuItem[] = [
   },
 ];
 
-export const getMenuDict = () => {
+export function getMenuDict() {
   const menuDict: Record<string, string> = {};
   menuMap.forEach((menu) => {
     menuDict[menu.code] = menu.routeName;
   });
   return menuDict;
-};
+}
 
-export const parseMapLine = (routeName: string) => {
+export function parseMapLine(routeName: string) {
   let finded = false;
   let mapLine: MenuSubItem[] = [];
   const parseTree = (list: MenuSubItem[], parents: MenuSubItem[]) => {
@@ -78,4 +78,4 @@ export const parseMapLine = (routeName: string) => {
   };
   parseTree(menuMap, []);
   return mapLine;
-};
+}

+ 9 - 9
src/features/ChangePassword/ChangePassword.vue

@@ -1,8 +1,7 @@
 <script lang="ts" setup>
-import { FormInst, FormItemRule, FormRules, useMessage } from "naive-ui";
+import { FormInst, FormItemRule, FormRules } from "naive-ui";
 import { changePwdApi } from "@/api/changePwd";
 const modelFormRef = $ref<FormInst | null>(null);
-const message = useMessage();
 
 type FormModel = {
   password: string;
@@ -43,16 +42,17 @@ const formRules: FormRules = {
   ],
   renewPassword: [password, { validator: equalToPswd, trigger: "input" }],
 };
-const inputChange = async () => {
-  await modelFormRef?.validate().catch(() => 0);
-};
+async function inputChange() {
+  await modelFormRef?.validate();
+}
 
-const toSubmit = async () => {
-  if (await modelFormRef?.validate().catch(() => true)) return;
+async function toSubmit() {
+  const valid = await modelFormRef?.validate().catch(() => false);
+  if (!valid) return;
 
   await changePwdApi(formModel.password, formModel.newPassword);
-  message.success("修改成功!");
-};
+  $message.success("修改成功!");
+}
 </script>
 
 <template>

+ 53 - 2
src/features/OfflineExam/OfflineExam.vue

@@ -1,5 +1,56 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { store } from "@/store/store";
+import { getAnswersUrlApi } from "@/api/offlineExam";
+import OfflineExamList from "./OfflineExamList.vue";
+import { onMounted } from "vue";
+import { downloadByUrl } from "@/utils/download";
+
+logger({
+  cnl: ["local", "server"],
+  pgn: "离线考试",
+  act: "首次渲染",
+});
+
+// 获取答题纸链接
+let answerCardUrl: string = $ref("");
+async function getAnswersUrl() {
+  const res = await getAnswersUrlApi(store.user.rootOrgId);
+  answerCardUrl = res.data;
+}
+
+// 下载答题纸
+let loading = $ref(false);
+function toDownloadPaper() {
+  logger({
+    cnl: ["local", "server"],
+    pgn: "离线考试",
+    act: "下载答题卡",
+  });
+
+  downloadByUrl(answerCardUrl);
+  loading = true;
+  setTimeout(() => {
+    loading = false;
+  }, 10 * 1000);
+}
+
+onMounted(() => {
+  void getAnswersUrl();
+});
+</script>
 
 <template>
-  <div class=""></div>
+  <div v-if="answerCardUrl" class="off-exam-card">
+    <span>答题纸模板:</span>
+    <n-button text :disabled="loading" @click="toDownloadPaper">下载</n-button>
+  </div>
+  <OfflineExamList />
 </template>
+
+<style scoped>
+.off-exam-card {
+  height: 30px;
+  margin-bottom: 30px;
+  text-align: right;
+}
+</style>

+ 224 - 0
src/features/OfflineExam/OfflineExamList.vue

@@ -0,0 +1,224 @@
+<script setup lang="ts">
+import { OfflineExam } from "@/types/student-client";
+import {
+  exportOfflinePaperApi,
+  getOfflineCourseApi,
+  startOfflineExamApi,
+} from "@/api/offlineExam";
+import { CloudDownload } from "@vicons/ionicons5";
+import { onMounted } from "vue";
+import { store } from "@/store/store";
+import {
+  downloadBlobByApi,
+  downloadByBlob,
+  toBlobByUrl,
+} from "@/utils/download";
+import OfflineExamUploadModal from "./OfflineExamUploadModal.vue";
+
+interface OfflineExamUploadModalInst {
+  open: () => void;
+}
+const OfflineExamUploadModalRef = $ref<OfflineExamUploadModalInst | null>(null);
+
+const timeDifference = store.sysTime.difference;
+let loading = $ref(false);
+// 科目列表
+let courseList = $ref<OfflineExam[]>();
+let curCourse: OfflineExam = $ref();
+
+async function getCourseList() {
+  const res = await getOfflineCourseApi();
+  courseList = res.data || [];
+}
+
+function checkCourseTime(time: string) {
+  return new Date(time).getTime() - Date.now() > timeDifference;
+}
+
+// 下载离线文件
+async function toDownloadOfflineFile(url: string, name: string) {
+  logger({
+    cnl: ["local", "server"],
+    pgn: "离线考试",
+    act: "下载作答",
+  });
+
+  if ([".png", ".jpg", ".jpeg"].some((v) => name.endsWith(v))) {
+    const blob = await toBlobByUrl(url);
+    downloadByBlob(blob, name);
+  } else {
+    window.location.href = url;
+  }
+}
+
+async function toEnterExam(course: OfflineExam) {
+  logger({
+    cnl: ["local", "server"],
+    pgn: "离线考试",
+    act: "抽取试卷",
+    ext: {
+      examStudentId: course.examStudentId,
+    },
+  });
+  loading = true;
+  const res = await startOfflineExamApi(course.examStudentId).catch(
+    () => false
+  );
+  loading = false;
+  if (!res) return;
+
+  void getCourseList();
+}
+
+function toViewPaper(course: OfflineExam) {
+  logger({
+    cnl: ["local", "server"],
+    pgn: "离线考试",
+    act: "查看试卷",
+  });
+
+  const user = {
+    loginName: course.examStudentId,
+    backUrl: window.document.location.href,
+    isOnlineExam: true,
+  };
+  window.name = JSON.stringify(user);
+  window.location.href = `/admin/preview_paper/${course.paperId}?isback=true`;
+}
+
+let downloading = $ref(false);
+async function toDownloadPaper(course: OfflineExam) {
+  if (downloading) return;
+
+  logger({
+    cnl: ["local", "server"],
+    pgn: "离线考试",
+    act: "下载试卷",
+  });
+  downloading = true;
+  const result = await downloadBlobByApi(() => {
+    return exportOfflinePaperApi(course.paperId);
+  }).catch(() => false);
+
+  downloading = false;
+
+  if (result) {
+    $message.success("下载成功!");
+  } else {
+    $message.error("下载失败,请重新尝试!");
+  }
+}
+
+function toUploadPaper(course: OfflineExam) {
+  logger({
+    cnl: ["local", "server"],
+    pgn: "离线考试",
+    act: "上传答案",
+  });
+  curCourse = course;
+  OfflineExamUploadModalRef?.open();
+}
+
+onMounted(() => {
+  void getCourseList();
+});
+</script>
+
+<template>
+  <div class="off-exam-courses">
+    <n-table class="n-table-text-center" :singleLine="false">
+      <colgroup>
+        <col />
+        <col />
+        <col width="200px" />
+        <col />
+        <col width="200px" />
+      </colgroup>
+      <thead>
+        <tr>
+          <th>课程</th>
+          <th>专业</th>
+          <th>考试进入时间</th>
+          <th>状态</th>
+          <th>操作</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="course in courseList" :key="course.courseId">
+          <td>{{ course.courseName }}</td>
+          <td>{{ course.specialtyName }}</td>
+          <td>
+            <p>{{ course.startTime }}</p>
+            <p>~</p>
+            <p>{{ course.endTime }}</p>
+          </td>
+          <td>
+            <div v-if="course.offlineFiles">
+              <n-button
+                v-for="(file, findex) in course.offlineFiles"
+                :key="findex"
+                :title="file.originalFileName"
+                text
+                block
+                @click="
+                  toDownloadOfflineFile(
+                    file.offlineFileUrl,
+                    file.originalFileName
+                  )
+                "
+              >
+                <template #icon>
+                  <n-icon :component="CloudDownload" :size="16"></n-icon>
+                </template>
+                下载作答
+              </n-button>
+            </div>
+            <div v-else>未上传</div>
+          </td>
+          <td>
+            <div v-if="course.paperId" class="column-action">
+              <n-button type="success" block @click="toViewPaper(course)"
+                >查看试卷</n-button
+              >
+              <n-button
+                type="success"
+                :loading="downloading"
+                :disabled="downloading"
+                block
+                @click="toDownloadPaper(course)"
+                >下载试卷</n-button
+              >
+              <n-button type="success" block @click="toUploadPaper(course)"
+                >上传答案</n-button
+              >
+            </div>
+            <div v-else>
+              <div v-if="checkCourseTime(course.startTime)">未开始</div>
+              <div v-else-if="!checkCourseTime(course.endTime)">已结束</div>
+              <div v-else>
+                <n-button
+                  type="success"
+                  :loading="loading"
+                  @click="toEnterExam(course)"
+                >
+                  抽取试卷
+                </n-button>
+              </div>
+            </div>
+          </td>
+        </tr>
+      </tbody>
+    </n-table>
+  </div>
+  <OfflineExamUploadModal
+    ref="OfflineExamUploadModalRef"
+    :course="curCourse"
+    @modified="getCourseList"
+  />
+</template>
+
+<style scoped>
+.column-action .n-button--block:not(:last-child) {
+  margin-bottom: 10px;
+}
+</style>

+ 281 - 0
src/features/OfflineExam/OfflineExamUploadModal.vue

@@ -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>

+ 59 - 0
src/features/OfflineExam/fileFormatCheck.ts

@@ -0,0 +1,59 @@
+function getMimetype(signature: string) {
+  switch (signature) {
+    case "89504E47":
+      return "image/png";
+    case "47494638":
+      return "image/gif";
+    case "25504446":
+      return "application/pdf";
+    case "FFD8FFE0":
+    case "FFD8FFE1":
+    case "FFD8FFE2":
+    case "FFD8FFE3":
+      return "image/jpeg";
+    case "504B0304":
+      return "application/zip";
+    case "504B34":
+      return "application/zip";
+    default:
+      return "Unknown filetype";
+  }
+}
+
+const mimeTypeSuffix: Recode<string, string[]> = {
+  "image/png": ["png"],
+  "image/gif": ["gif"],
+  "image/jpeg": ["jpeg", "jpg"],
+  "application/zip": ["zip"],
+  "application/pdf": ["pdf"],
+};
+
+export function fileFormatCheck(file: File, validFileType: string) {
+  return new Promise((resolve, reject) => {
+    const filereader = new FileReader();
+    filereader.onloadend = (evt) => {
+      if (evt.target.readyState === FileReader.DONE) {
+        const uint = new Uint8Array(evt.target?.result);
+        const bytes = [];
+        uint.forEach((byte) => bytes.push(byte.toString(16)));
+        const hex = bytes.join("").toUpperCase();
+        const mimeType = getMimetype(hex);
+        if (!mimeType.toUpperCase().includes(validFileType)) {
+          return reject(
+            `${file.name}文件内容与所选文件类型${validFileType}不一致`
+          );
+        }
+
+        const suffix = file.name.split(".").pop() || "";
+        const validSuffix: string[] = mimeTypeSuffix[mimeType] || [];
+        if (validSuffix.length && suffix && validSuffix.includes(suffix)) {
+          resolve(true);
+        } else {
+          reject(`${file.name}文件内容与文件的后缀不一致,或文件已损坏!`);
+        }
+      }
+    };
+    const blob = file.slice(0, 4);
+    filereader.readAsArrayBuffer(blob);
+  });
+}

+ 9 - 10
src/features/SiteMessage/SiteMessage.vue

@@ -2,14 +2,13 @@
 import { SiteMessage } from "@/types/student-client";
 import { updateNotieReadStatusApi } from "@/api/siteMessage";
 import { store } from "@/store/store";
-import { DataTableColumns, NIcon, useMessage } from "naive-ui";
+import { DataTableColumns, NIcon } from "naive-ui";
 import { h, computed, onMounted } from "vue";
 import { useRouter } from "vue-router";
 import { RowKey } from "naive-ui/lib/data-table/src/interface";
 import { MailOpen, MailUnread } from "@vicons/ionicons5";
 import { fetchSiteMessage } from "./siteMessageUpdate";
 
-const message = useMessage();
 const router = useRouter();
 
 const columns: DataTableColumns<SiteMessage> = [
@@ -47,23 +46,23 @@ const pagination = $ref({
   },
 });
 
-const selectedChange = (ids: RowKey[]) => {
+function selectedChange(ids: RowKey[]) {
   selectedMessageIds = ids;
-};
-const toDetail = (row: SiteMessage) => {
+}
+function toDetail(row: SiteMessage) {
   void router.push({ name: "SiteMessageDetail", params: { noticeId: row.id } });
-};
+}
 
-const toMarkRead = async () => {
+async function toMarkRead() {
   if (!selectedMessageIds.length) {
-    message.error("请先选择要标记的信息!");
+    $message.error("请先选择要标记的信息!");
     return;
   }
   await updateNotieReadStatusApi(selectedMessageIds.join());
-  message.success("修改成功!");
+  $message.success("修改成功!");
   store.updateSiteMessagesTimeStamp();
   void fetchSiteMessage();
-};
+}
 
 onMounted(() => {
   void fetchSiteMessage();

+ 6 - 6
src/features/SiteMessage/SiteMessageDetail.vue

@@ -10,18 +10,18 @@ import { fetchSiteMessage } from "./siteMessageUpdate";
 let notice: SiteMessage | undefined = $ref();
 const route = useRoute();
 
-const toBack = () => {
+function toBack() {
   window.history.go(-1);
-};
+}
 
-const markMessageRead = async (noticeId: string) => {
+async function markMessageRead(noticeId: string) {
   if (!noticeId) return;
   await updateNotieReadStatusApi(noticeId);
   store.updateSiteMessagesTimeStamp();
   void fetchSiteMessage();
-};
+}
 
-const initData = () => {
+function initData() {
   const paramNoticeId = route.params.noticeId as string;
   const curNoticeId = Number(paramNoticeId);
   const curNotice = store.siteMessage.messages.find(
@@ -32,7 +32,7 @@ const initData = () => {
   if (notice?.hasRead) return;
 
   void markMessageRead(paramNoticeId);
-};
+}
 
 onMounted(() => {
   initData();

+ 7 - 7
src/features/SiteMessage/SiteMessageNotification.vue

@@ -10,7 +10,7 @@ const router = useRouter();
 const firstUnreadMessage = computed(() => store.siteMessage.firstUnreadMessage);
 const showPopover = computed(() => !!firstUnreadMessage.value);
 
-const updateSiteMessage = () => {
+function updateSiteMessage() {
   const { messages, ignoreMessageIds } = store.siteMessage;
   if (!messages) return;
   const unreadMessages = messages
@@ -18,9 +18,9 @@ const updateSiteMessage = () => {
     .reverse();
   store.siteMessage.unreadCount = unreadMessages.length;
   store.siteMessage.firstUnreadMessage = unreadMessages[0] || null;
-};
+}
 
-const toView = () => {
+function toView() {
   if (!firstUnreadMessage.value) return;
   void router.push({
     name: "SiteMessageDetail",
@@ -28,9 +28,9 @@ const toView = () => {
       noticeId: firstUnreadMessage.value.id,
     },
   });
-};
+}
 
-const toIgnore = () => {
+function toIgnore() {
   if (!firstUnreadMessage.value) return;
 
   if (store.siteMessage.ignoreMessageIds.includes(firstUnreadMessage.value.id))
@@ -38,7 +38,7 @@ const toIgnore = () => {
 
   store.siteMessage.ignoreMessageIds.push(firstUnreadMessage.value.id);
   updateSiteMessage();
-};
+}
 
 watch(
   () => store.siteMessage.messages,
@@ -94,7 +94,7 @@ onMounted(() => {
 .site-message-main {
   width: 280px;
   margin: -8px -14px;
-  border-radius: var(--app-border-radius);
+  border-radius: 3px;
   overflow: hidden;
 }
 .site-message-title {

+ 33 - 0
src/styles/global.css

@@ -174,3 +174,36 @@ body {
 }
 
 /* naive ui custome */
+.n-table.n-table-text-center {
+  text-align: center;
+}
+/* n-button */
+.n-button + .n-button {
+  margin-left: 10px;
+}
+.n-button--block + .n-button--block {
+  margin: 0;
+}
+/* n-dialog */
+.qm-modal.n-dialog {
+  position: absolute;
+  top: 10vh;
+  left: 50%;
+  transform: translateX(-50%);
+  padding: 0px;
+}
+.qm-modal.n-dialog .n-dialog__close {
+  margin: 16px 20px auto auto;
+}
+.qm-modal.n-dialog .n-dialog__title {
+  padding: 15px 20px;
+  line-height: 20px;
+  border-bottom: 1px solid var(--app-color-border);
+}
+.qm-modal.n-dialog .n-dialog__content {
+  margin: 0;
+  padding: 20px;
+}
+.qm-modal.n-dialog .n-dialog__action {
+  padding: 15px 20px;
+}

+ 69 - 0
src/utils/download.ts

@@ -0,0 +1,69 @@
+import { AxiosResponse } from "axios";
+
+const parseDownloadFilename = (dispositionInfo: string): string => {
+  if (!dispositionInfo) return "";
+
+  const filename = dispositionInfo.match(/attachment;filename=(.*)/)[1];
+  return filename ? decodeURI(filename) : "";
+};
+
+interface ApiFunc {
+  (): Promise<AxiosResponse<Blob, any>>;
+}
+/**
+ * 通过api下载文件
+ * @param axiosFetchFunc axios请求方法
+ * @param fileName 文件保存名称
+ * @returns
+ */
+export function downloadBlobByApi(axiosFetchFunc: ApiFunc, fileName?: string) {
+  return axiosFetchFunc().then((res: AxiosResponse) => {
+    console.log(res);
+
+    const filename =
+      fileName || parseDownloadFilename(res.headers["content-disposition"]);
+    downloadByBlob(new Blob([res.data]), filename);
+    return true;
+  });
+}
+
+/**
+ * 下载blob
+ * @param {Blob} data blob对象
+ * @param {String} filename 文件名
+ */
+export function downloadByBlob(data: Blob, filename?: string) {
+  const blobUrl = window.URL.createObjectURL(data);
+  downloadByUrl(blobUrl, filename);
+}
+
+/**
+ * 下载url
+ * @param {String} url 文件下载地址
+ * @param {String}} filename 文件名
+ */
+export function downloadByUrl(url: string, filename?: string) {
+  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);
+}
+
+/**
+ * 通过url获得dataURL
+ * @param url 链接
+ * @returns dataURL
+ */
+export function toBlobByUrl(url: string) {
+  return fetch(url).then((response) => {
+    return response.blob();
+  });
+}

+ 23 - 0
src/utils/md5.ts

@@ -0,0 +1,23 @@
+import jsmd5 from "js-md5";
+
+/**
+ *
+ * @param {any} str 字符串
+ */
+export const MD5 = (content: string): string => {
+  return jsmd5(content);
+};
+
+export const fileMD5 = (file: File): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onloadend = function () {
+      const arrayBuffer = reader.result;
+      resolve(jsmd5(arrayBuffer));
+    };
+    reader.onerror = function (err) {
+      reject(err);
+    };
+    reader.readAsArrayBuffer(file);
+  });
+};