Ver código fonte

feat: 导出图片页面

zhangjie 1 ano atrás
pai
commit
57fea9cd7e

+ 2 - 0
electron/main/index.ts

@@ -18,6 +18,7 @@ function createWindow(): void {
     webPreferences: {
       preload: join(__dirname, '../preload/index.js'),
       sandbox: false,
+      webSecurity: false,
     },
   });
 
@@ -56,6 +57,7 @@ app.whenReady().then(() => {
 
   // use electron
   useElectron();
+  // use multiple window porcess
   useWinProcess();
 
   createWindow();

+ 1 - 1
src/api/interceptor.ts

@@ -103,7 +103,7 @@ axios.interceptors.response.use(
 
     if (response.config.responseType === 'blob') return response;
 
-    return response.data;
+    return response.data.data;
   },
   (error) => {
     // 关闭loading提示

+ 34 - 0
src/api/task.ts

@@ -5,6 +5,11 @@ import {
   StudentObjectiveInfo,
   SemesterItem,
   ExamItem,
+  CourseItem,
+  TrackExportListParams,
+  TrackExportListPageRes,
+  CourseQueryParams,
+  PaperNumberQueryParams,
 } from './types/task';
 
 // semester
@@ -21,6 +26,35 @@ export function examQuery(semesterId: string): Promise<ExamItem[]> {
     }
   );
 }
+// course
+export function courseQuery(params: CourseQueryParams): Promise<CourseItem[]> {
+  return axios.post(
+    '/api/admin/basic/condition/list_course',
+    {},
+    {
+      params,
+    }
+  );
+}
+// paper_number
+export function paperNumberQuery(
+  params: PaperNumberQueryParams
+): Promise<string[]> {
+  return axios.post(
+    '/api/admin/basic/condition/list_paper_number',
+    {},
+    {
+      params,
+    }
+  );
+}
+
+// 成绩查询列表
+export async function trackExportListPage(
+  params: TrackExportListParams
+): Promise<TrackExportListPageRes> {
+  return axios.post('/api/admin/mark/setting/scoreList', {}, { params });
+}
 
 /** 查看单个学生的试卷轨迹 */
 export async function getSingleStudentTaskOfStudentTrack(

+ 35 - 0
src/api/types/task.ts

@@ -1,3 +1,5 @@
+import { PageResult, PageParams } from './common';
+
 export interface SemesterItem {
   id: string;
   name: string;
@@ -6,6 +8,21 @@ export interface ExamItem {
   id: string;
   name: string;
 }
+export interface CourseItem {
+  id: string;
+  name: string;
+  code: string;
+}
+
+export interface CourseQueryParams {
+  semesterId: string;
+  examId: string;
+}
+export interface PaperNumberQueryParams {
+  semesterId: string;
+  examId: string;
+  courseId: string;
+}
 
 export interface CardData {
   id: string;
@@ -156,3 +173,21 @@ export type StudentObjectiveInfo = {
   titles: { [index: number]: string };
   success: boolean;
 };
+
+export interface TrackExportListFilter {
+  semesterId: string;
+  examId: string;
+  courseId: string;
+  paperNumber: string;
+}
+export type TrackExportListParams = PageParams<TrackExportListFilter>;
+
+export interface TrackExportItem {
+  examId: string;
+  courseCode: string;
+  courseName: string;
+  paperNumber: string;
+  studentCount: number;
+  uploadCount: number;
+}
+export type TrackExportListPageRes = PageResult<TrackExportItem>;

+ 1 - 1
src/api/user.ts

@@ -7,7 +7,7 @@ export function login(data: LoginData): Promise<UserState> {
 }
 
 export function schoolList(): Promise<SchoolItem[]> {
-  return axios.post('/api/admin/common/school/list', {});
+  return axios.post('/api/admin/client/school/list', {});
 }
 
 export function userLogout() {

+ 37 - 75
src/assets/styles/home.less

@@ -19,6 +19,10 @@
   min-width: 800px;
   min-height: 600px;
   background: var(--color-background);
+
+  .home-view {
+    margin: 16px;
+  }
 }
 
 .home-head {
@@ -28,38 +32,50 @@
   top: 0;
   left: 0;
   z-index: 99;
-  background-color: #262626;
-  padding: 12px 24px;
+  padding: 12px 16px 11px;
+  background-color: #fff;
 
-  .home-user {
-    ul {
-      font-size: 0;
-    }
-    li {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid var(--color-border);
+
+  .home-title {
+    font-size: 18px;
+    font-weight: bold;
+  }
+
+  .home-action {
+    &-item {
       display: inline-block;
-      vertical-align: top;
-      font-size: 14px;
-      border-radius: 4px;
-      border: 1px solid #686868;
+      vertical-align: middle;
       height: 32px;
-      line-height: 20px;
-      padding: 5px 12px;
-      color: #fff;
+      line-height: 32px;
+      padding: 0 8px;
+      font-weight: 400;
+      border-radius: var(--border-radius-small);
+
+      &:not(:first-child) {
+        margin-left: 8px;
+      }
 
-      &:not(:last-child) {
-        margin-right: 8px;
+      .svg-icon {
+        font-size: 18px;
       }
 
-      > i {
-        margin-right: 8px;
-        margin-top: -2px;
+      .svg-icon + span {
+        margin-left: 6px;
+      }
+      > span {
+        display: inline-block;
+        vertical-align: middle;
       }
 
-      &.hover {
+      &.cursor {
         cursor: pointer;
 
         &:hover {
-          border-color: #fff;
+          background-color: var(--color-background);
         }
       }
     }
@@ -86,57 +102,3 @@
     }
   }
 }
-
-// layout
-.layout {
-  .home-head {
-    background-color: transparent;
-  }
-
-  .head-logo {
-    position: absolute;
-    width: 140px;
-    height: 24px;
-    top: 48px;
-    left: 48px;
-    background-image: url(../images/bg-logo.png);
-    background-repeat: no-repeat;
-    background-size: 100% 100%;
-  }
-
-  .head-actions {
-    font-size: 0;
-    position: absolute;
-    top: 12px;
-    right: 12px;
-  }
-  .action-icon {
-    display: inline-block;
-    vertical-align: top;
-    width: 32px;
-    height: 32px;
-    cursor: pointer;
-    margin-left: 8px;
-    border-radius: 3px;
-
-    &:hover {
-      background-color: #bedaff;
-    }
-  }
-  .action-min {
-    background-image: url(../images/bg-min.png);
-    background-repeat: no-repeat;
-    background-size: 100% 100%;
-  }
-  .action-close {
-    background-image: url(../images/bg-close.png);
-    background-repeat: no-repeat;
-    background-size: 100% 100%;
-  }
-  .action-logout {
-    background-image: url(../images/icon-logout.png);
-    background-size: 70% 70%;
-    background-repeat: no-repeat;
-    background-position: center;
-  }
-}

+ 15 - 0
src/assets/styles/pages.less

@@ -1,5 +1,20 @@
 /* login */
 .login-home {
+  .home-head {
+    background-color: transparent;
+    border: none;
+  }
+
+  .head-logo {
+    position: absolute;
+    width: 140px;
+    height: 24px;
+    top: 48px;
+    left: 48px;
+    background-image: url(../images/bg-logo.png);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+  }
   .home-body {
     background-image: url(../images/bg-login.png);
     background-repeat: no-repeat;

+ 4 - 0
src/components/index.ts

@@ -10,6 +10,8 @@ import StatusTag from './status-tag/index.vue';
 
 import SelectExam from './select-exam/index.vue';
 import SelectSemester from './select-semester/index.vue';
+import SelectCourse from './select-course/index.vue';
+import SelectPaperNumber from './select-paper-number/index.vue';
 
 export default {
   install(Vue: App) {
@@ -22,5 +24,7 @@ export default {
 
     Vue.component('SelectExam', SelectExam);
     Vue.component('SelectSemester', SelectSemester);
+    Vue.component('SelectCourse', SelectCourse);
+    Vue.component('SelectPaperNumber', SelectPaperNumber);
   },
 };

+ 85 - 0
src/components/select-course/index.vue

@@ -0,0 +1,85 @@
+<template>
+  <a-select
+    v-model="selected"
+    :placeholder="placeholder"
+    :allow-clear="clearable"
+    :disabled="disabled"
+    :options="optionList"
+    allow-search
+    popup-container="body"
+    v-bind="attrs"
+    @change="onChange"
+  >
+    <template v-if="prefix" #prefix>课程</template>
+  </a-select>
+</template>
+
+<script setup lang="ts">
+  import { ref, useAttrs, watch } from 'vue';
+  import { courseQuery } from '@/api/task';
+  import { OptionListItem } from '@/types/global';
+
+  defineOptions({
+    name: 'SelectCourse',
+  });
+  type ValueType = string | Array<string>;
+
+  const props = defineProps<{
+    modelValue: ValueType;
+    clearable?: boolean;
+    disabled?: boolean;
+    placeholder?: string;
+    multiple?: boolean;
+    prefix?: boolean;
+    semesterId: string;
+    examId: string;
+  }>();
+  const emit = defineEmits(['update:modelValue', 'change']);
+  const attrs = useAttrs();
+
+  const selected = ref<string | Array<string> | undefined>();
+  const optionList = ref<OptionListItem[]>([]);
+  const search = async () => {
+    optionList.value = [];
+    if (!props.examId) return;
+    const resData = await courseQuery({
+      semesterId: props.semesterId,
+      examId: props.examId,
+    });
+
+    optionList.value = (resData || []).map((item) => {
+      return { ...item, value: item.id, label: `${item.name}(${item.code})` };
+    });
+  };
+  search();
+
+  const onChange = () => {
+    const selectedData = props.multiple
+      ? optionList.value.filter(
+          (item) =>
+            selected.value && (selected.value as string[]).includes(item.value)
+        )
+      : optionList.value.filter((item) => selected.value === item.value);
+    emit('update:modelValue', selected.value || null);
+    emit('change', props.multiple ? selectedData : selectedData[0]);
+  };
+
+  watch(
+    () => props.modelValue,
+    (val) => {
+      selected.value = val || undefined;
+    },
+    {
+      immediate: true,
+    }
+  );
+  watch(
+    () => props.examId,
+    (val, oldval) => {
+      if (val !== oldval) search();
+    },
+    {
+      immediate: true,
+    }
+  );
+</script>

+ 20 - 13
src/components/select-exam/index.vue

@@ -22,21 +22,20 @@
   defineOptions({
     name: 'SelectExam',
   });
-  type ValueType = string | Array<string> | null;
 
   const props = defineProps<{
-    modelValue: ValueType;
+    modelValue: string;
     clearable?: boolean;
     disabled?: boolean;
     placeholder?: string;
-    multiple?: boolean;
     prefix?: boolean;
-    semesterId: string | null;
+    selectDefault?: boolean;
+    semesterId: string;
   }>();
-  const emit = defineEmits(['update:modelValue', 'change']);
+  const emit = defineEmits(['update:modelValue', 'change', 'defaultSelected']);
   const attrs = useAttrs();
 
-  const selected = ref<string | Array<string> | undefined>();
+  const selected = ref<string | undefined>();
   const optionList = ref<OptionListItem[]>([]);
   const search = async () => {
     optionList.value = [];
@@ -46,18 +45,26 @@
     optionList.value = (resData || []).map((item) => {
       return { ...item, value: item.id, label: item.name };
     });
+    if (props.selectDefault) emitDefaultOption();
   };
   search();
 
+  function emitDefaultOption() {
+    const defaultData = optionList.value[0];
+    if (!defaultData) return;
+
+    selected.value = defaultData.value;
+    emit('update:modelValue', selected.value || null);
+    emit('change', defaultData);
+    emit('defaultSelected', defaultData);
+  }
+
   const onChange = () => {
-    const selectedData = props.multiple
-      ? optionList.value.filter(
-          (item) =>
-            selected.value && (selected.value as string[]).includes(item.value)
-        )
-      : optionList.value.filter((item) => selected.value === item.value);
+    const selectedData = optionList.value.filter(
+      (item) => selected.value === item.value
+    );
     emit('update:modelValue', selected.value || null);
-    emit('change', props.multiple ? selectedData : selectedData[0]);
+    emit('change', selectedData);
   };
 
   watch(

+ 87 - 0
src/components/select-paper-number/index.vue

@@ -0,0 +1,87 @@
+<template>
+  <a-select
+    v-model="selected"
+    :placeholder="placeholder"
+    :allow-clear="clearable"
+    :disabled="disabled"
+    :options="optionList"
+    allow-search
+    popup-container="body"
+    v-bind="attrs"
+    @change="onChange"
+  >
+    <template v-if="prefix" #prefix>试卷编号</template>
+  </a-select>
+</template>
+
+<script setup lang="ts">
+  import { ref, useAttrs, watch } from 'vue';
+  import { paperNumberQuery } from '@/api/task';
+  import { OptionListItem } from '@/types/global';
+
+  defineOptions({
+    name: 'SelectPaperNumber',
+  });
+  type ValueType = string | Array<string>;
+
+  const props = defineProps<{
+    modelValue: ValueType;
+    clearable?: boolean;
+    disabled?: boolean;
+    placeholder?: string;
+    multiple?: boolean;
+    prefix?: boolean;
+    semesterId: string;
+    examId: string;
+    courseId: string;
+  }>();
+  const emit = defineEmits(['update:modelValue', 'change']);
+  const attrs = useAttrs();
+
+  const selected = ref<string | Array<string> | undefined>();
+  const optionList = ref<OptionListItem[]>([]);
+  const search = async () => {
+    optionList.value = [];
+    if (!props.courseId) return;
+    const resData = await paperNumberQuery({
+      semesterId: props.semesterId,
+      examId: props.examId,
+      courseId: props.courseId,
+    });
+
+    optionList.value = (resData || []).map((item) => {
+      return { value: item, label: item };
+    });
+  };
+  search();
+
+  const onChange = () => {
+    const selectedData = props.multiple
+      ? optionList.value.filter(
+          (item) =>
+            selected.value && (selected.value as string[]).includes(item.value)
+        )
+      : optionList.value.filter((item) => selected.value === item.value);
+    emit('update:modelValue', selected.value || null);
+    emit('change', props.multiple ? selectedData : selectedData[0]);
+  };
+
+  watch(
+    () => props.modelValue,
+    (val) => {
+      selected.value = val || undefined;
+    },
+    {
+      immediate: true,
+    }
+  );
+  watch(
+    () => props.courseId,
+    (val, oldval) => {
+      if (val !== oldval) search();
+    },
+    {
+      immediate: true,
+    }
+  );
+</script>

+ 19 - 12
src/components/select-semester/index.vue

@@ -22,20 +22,19 @@
   defineOptions({
     name: 'SelectSemester',
   });
-  type ValueType = string | Array<string> | null;
 
   const props = defineProps<{
-    modelValue: ValueType;
+    modelValue: string;
     clearable?: boolean;
     disabled?: boolean;
     placeholder?: string;
-    multiple?: boolean;
     prefix?: boolean;
+    selectDefault?: boolean;
   }>();
-  const emit = defineEmits(['update:modelValue', 'change']);
+  const emit = defineEmits(['update:modelValue', 'change', 'defaultSelected']);
   const attrs = useAttrs();
 
-  const selected = ref<string | Array<string> | undefined>();
+  const selected = ref<string | undefined>();
   const optionList = ref<OptionListItem[]>([]);
   const search = async () => {
     optionList.value = [];
@@ -44,18 +43,26 @@
     optionList.value = (resData || []).map((item) => {
       return { ...item, value: item.id, label: item.name };
     });
+    if (props.selectDefault) emitDefaultOption();
   };
   search();
 
+  function emitDefaultOption() {
+    const defaultData = optionList.value[0];
+    if (!defaultData) return;
+
+    selected.value = defaultData.value;
+    emit('update:modelValue', selected.value || null);
+    emit('change', defaultData);
+    emit('defaultSelected', defaultData);
+  }
+
   const onChange = () => {
-    const selectedData = props.multiple
-      ? optionList.value.filter(
-          (item) =>
-            selected.value && (selected.value as string[]).includes(item.value)
-        )
-      : optionList.value.filter((item) => selected.value === item.value);
+    const selectedData = optionList.value.filter(
+      (item) => selected.value === item.value
+    );
     emit('update:modelValue', selected.value || null);
-    emit('change', props.multiple ? selectedData : selectedData[0]);
+    emit('change', selectedData);
   };
 
   watch(

+ 1 - 0
src/constants/enumerate.ts

@@ -19,6 +19,7 @@ export type ExtTypeEnum = keyof typeof EXT_TYPE;
 export const PICTURE_TYPE = {
   track: '轨迹图',
   origin: '原图',
+  pdf: 'PDF',
 };
 
 export type PictureTypeEnum = keyof typeof PICTURE_TYPE;

+ 1 - 2
src/hooks/dict-option.ts

@@ -1,10 +1,9 @@
 import { ref } from 'vue';
-import { DEFAULT_LABEL, EXT_TYPE, PICTURE_TYPE } from '@/constants/enumerate';
+import { DEFAULT_LABEL, PICTURE_TYPE } from '@/constants/enumerate';
 import { dictToOption } from '@/utils/utils';
 import { SelectOptions } from '@/types/global';
 
 const dicts = {
-  EXT_TYPE,
   PICTURE_TYPE,
 };
 

+ 38 - 3
src/layout/default-layout.vue

@@ -1,16 +1,51 @@
 <template>
-  <div class="home layout">
+  <div class="home">
     <div class="home-head">
-      <div class="head-logo"></div>
+      <div>
+        <h1 class="home-title">图片导出工具</h1>
+      </div>
+      <div class="home-action">
+        <div class="home-action-item">
+          <svg-icon name="icon-home" fill="#BFBFBF" />
+          <span :title="userStore.curSchoolInfo.name">{{
+            userStore.curSchoolInfo.name
+          }}</span>
+        </div>
+        <div class="home-action-item">
+          <svg-icon name="icon-user" fill="#BFBFBF" />
+          <span :title="userStore.realName">{{ userStore.realName }}</span>
+        </div>
+        <a-tooltip content="退出登录" position="br">
+          <div class="home-action-item cursor" @click="toLogout">
+            <svg-icon name="icon-logout" />
+          </div>
+        </a-tooltip>
+      </div>
     </div>
     <div class="home-body">
-      <router-view />
+      <!-- home-view: page detail -->
+      <div class="home-view">
+        <router-view />
+      </div>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
+  import { useUserStore } from '@/store';
+  import { modalConfirm } from '@/utils/arco';
+
   defineOptions({
     name: 'DefaultLayout',
   });
+
+  const userStore = useUserStore();
+
+  async function toLogout() {
+    const confirmRes = await modalConfirm('提示', `确定要退出登录吗?`).catch(
+      () => false
+    );
+    if (confirmRes !== 'confirm') return;
+    userStore.logout();
+  }
 </script>

+ 2 - 2
src/router/routes/modules/base.ts

@@ -6,7 +6,7 @@ const routes: AppRouteRecordRaw = {
   name: 'base',
   component: DEFAULT_LAYOUT,
   meta: {
-    requiresAuth: false,
+    requiresAuth: true,
   },
   children: [
     {
@@ -15,7 +15,7 @@ const routes: AppRouteRecordRaw = {
       component: () => import('@/views/base/track-export/index.vue'),
       meta: {
         title: '轨迹图导出',
-        requiresAuth: false,
+        requiresAuth: true,
       },
     },
   ],

+ 5 - 0
src/store/modules/app/index.ts

@@ -6,6 +6,11 @@ const useAppStore = defineStore('app', {
     version: '1.0.0',
     domain: '',
     device: 'desktop',
+    trackConfig: {
+      pictureType: ['track'],
+      outputDir: '',
+      outputDirIsDefault: true,
+    },
   }),
 
   getters: {

+ 9 - 0
src/store/modules/app/types.ts

@@ -1,5 +1,14 @@
+import { PictureTypeEnum } from '@/constants/enumerate';
+
+export interface TrackConfigType {
+  pictureType: PictureTypeEnum[];
+  outputDir: string;
+  outputDirIsDefault: boolean;
+}
+
 export interface AppState {
   version: string;
   domain: string;
   device: string;
+  trackConfig: TrackConfigType;
 }

+ 141 - 102
src/views/base/track-export/index.vue

@@ -1,123 +1,162 @@
 <template>
+  <div class="part-box is-filter">
+    <a-space class="filter-line" :size="12" wrap>
+      <SelectSemester
+        v-model="searchModel.semesterId"
+        :clearable="false"
+        select-default
+        placeholder="请选择"
+      />
+      <SelectExam
+        v-model="searchModel.examId"
+        :semester-id="searchModel.semesterId"
+        :clearable="false"
+        select-default
+        placeholder="请选择"
+        @default-selected="toPage(1)"
+      />
+      <SelectCourse
+        v-model="searchModel.courseId"
+        :semester-id="searchModel.semesterId"
+        :exam-id="searchModel.examId"
+        placeholder="请选择"
+      />
+      <SelectPaperNumber
+        v-model="searchModel.paperNumber"
+        :semester-id="searchModel.semesterId"
+        :exam-id="searchModel.examId"
+        :course-id="searchModel.courseId"
+        placeholder="请选择"
+      />
+      <a-button type="primary" @click="toPage(1)">查询</a-button>
+    </a-space>
+  </div>
   <div class="part-box">
-    <a-form ref="formRef" :model="formData" :rules="rules" auto-label-width>
-      <a-form-item field="semesterId" label="学期">
-        <!-- <SelectSemester v-model="formData.semesterId" placeholder="请选择" /> -->
-      </a-form-item>
-      <a-form-item field="examId" label="考试">
-        <!-- <SelectExam v-model="formData.examId" placeholder="请选择" /> -->
-      </a-form-item>
-      <a-form-item field="extType" label="文件类型">
-        <a-radio-group v-model="formData.extType">
-          <a-radio
-            v-for="(option, index) in extOptions"
-            :key="index"
-            :value="option.value"
-            >{{ option.label }}</a-radio
-          >
-        </a-radio-group>
-      </a-form-item>
-      <a-form-item field="pictureType" label="图片类型">
-        <a-checkbox-group v-model="formData.pictureType">
-          <a-checkbox
-            v-for="(option, index) in pictureOptions"
-            :key="index"
-            :value="option.value"
-            >{{ option.label }}</a-checkbox
-          >
-        </a-checkbox-group>
-      </a-form-item>
-      <a-form-item field="outputDir" label="保存目录">
-        <a-input-search
-          v-model.trim="formData.outputDir"
-          :style="{ width: '400px' }"
-          readonly
-          search-button
-          button-text="选择"
-          @search="toSelectDir"
+    <a-space class="part-action" :size="6">
+      <a-button type="text" @click="toBatchDownload">
+        <template #icon>
+          <svg-icon name="icon-import" />
+        </template>
+        批量下载
+      </a-button>
+      <a-button type="text" @click="toSet">
+        <template #icon>
+          <svg-icon name="icon-add" />
+        </template>
+        下载设置
+      </a-button>
+    </a-space>
+    <a-table
+      class="page-table"
+      :columns="columns"
+      :data="dataList"
+      :pagination="pagination"
+      :scroll="{ x: 1200 }"
+      :bordered="false"
+    >
+      <template #courseCode="{ record }">
+        {{ courseNameCodeFilter(record) }}
+      </template>
+      <template #action="{ record }">
+        <a-button
+          type="text"
+          class="btn-primary"
+          disabled
+          @click="toDownload(record)"
+          >下载</a-button
         >
-        </a-input-search>
-      </a-form-item>
-      <a-form-item>
-        <a-button type="primary">开始</a-button>
-      </a-form-item>
-    </a-form>
+      </template>
+    </a-table>
   </div>
+
+  <!-- ModifySet -->
+  <ModifySet ref="modifySetRef" />
 </template>
 
 <script setup lang="ts">
   import { ref, reactive } from 'vue';
-  import type { FormInstance } from '@arco-design/web-vue/es/form';
-  import { FormRules } from '@/types/global';
-  import useDictOption from '@/hooks/dict-option';
-  import { ExtTypeEnum, PictureTypeEnum } from '@/constants/enumerate';
+  import { Message, TableColumnData } from '@arco-design/web-vue';
+
+  import useTable from '@/hooks/table';
+  import { courseNameCodeFilter } from '@/utils/filter';
+  import { TrackExportItem } from '@/api/types/task';
+  import { trackExportListPage } from '@/api/task';
+  import { TrackConfigType } from '@/store/modules/app/types';
+  import { useAppStore } from '@/store';
+
+  import ModifySet from './modifySet.vue';
 
   defineOptions({
     name: 'TrackExport',
   });
 
-  const { optionList: extOptions } = useDictOption('EXT_TYPE');
-  const { optionList: pictureOptions } = useDictOption('PICTURE_TYPE');
+  const appStore = useAppStore();
 
-  const defaultFormData = {
+  const searchModel = reactive({
     semesterId: '',
     examId: '',
-    extType: 'img' as ExtTypeEnum,
-    pictureType: ['track'] as PictureTypeEnum[],
-    outputDir: '',
-  };
-
-  interface FormDataType {
-    semesterId: string;
-    examId: string;
-    extType: ExtTypeEnum;
-    pictureType: PictureTypeEnum[];
-    outputDir: string;
-  }
-
-  const formRef = ref<FormInstance>();
-  const formData = reactive<FormDataType>({ ...defaultFormData });
-  const rules: FormRules<keyof FormDataType> = {
-    semesterId: [
-      {
-        required: true,
-        message: '请选择学期',
-      },
-    ],
-    examId: [
-      {
-        required: true,
-        message: '请选择考试',
-      },
-    ],
-    extType: [
-      {
-        required: true,
-        message: '请选择文件类型',
-      },
-    ],
-    pictureType: [
-      {
-        required: true,
-        message: '请选择图片类型',
-      },
-    ],
-    outputDir: [
-      {
-        required: true,
-        message: '请选择保存目录',
-      },
-    ],
-  };
+    courseId: '',
+    paperNumber: '',
+  });
 
-  async function toSelectDir() {
-    const result = await window.electron.dialogSelectFile({
-      title: '选择保存目录',
-      properties: ['openDirectory'],
-    });
+  const columns: TableColumnData[] = [
+    {
+      title: '课程(代码)',
+      slotName: 'courseCode',
+    },
+    {
+      title: '试卷编号',
+      slotName: 'paperNumber',
+    },
+    {
+      title: '参考人数',
+      slotName: 'studentCount',
+    },
+    {
+      title: '操作',
+      slotName: 'action',
+      width: 220,
+      fixed: 'right',
+      cellClass: 'action-column',
+    },
+  ];
+  const { dataList, pagination, toPage } = useTable<TrackExportItem>(
+    trackExportListPage,
+    searchModel,
+    false
+  );
 
-    if (result.canceled) return;
+  // table action
+  const modifySetRef = ref();
+  function toSet() {
+    modifySetRef.value?.open();
+  }
+  function checkTrackConfigExist() {
+    return Boolean(
+      appStore.trackConfig.outputDir && appStore.trackConfig.pictureType.length
+    );
+  }
+  function toBatchDownload() {
+    if (!checkTrackConfigExist()) {
+      Message.error('请先编辑下载设置');
+      return;
+    }
+    console.log('batch');
+  }
+  function toDownload(row: TrackExportItem) {
+    if (!checkTrackConfigExist()) {
+      Message.error('请先编辑下载设置');
+      return;
+    }
+    console.log('download', row);
+  }
 
-    formData.outputDir = result.filePaths[0];
+  async function initData() {
+    const res = await window.db.getDict('trackConfig');
+    if (res) {
+      const trackConfig = JSON.parse(res) as TrackConfigType;
+      appStore.setInfo({ trackConfig });
+    }
   }
+  initData();
 </script>

+ 152 - 0
src/views/base/track-export/modifySet.vue

@@ -0,0 +1,152 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :width="500"
+    title-align="start"
+    top="10vh"
+    :align-center="false"
+    :mask-closable="false"
+    :esc-to-close="false"
+    @before-open="modalBeforeOpen"
+  >
+    <template #title> 下载设置 </template>
+    <a-form ref="formRef" :model="formData" :rules="rules" auto-label-width>
+      <a-form-item field="pictureType" label="下载文件">
+        <a-checkbox-group v-model="formData.pictureType" direction="vertical">
+          <a-checkbox
+            v-for="(option, index) in pictureOptions"
+            :key="index"
+            :value="option.value"
+          >
+            {{ option.label }}
+            <span class="tips-info"
+              >({{ pictureDesc[option.value as PictureTypeEnum] }})</span
+            >
+          </a-checkbox>
+        </a-checkbox-group>
+      </a-form-item>
+      <a-form-item field="outputDir" label="保存目录">
+        <a-input-search
+          v-model.trim="formData.outputDir"
+          :style="{ width: '400px' }"
+          readonly
+          search-button
+          button-text="浏览"
+          @search="toSelectDir"
+        >
+        </a-input-search>
+      </a-form-item>
+    </a-form>
+
+    <template #footer>
+      <a-button @click="close">取消</a-button>
+      <a-button type="primary" :disabled="loading" @click="confirm"
+        >确认</a-button
+      >
+    </template>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+  import { nextTick, reactive, ref } from 'vue';
+  import { Message } from '@arco-design/web-vue';
+  import type { FormInstance } from '@arco-design/web-vue/es/form';
+  import useLoading from '@/hooks/loading';
+  import useModal from '@/hooks/modal';
+  import { objAssign, objModifyAssign } from '@/utils/utils';
+  import { FormRules } from '@/types/global';
+  import useDictOption from '@/hooks/dict-option';
+  import { useAppStore } from '@/store';
+  import { TrackConfigType } from '@/store/modules/app/types';
+  import { PictureTypeEnum } from '@/constants/enumerate';
+
+  defineOptions({
+    name: 'ModifySet',
+  });
+
+  /* modal */
+  const { visible, open, close } = useModal();
+  defineExpose({ open, close });
+
+  const appStore = useAppStore();
+
+  const { optionList: pictureOptions } = useDictOption('PICTURE_TYPE');
+  const pictureDesc: Record<PictureTypeEnum, string> = {
+    track: '批阅后图片',
+    origin: '学生作答原图',
+    pdf: '将学生批阅后的图片合并成一个PDF,一个学生一个PDF',
+  };
+
+  const defaultFormData = {
+    pictureType: ['track'] as PictureTypeEnum[],
+    outputDir: '',
+    outputDirIsDefault: true,
+  };
+
+  const emit = defineEmits(['modified']);
+
+  const formRef = ref<FormInstance>();
+  const formData = reactive<TrackConfigType>({ ...defaultFormData });
+  const rules: FormRules<keyof TrackConfigType> = {
+    pictureType: [
+      {
+        required: true,
+        message: '请选择图片类型',
+      },
+    ],
+    outputDir: [
+      {
+        required: true,
+        message: '请选择保存目录',
+      },
+    ],
+  };
+
+  async function toSelectDir() {
+    const result = await window.electron.dialogSelectFile({
+      title: '选择保存目录',
+      properties: ['openDirectory'],
+    });
+
+    if (result.canceled) return;
+
+    formData.outputDir = result.filePaths[0];
+  }
+
+  /* confirm */
+  const { loading, setLoading } = useLoading();
+  async function confirm() {
+    const err = await formRef.value?.validate();
+    if (err) return;
+
+    setLoading(true);
+    const datas = objAssign(formData, {});
+    let res = true;
+    await window.db
+      .updateDict({ key: 'trackConfig', val: JSON.stringify(datas) })
+      .catch(() => {
+        res = false;
+      });
+    setLoading(false);
+    if (!res) return;
+
+    appStore.setInfo({ trackConfig: datas });
+    Message.success('修改成功!');
+    emit('modified', datas);
+    close();
+  }
+  /* init modal */
+  async function modalBeforeOpen() {
+    const res = await window.db.getDict('trackConfig');
+    if (res) {
+      const config = JSON.parse(res);
+      objModifyAssign(formData, config);
+    } else {
+      objModifyAssign(formData, defaultFormData);
+    }
+
+    nextTick(() => {
+      formRef.value?.clearValidate();
+    });
+  }
+</script>

+ 1 - 1
src/views/login/home.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="home layout login-home">
+  <div class="home login-home">
     <div class="home-head">
       <div class="head-logo"></div>
     </div>

+ 5 - 8
src/views/login/login/index.vue

@@ -81,7 +81,7 @@
 
   const formRef = ref<FormInstance>();
   const formData = reactive<LoginData>({
-    schoolCode: 'test-school-1',
+    schoolCode: '',
     type: 'ACCOUNT',
     loginName: 'admin',
     password: '12345678',
@@ -91,7 +91,7 @@
     loginName: [
       {
         required: true,
-        message: '请输入用户名',
+        message: '请输入账号',
       },
     ],
     password: [
@@ -114,16 +114,13 @@
 
   const schools = ref<OptionListItem[]>([]);
   async function getSchools() {
-    if (!schools.value.length) {
-      schools.value = [{ value: 'test-school-1', label: '学校1' }];
-      return;
-    }
-
     if (!appStore.domain) return;
     const resData = await schoolList();
     schools.value = (resData || []).map((item) => {
       return { value: item.code, label: item.name };
     });
+
+    formData.schoolCode = schools.value[0].value;
   }
 
   /* submit */
@@ -148,7 +145,7 @@
     userStore.setInfo(data);
 
     router.push({
-      name: 'SchoolSelect',
+      name: 'TrackExport',
     });
   }