Selaa lähdekoodia

现在练习首页

zhangjie 3 vuotta sitten
vanhempi
commit
c6dc6c75d3

+ 32 - 0
src/api/onlinePractice.ts

@@ -0,0 +1,32 @@
+import { httpApp } from "@/plugins/axiosApp";
+import { PracticeExam } from "@/types/student-client";
+
+type ExamItem = {
+  id: number;
+  name: string;
+};
+
+/** 获取在线练习考试列表 */
+export async function onlinePracticeExamListApi(studentId: number) {
+  return httpApp.get<any, { data: ExamItem[] }>(
+    `/api/ecs_exam_work/exam/queryByNameLike`,
+    {
+      params: {
+        examTyes: "PRACTICE",
+        name: "",
+        studentId,
+      },
+    }
+  );
+}
+/** 获取在线练习考试科目列表 */
+export async function onlinePracticeCourseListApi(examId: number) {
+  return httpApp.get<any, { data: PracticeExam[] }>(
+    `/api/branch_ecs_oe_admin/practice/queryPracticeCourseList`,
+    {
+      params: {
+        examId,
+      },
+    }
+  );
+}

+ 1 - 2
src/components/MainLayout/MainLayout.vue

@@ -105,8 +105,7 @@ onMounted(async () => {
   await getMenus();
 
   if (menuOptions.length && route.name === "MainLayout") {
-    void router.push({ name: menuOptions.slice(-1)[0].routeName });
-    // void router.push({ name: menuOptions[0].routeName });
+    void router.push({ name: menuOptions[0].routeName });
     return;
   }
   routerChange();

+ 12 - 0
src/components/MainLayout/menuConfig.ts

@@ -23,6 +23,18 @@ export const menuMap: MenuItem[] = [
     code: "STU_ONLINE_PRACTICE",
     name: "在线练习",
     routeName: "OnlinePractice",
+    children: [
+      {
+        name: "练习详情",
+        routeName: "OnlinePracticeRecord",
+        children: [
+          {
+            name: "成绩报告",
+            routeName: "OnlinePracticeRecordDetail",
+          },
+        ],
+      },
+    ],
   },
   {
     code: "STU_OFFLINE_EXAM",

+ 7 - 1
src/features/OfflineExam/OfflineExam.vue

@@ -42,7 +42,13 @@ onMounted(() => {
 <template>
   <div v-if="answerCardUrl" class="off-exam-card">
     <span>答题纸模板:</span>
-    <n-button text :disabled="loading" @click="toDownloadPaper">下载</n-button>
+    <n-button
+      type="success"
+      size="small"
+      :disabled="loading"
+      @click="toDownloadPaper"
+      >下载</n-button
+    >
   </div>
   <OfflineExamList />
 </template>

+ 1 - 7
src/features/OfflineExam/OfflineExamList.vue

@@ -176,7 +176,7 @@ onMounted(() => {
             <div v-else>未上传</div>
           </td>
           <td>
-            <div v-if="course.paperId" class="column-action">
+            <div v-if="course.paperId">
               <n-button type="success" block @click="toViewPaper(course)"
                 >查看试卷</n-button
               >
@@ -216,9 +216,3 @@ onMounted(() => {
     @modified="getCourseList"
   />
 </template>
-
-<style scoped>
-.column-action .n-button--block:not(:last-child) {
-  margin-bottom: 10px;
-}
-</style>

+ 12 - 2
src/features/OfflineExam/OfflineExamUploadModal.vue

@@ -38,10 +38,18 @@ interface FileChangeOption {
 
 // upload option
 const maxUploadImageCount = 6;
+const UPLOAD_FILE_ACCEPT_TYPE: Record<string, string> = {
+  ZIP: ".zip,zip",
+  PDF: ".pdf",
+  IMAGE: ".jpeg,.jpg,.png",
+};
 const isImageType = computed(() => selectedFileType === "IMAGE");
 const uploadListType = computed(() =>
   isImageType.value ? "image-card" : "text"
 );
+const uploadFileAccept = computed(
+  () => UPLOAD_FILE_ACCEPT_TYPE[selectedFileType]
+);
 const uploadFileMaxCount = computed(() =>
   isImageType.value ? maxUploadImageCount : 1
 );
@@ -64,7 +72,6 @@ async function getUploadFileTypes() {
   selectedFileType = serverFileTypes[0];
 }
 function fileTypeChange() {
-  void UploadRef?.clear();
   uploadFileList = [];
 }
 
@@ -153,7 +160,9 @@ async function toSubmit() {
   if (fileValid.some((item) => !item)) return;
 
   if (props.course.offlineFiles && props.course.offlineFiles.length) {
-    const confirm = await dialogConfirm("已有作答附件,是否覆盖?");
+    const confirm = await dialogConfirm("已有作答附件,是否覆盖?").catch(
+      (error: string) => error
+    );
     if (confirm !== "confirm") return;
   }
 
@@ -237,6 +246,7 @@ defineExpose({
           <n-upload
             ref="UploadRef"
             v-model:fileList="uploadFileList"
+            :accept="uploadFileAccept"
             :disabled="loading"
             :listType="uploadListType"
             :multiple="isImageType"

+ 4 - 4
src/features/OfflineExam/fileFormatCheck.ts

@@ -20,7 +20,7 @@ function getMimetype(signature: string) {
   }
 }
 
-const mimeTypeSuffix: Recode<string, string[]> = {
+const mimeTypeSuffix: Record<string, string[]> = {
   "image/png": ["png"],
   "image/gif": ["gif"],
   "image/jpeg": ["jpeg", "jpg"],
@@ -32,9 +32,9 @@ 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 = [];
+      if (evt.target?.readyState === FileReader.DONE) {
+        const uint = new Uint8Array(evt.target.result!);
+        const bytes: string[] = [];
         uint.forEach((byte) => bytes.push(byte.toString(16)));
         const hex = bytes.join("").toUpperCase();
         const mimeType = getMimetype(hex);

+ 211 - 0
src/features/OnlinePractice/OnlinePractice.vue

@@ -0,0 +1,211 @@
+<script setup lang="ts">
+import {
+  onlinePracticeCourseListApi,
+  onlinePracticeExamListApi,
+} from "@/api/onlinePractice";
+import { store } from "@/store/store";
+import { PracticeExam } from "@/types/student-client";
+import moment from "moment";
+import { SelectOption } from "naive-ui";
+import { onMounted } from "vue";
+import { useRoute, useRouter } from "vue-router";
+
+const route = useRoute();
+const router = useRouter();
+
+// exam-list
+let examId: number = $ref();
+let examList = $ref<SelectOption[]>([]);
+async function getExamList() {
+  const userId = store.user.id;
+  const res = await onlinePracticeExamListApi(userId);
+  let exams = res.data || [];
+  examList = exams.map((item) => {
+    return {
+      label: item.name,
+      value: item.id,
+    };
+  });
+}
+
+// course-list
+let nowTime = $ref(Date.now() + store.sysTime.difference);
+let enterButtonClicked = $ref(false);
+let courseList = $ref<PracticeExam[]>([]);
+async function getCourseList() {
+  const res = await onlinePracticeCourseListApi(examId);
+  courseList = res.data || [];
+}
+
+async function checkExamInProgress() {
+  return Promise.resolve(false);
+}
+async function toEnterPractice(course: PracticeExam) {
+  if (enterButtonClicked) return;
+  const examInProgress = await checkExamInProgress();
+  if (examInProgress) {
+    // TODO:
+  } else {
+    void router.push({
+      name: "OnlineExamOverview",
+      params: {
+        examId: course.examId,
+      },
+    });
+  }
+}
+function toEnterPracticeList(course: PracticeExam) {
+  void router.push({
+    name: "OnlinePracticeRecord",
+    params: {
+      examId: course.examId,
+    },
+  });
+}
+
+// transfer
+function weekDayNameTransfer(week: number): string {
+  const weekdayNames: Record<number, string> = {
+    1: "一",
+    2: "二",
+    3: "三",
+    4: "四",
+    5: "五",
+    6: "六",
+    7: "日",
+  };
+  return weekdayNames[week] ? `周${weekdayNames[week]}` : "";
+}
+function courseInBetween(course: PracticeExam) {
+  return moment(nowTime).isBetween(
+    moment(course.startTime),
+    moment(course.endTime)
+  );
+}
+function courseInCycle(course: PracticeExam) {
+  if (!course.examCycleEnabled) return true;
+  const weekday = moment(nowTime).isoWeekday();
+  if (!course.examCycleWeek.includes(weekday)) return false;
+  const HHmm = moment(nowTime).format("HH:mm");
+  return course.examCycleTimeRange.some((range) =>
+    range.timeRange.some((v) => HHmm >= v[0] && HHmm <= v[1])
+  );
+}
+function courseIsDisabled(course: PracticeExam) {
+  return (
+    !courseInBetween(course) ||
+    course.allowExamCount < 1 ||
+    enterButtonClicked ||
+    !courseInCycle(course)
+  );
+}
+function courseDiableReason(course: PracticeExam) {
+  if (!courseInBetween(course)) {
+    return "当前时间不在练习开放时间范围";
+  } else if (course.allowExamCount < 1) {
+    return "无剩余练习次数";
+  } else if (enterButtonClicked) {
+    return "请稍后点击";
+  } else if (!courseInCycle(course)) {
+    return "不在练习时间周期内";
+  } else {
+    return "";
+  }
+}
+
+// init
+async function initData() {
+  await getExamList();
+  if (route.query.examId) {
+    examId = Number(route.query.examId);
+  } else {
+    examId = examList[0] && (examList[0].value as number);
+  }
+  void getCourseList();
+}
+
+onMounted(() => {
+  void initData();
+});
+</script>
+
+<template>
+  <div class="qm-mb-20">
+    <span>选择考试批次:</span>
+    <n-select
+      v-model:value="examId"
+      class="qm-select"
+      :options="examList"
+    ></n-select>
+  </div>
+  <n-table class="n-table-text-center" :singleLine="false">
+    <colgroup>
+      <col />
+      <col />
+      <col />
+      <col />
+      <col />
+      <col />
+      <col />
+      <col width="200px" />
+    </colgroup>
+    <thead>
+      <tr>
+        <th>课程</th>
+        <th>考试进入时间</th>
+        <th>考试时间周期</th>
+        <th>练习次数</th>
+        <th>最近正确率</th>
+        <th>平均正确率</th>
+        <th>最高正确率</th>
+        <th>操作</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr v-for="course in courseList" :key="course.courseCode">
+        <td>{{ course.courseName }}</td>
+        <td>
+          {{ course.startTime }} <br />
+          ~ <br />
+          {{ course.endTime }}
+        </td>
+        <td>
+          <div v-if="course.examCycleEnabled">
+            <p>
+              <span
+                v-for="(week, index) in course.examCycleWeek"
+                :key="index"
+                >{{ weekDayNameTransfer(week) }}</span
+              >
+            </p>
+            <p>
+              <span
+                v-for="(rang, rindex) in course.examCycleTimeRange"
+                :key="rindex"
+              >
+                {{ rang.timeRange[0] }}~{{ rang.timeRange[1] }}
+              </span>
+            </p>
+          </div>
+        </td>
+        <td>{{ course.practiceCount }}</td>
+        <td>{{ course.recentObjectiveAccuracy }}%</td>
+        <td>{{ course.aveObjectiveAccuracy }}%</td>
+        <td>{{ course.maxObjectiveAccuracy }}%</td>
+        <td>
+          <n-button
+            type="success"
+            block
+            :disabled="courseIsDisabled(course)"
+            :title="courseDiableReason(course)"
+            @click="toEnterPractice(course)"
+            >进入练习</n-button
+          >
+          <n-button type="success" block @click="toEnterPracticeList(course)"
+            >查看详情</n-button
+          >
+        </td>
+      </tr>
+    </tbody>
+  </n-table>
+</template>

+ 0 - 0
src/features/OnlinePractice/OnlinePracticeRecord.vue


+ 0 - 0
src/features/OnlinePractice/OnlinePracticeRecordDetail.vue


+ 18 - 0
src/router/index.ts

@@ -7,6 +7,9 @@ import ChangePassword from "@/features/ChangePassword/ChangePassword.vue";
 import SiteMessage from "@/features/SiteMessage/SiteMessage.vue";
 import SiteMessageDetail from "@/features/SiteMessage/SiteMessageDetail.vue";
 import OfflineExam from "@/features/OfflineExam/OfflineExam.vue";
+import OnlinePractice from "@/features/OnlinePractice/OnlinePractice.vue";
+import OnlinePracticeRecord from "@/features/OnlinePractice/OnlinePracticeRecord.vue";
+import OnlinePracticeRecordDetail from "@/features/OnlinePractice/OnlinePracticeRecordDetail.vue";
 import { resetStore, store } from "@/store/store";
 
 const routes: RouteRecordRaw[] = [
@@ -46,6 +49,21 @@ const routes: RouteRecordRaw[] = [
         component: OfflineExam,
         name: "OfflineExam",
       },
+      {
+        path: "online-practice",
+        component: OnlinePractice,
+        name: "OnlinePractice",
+      },
+      {
+        path: "online-practice/:examId/record",
+        component: OnlinePracticeRecord,
+        name: "OnlinePracticeRecord",
+      },
+      {
+        path: "online-practice/:examId/record/:examRecordDataId",
+        component: OnlinePracticeRecordDetail,
+        name: "OnlinePracticeRecordDetail",
+      },
     ],
   },
   {

+ 8 - 1
src/styles/global.css

@@ -174,6 +174,13 @@ body {
 }
 
 /* naive ui custome */
+/* n-select */
+.n-select.qm-select {
+  max-width: 200px;
+  display: inline-block;
+  vertical-align: middle;
+}
+/* table */
 .n-table.n-table-text-center {
   text-align: center;
 }
@@ -182,7 +189,7 @@ body {
   margin-left: 10px;
 }
 .n-button--block + .n-button--block {
-  margin: 0;
+  margin: 10px 0 0 0;
 }
 /* n-dialog */
 .qm-modal.n-dialog {

+ 28 - 17
src/types/student-client.d.ts

@@ -222,23 +222,34 @@ type OnlineExam = BaseExam &
     isObjScoreView: boolean;
   };
 
-type PracticeExam = BaseExam &
-  ExamCycle & {
-    /** 考试批次名称 */
-    examName: string;
-    /** 练习次数 */
-    practiceCount: number;
-    /** 剩余练习次数 */
-    allowExamCount: number;
-    /** 最近正确率(后端已乘100) */
-    recentObjectiveAccuracy: number;
-    /** 平均正确率(后端已乘100) */
-    aveObjectiveAccuracy: number;
-    /** 最高正确率(后端已乘100) */
-    maxObjectiveAccuracy: number;
-  };
+export type PracticeExam = ExamCycle & {
+  /** 考试批次id */
+  examId: number;
+  /** 考试批次名称 */
+  examName: string;
+  /** 考试类型 */
+  examType: ExamType;
+  /** 课程名称 */
+  courseName: string;
+  /** 课程code */
+  courseCode: string;
+  /** 考试开始时间。 */
+  startTime: string;
+  /** 考试结束时间 */
+  endTime: string;
+  /** 练习次数 */
+  practiceCount: number;
+  /** 剩余练习次数 */
+  allowExamCount: number;
+  /** 最近正确率(后端已乘100) */
+  recentObjectiveAccuracy: number;
+  /** 平均正确率(后端已乘100) */
+  aveObjectiveAccuracy: number;
+  /** 最高正确率(后端已乘100) */
+  maxObjectiveAccuracy: number;
+};
 
-type OfflineExam = BaseExam & {
+export type OfflineExam = BaseExam & {
   /** 机构名称 */
   orgName: string;
   /** 考试周期是否开启循环 */
@@ -247,7 +258,7 @@ type OfflineExam = BaseExam & {
   paperId: string;
 };
 
-type OnlinePracticeRecord = {
+export type OnlinePracticeRecord = {
   id: number;
   /** 考试开始时间。日期时间字符串,以前叫后台改为数字,但后台不改,遗留的设计错误。 */
   startTime: string;

+ 8 - 4
src/utils/download.ts

@@ -2,8 +2,8 @@ import { AxiosResponse } from "axios";
 
 const parseDownloadFilename = (dispositionInfo: string): string => {
   if (!dispositionInfo) return "";
-
-  const filename = dispositionInfo.match(/attachment;filename=(.*)/)[1];
+  const matchs = dispositionInfo.match(/attachment;filename=(.*)/) || [];
+  const filename = matchs[1];
   return filename ? decodeURI(filename) : "";
 };
 
@@ -18,7 +18,7 @@ interface ApiFunc {
  */
 export function downloadBlobByApi(axiosFetchFunc: ApiFunc, fileName?: string) {
   return axiosFetchFunc().then((res: AxiosResponse) => {
-    console.log(res);
+    // console.log(res);
 
     const filename =
       fileName || parseDownloadFilename(res.headers["content-disposition"]);
@@ -46,7 +46,11 @@ 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];
+  let fileName = filename;
+  if (!fileName) {
+    const strs = url.split("/").pop() || "";
+    fileName = strs.split("?")[0];
+  }
   tempLink.setAttribute("download", fileName);
   if (tempLink.download === "undefined") {
     tempLink.setAttribute("target", "_blank");

+ 1 - 1
src/utils/md5.ts

@@ -13,7 +13,7 @@ export const fileMD5 = (file: File): Promise<string> => {
     const reader = new FileReader();
     reader.onloadend = function () {
       const arrayBuffer = reader.result;
-      resolve(jsmd5(arrayBuffer));
+      resolve(jsmd5(arrayBuffer!));
     };
     reader.onerror = function (err) {
       reject(err);