Pārlūkot izejas kodu

feat: test-用户管理

zhangjie 1 dienu atpakaļ
vecāks
revīzija
d48b6470ea

+ 1 - 0
package.json

@@ -42,6 +42,7 @@
     "nprogress": "^0.2.0",
     "pinia": "^2.0.23",
     "pinia-plugin-persistedstate": "^3.2.1",
+    "qs": "^6.14.0",
     "query-string": "^8.0.3",
     "vue": "^3.2.40",
     "vue-echarts": "^7.0.3",

+ 9 - 6
pnpm-lock.yaml

@@ -44,6 +44,7 @@ specifiers:
   pinia-plugin-persistedstate: ^3.2.1
   postcss-html: ^1.5.0
   prettier: ^2.7.1
+  qs: ^6.14.0
   query-string: ^8.0.3
   rollup: ^2.56.3
   rollup-plugin-visualizer: ^5.8.2
@@ -77,6 +78,7 @@ dependencies:
   nprogress: 0.2.0
   pinia: 2.3.1_menxnlokfaeiafe3xmyq5jnddi
   pinia-plugin-persistedstate: 3.2.3_pinia@2.3.1
+  qs: 6.14.0
   query-string: 8.2.0
   vue: 3.5.16_typescript@4.9.5
   vue-echarts: 7.0.3_echarts@5.6.0+vue@3.5.16
@@ -1905,7 +1907,6 @@ packages:
     dependencies:
       call-bind-apply-helpers: 1.0.2
       get-intrinsic: 1.3.0
-    dev: true
 
   /callsites/3.1.0:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
@@ -5182,7 +5183,6 @@ packages:
   /object-inspect/1.13.4:
     resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
     engines: {node: '>= 0.4'}
-    dev: true
 
   /object-keys/1.1.1:
     resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
@@ -5710,6 +5710,13 @@ packages:
     engines: {node: '>=6'}
     dev: true
 
+  /qs/6.14.0:
+    resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
+    engines: {node: '>=0.6'}
+    dependencies:
+      side-channel: 1.1.0
+    dev: false
+
   /quansync/0.2.10:
     resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==}
     dev: true
@@ -6134,7 +6141,6 @@ packages:
     dependencies:
       es-errors: 1.3.0
       object-inspect: 1.13.4
-    dev: true
 
   /side-channel-map/1.0.1:
     resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
@@ -6144,7 +6150,6 @@ packages:
       es-errors: 1.3.0
       get-intrinsic: 1.3.0
       object-inspect: 1.13.4
-    dev: true
 
   /side-channel-weakmap/1.0.2:
     resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
@@ -6155,7 +6160,6 @@ packages:
       get-intrinsic: 1.3.0
       object-inspect: 1.13.4
       side-channel-map: 1.0.1
-    dev: true
 
   /side-channel/1.1.0:
     resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
@@ -6166,7 +6170,6 @@ packages:
       side-channel-list: 1.0.0
       side-channel-map: 1.0.1
       side-channel-weakmap: 1.0.2
-    dev: true
 
   /signal-exit/3.0.7:
     resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}

+ 1 - 0
src/api/interceptor.ts

@@ -16,6 +16,7 @@ import { useUserStore } from '../store';
 axios.defaults.timeout = 60 * 1000;
 axios.defaults.headers.post['Content-Type'] =
   'application/x-www-form-urlencoded';
+
 let load: MessageHandler | null = null;
 // 同一时间有多个请求时,会形成队列。在第一个请求创建loading,在最后一个响应关闭loading
 const queue: Array<string | undefined> = [];

+ 3 - 1
src/api/types/user.ts

@@ -87,7 +87,7 @@ export interface ResetPasswordParam {
 }
 export interface EnableUserParam {
   ids: number[];
-  enabled: boolean;
+  enable: boolean;
 }
 
 export interface UpdatePasswordParam {
@@ -121,6 +121,8 @@ export interface SystemInfo {
   fileServer: string;
   // 首页Logo
   indexLogo: string;
+  // 权限类型
+  authType: 'offline' | 'online';
 }
 
 // 角色相关 --------->

+ 25 - 39
src/api/user.ts

@@ -17,6 +17,7 @@ import type {
   RoleItem,
   UserMenuItem,
 } from './types/user';
+import { paramsSerializer } from '../utils/utils';
 
 // 登录
 export function login(data: LoginData): Promise<UserItem> {
@@ -66,10 +67,12 @@ export function getUser(id: number): Promise<UserItem> {
 }
 // 新增或编辑用户
 export function updateUser(data: UserUpdateParam): Promise<boolean> {
-  if (data.id) {
-    return axios.post('/api/admin/user/update', data);
-  }
-  return axios.post('/api/admin/user/save', data);
+  const url = data.id ? '/api/admin/user/update' : '/api/admin/user/save';
+  return axios.post(url, data, {
+    headers: {
+      'Content-Type': 'multipart/form-data',
+    },
+  });
 }
 // 批量新增用户
 export function batchAddUser(datas: BatchCreateUserParam): Promise<boolean> {
@@ -84,16 +87,16 @@ export function deleteUser(id: number): Promise<boolean> {
 }
 // 重置用户密码
 export function resetUserPassword(data: ResetPasswordParam): Promise<boolean> {
-  return axios.post('/api/admin/user/reset', data);
+  return axios.post('/api/admin/user/resetPwd', paramsSerializer(data));
 }
 // 启用禁用用户
 export function enableUser(datas: EnableUserParam): Promise<boolean> {
-  return axios.post(`/api/admin/user/enable`, datas);
+  return axios.post(`/api/admin/user/enable`, paramsSerializer(datas));
 }
 // 导入评卷员班级-导入模板下载
 export function markerClassTemplate(): Promise<AxiosResponse<Blob>> {
   return axios.post(
-    '/api/admin/user/class/import',
+    '/api/admin/user/class/template',
     {},
     {
       responseType: 'blob',
@@ -104,41 +107,26 @@ export function markerClassTemplate(): Promise<AxiosResponse<Blob>> {
 export function exportUser(
   params: Record<string, any>
 ): Promise<AxiosResponse<Blob>> {
-  return axios.post(
-    '/api/admin/user/export',
-    {},
-    {
-      responseType: 'blob',
-      params,
-    }
-  );
+  return axios.post('/api/admin/user/export', params, {
+    responseType: 'blob',
+  });
 }
 
 // 按考试导出用户
 export function exportUserByExam(
   params: Record<string, any>
 ): Promise<AxiosResponse<Blob>> {
-  return axios.post(
-    '/api/admin/user/exportExam',
-    {},
-    {
-      responseType: 'blob',
-      params,
-    }
-  );
+  return axios.post('/api/admin/user/exportExam', params, {
+    responseType: 'blob',
+  });
 }
 // 按考试导出科目分表用户
 export function exportUserBySubject(
   params: Record<string, any>
 ): Promise<AxiosResponse<Blob>> {
-  return axios.post(
-    '/api/admin/user/exportSubject',
-    {},
-    {
-      responseType: 'blob',
-      params,
-    }
-  );
+  return axios.post('/api/admin/user/exportSubject', params, {
+    responseType: 'blob',
+  });
 }
 // 绑定考生导入模板下载
 export function bindStudentTemplate(): Promise<AxiosResponse<Blob>> {
@@ -151,14 +139,12 @@ export function bindStudentTemplate(): Promise<AxiosResponse<Blob>> {
   );
 }
 // 科组长-复核员导入模板下载
-export function headerInspectorTemplate(): Promise<AxiosResponse<Blob>> {
-  return axios.post(
-    '/api/admin/user/subject/template',
-    {},
-    {
-      responseType: 'blob',
-    }
-  );
+export function headerInspectorTemplate(params: {
+  isHeader: boolean;
+}): Promise<AxiosResponse<Blob>> {
+  return axios.post('/api/admin/user/subject/template', params, {
+    responseType: 'blob',
+  });
 }
 
 // 角色权限 ------->

+ 5 - 0
src/assets/style/home.scss

@@ -185,6 +185,11 @@
       font-weight: 500;
       color: var(--color-text-dark);
       font-size: 16px;
+      cursor: pointer;
+
+      &:hover {
+        color: var(--color-primary);
+      }
     }
   }
 

+ 30 - 1
src/assets/style/pages.scss

@@ -258,7 +258,7 @@
     }
   }
 }
-
+// exam-edit
 .exam-edit {
   .form-section {
     margin-bottom: 16px;
@@ -323,3 +323,32 @@
     text-align: center;
   }
 }
+
+// select-subject-dialog
+.select-subject-dialog {
+  .el-dialog {
+    margin-bottom: 0;
+    height: 100%;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+
+    .el-dialog__body {
+      flex: 1;
+      overflow: auto;
+
+      display: flex;
+      flex-direction: column;
+
+      .subject-table {
+        flex: 1;
+        overflow: auto;
+        margin: 0 -20px;
+        padding: 0 20px;
+      }
+    }
+    .el-dialog__footer {
+      display: none;
+    }
+  }
+}

+ 17 - 49
src/components/import-dialog/index.vue

@@ -89,6 +89,11 @@
     name: 'ImportDialog',
   });
 
+  interface UploadResult {
+    success: boolean;
+    message: string;
+  }
+
   /* modal */
   const { visible, open, close } = useModal();
   defineExpose({ open, close });
@@ -125,7 +130,6 @@
       uploadFileAlias: 'file',
       autoUpload: true,
       disabled: false,
-      successMessage: '上传成功',
     }
   );
 
@@ -248,7 +252,10 @@
     emit('uploading');
 
     return axios.post(options.action, formData, {
-      headers: options.headers as AxiosHeaders,
+      headers: {
+        ...(options.headers as AxiosHeaders),
+        'Content-Type': 'multipart/form-data',
+      },
       onUploadProgress: ({ loaded, total }) => {
         onProgress({
           percent: total ? Math.floor((100 * loaded) / total) : 0,
@@ -273,19 +280,25 @@
     }
     emit('uploadError', result.value);
   }
-  function handleSuccess(response: any, uploadFile: UploadFile) {
+  function handleSuccess(response: UploadResult, uploadFile: UploadFile) {
     canUpload.value = false;
     loading.value = false;
+    if (!response.success) {
+      handleError(new Error(response.message), uploadFile);
+      return;
+    }
+
     result.value = {
       success: true,
       message: '上传成功!',
     };
-    ElMessage.success(props.successMessage);
+    ElMessage.success(props.successMessage || response.message);
     emit('uploadSuccess', {
       ...result.value,
       filename: uploadFile.name,
       response,
     });
+    close();
   }
 
   function handleFormatError() {
@@ -367,51 +380,6 @@
         }
       }
     }
-    .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;

+ 14 - 11
src/components/select-exam/index.vue

@@ -34,13 +34,13 @@
       clearable?: boolean;
       disabled?: boolean;
       placeholder?: string;
-      multiple?: boolean;
+      defaultFirst?: boolean;
     }>(),
     {
       clearable: true,
       disabled: false,
       placeholder: '请选择考试',
-      multiple: false,
+      defaultFirst: true,
     }
   );
   const emit = defineEmits(['update:modelValue', 'change']);
@@ -56,20 +56,23 @@
   const search = async () => {
     const res = await examQuery();
     optionList.value = res.map((item) => {
-      return { ...item, value: item.id, label: item.name };
+      return { ...item, value: item.id, label: `${item.id}-${item.name}` };
     });
+
+    if (props.defaultFirst && optionList.value[0]) {
+      selected.value = optionList.value[0].value;
+      emit('update:modelValue', selected.value || undefined);
+      emit('change', optionList.value[0]);
+    }
   };
   search(); // Initial load
 
   const onChange = () => {
-    const selectedData = props.multiple
-      ? optionList.value.filter(
-          (item) =>
-            selected.value && (selected.value as number[]).includes(item.value)
-        )
-      : optionList.value.filter((item) => selected.value === item.value);
-    emit('update:modelValue', selected.value || null);
-    emit('change', props.multiple ? selectedData : selectedData[0]);
+    const selectedData = optionList.value.filter(
+      (item) => selected.value === item.value
+    );
+    emit('update:modelValue', selected.value || undefined);
+    emit('change', selectedData[0]);
   };
 
   watch(

+ 1 - 1
src/components/select-subject/index.vue

@@ -59,7 +59,7 @@
     if (!appStore.curExam?.id) return;
     const res = await subjectQuery(appStore.curExam?.id);
     optionList.value = res.map((item) => {
-      return { ...item, value: item.code, label: item.name };
+      return { ...item, value: item.code, label: `${item.code}-${item.name}` };
     });
   };
   search(); // Initial load

+ 17 - 4
src/layout/default-layout.vue

@@ -5,7 +5,9 @@
         <el-button text @click="toggleCollapse">
           <svg-icon name="icon-menu" />
         </el-button>
-        <span>当前考试:{{ curExamName }}</span>
+        <span v-if="curExamName" @click="toSwitchExam"
+          >当前考试:{{ curExamName }}</span
+        >
       </div>
       <div class="home-tips">
         <svg-icon name="icon-ai" />
@@ -16,9 +18,10 @@
           <template #reference>
             <el-button text>
               <svg-icon name="icon-user" />
-              <!-- <span :title="userStore.name">{{ userStore.name }}</span> -->
               <span :title="userStore.name" style="margin-left: 6px"
-                >姓名(角色)</span
+                >{{ userStore.name }}({{
+                  dictFilter.role(userStore.role)
+                }})</span
               >
             </el-button>
           </template>
@@ -117,6 +120,7 @@
   import { useRoute, useRouter } from 'vue-router';
   import { useAppStore, useUserStore } from '@/store';
   import { modalConfirm } from '@/utils/ui';
+  import { dictFilter } from '@/utils/filter';
 
   import ResetPwdDialog from '@/views/login/components/ResetPwdDialog.vue';
   import UserInfoDialog from '@/views/login/components/UserInfoDialog.vue';
@@ -136,7 +140,7 @@
   const isCollapse = ref(false);
 
   const curExamName = computed(() => {
-    return appStore.curExam?.name ?? '';
+    return appStore.displayExamName;
   });
 
   function getRouteName() {
@@ -156,6 +160,15 @@
     isCollapse.value = !isCollapse.value;
   }
 
+  function toSwitchExam() {
+    router.push({
+      name: 'SwitchExam',
+      query: {
+        backUrl: route.path,
+      },
+    });
+  }
+
   function handleSubMenuOpen(val: string) {
     if (appStore.checkCurRouteInSubMenu(val, getRouteName())) {
       return;

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

@@ -43,6 +43,7 @@ const useAppStore = defineStore('app', {
       versionDate: '',
       fileServer: '',
       indexLogo: '',
+      authType: 'offline',
     },
     appMenus: [],
     validRoutes: [],
@@ -58,6 +59,9 @@ const useAppStore = defineStore('app', {
     isMultiExam() {
       return this.curExam && this.curExam.type === 'MULTI_MEDIA';
     },
+    displayExamName() {
+      return this.curExam?.id ? `${this.curExam.id}-${this.curExam.name}` : '';
+    },
   },
 
   actions: {

+ 1 - 1
src/utils/download-export.ts

@@ -179,7 +179,7 @@ export async function downloadExport(
   setLoading(true);
   const res = await downloadByApi(() => downloadConfig[type](params)).catch(
     (e) => {
-      ElMessage.error(e || '下载失败,请重新尝试!');
+      ElMessage.error(e.message || '下载失败,请重新尝试!');
     }
   );
   setLoading(false);

+ 3 - 0
src/utils/download.ts

@@ -64,6 +64,9 @@ export async function downloadByApi<T extends ApiFunc>(
   }
 
   const result = res as AxiosResponse<Blob>;
+  if (result.data.size === 0) {
+    return Promise.reject(new Error('文件为空,下载失败!'));
+  }
   const filename =
     fileName || parseDownloadFilename(result.headers['content-disposition']);
   downloadByBlob(new Blob([result.data]), filename);

+ 7 - 0
src/utils/utils.ts

@@ -1,3 +1,5 @@
+import qs from 'qs';
+
 /**
  * 判断对象类型
  * @param {*} obj 对象
@@ -8,6 +10,7 @@ export function objTypeOf(obj: any): string {
   if (obj instanceof Date) return 'date';
   if (obj instanceof RegExp) return 'regExp';
   if (obj instanceof Blob) return 'blob';
+  if (obj instanceof FormData) return 'formData';
   if (typeof obj === 'object') return 'object';
   return typeof obj;
 }
@@ -375,3 +378,7 @@ export function deepCopy<T>(data: T): T {
 export function isEmpty(val: any) {
   return val === undefined || val === null || val === '';
 }
+
+export const paramsSerializer = (data: Record<string, any>) => {
+  return qs.stringify(data, { arrayFormat: 'repeat' });
+};

+ 10 - 4
src/views/login/SwitchExam.vue

@@ -39,7 +39,7 @@
 
 <script lang="ts" setup>
   import { ref, reactive } from 'vue';
-  import { useRouter } from 'vue-router';
+  import { useRoute, useRouter } from 'vue-router';
   import type { FormInstance, FormRules } from 'element-plus';
   import { useAppStore } from '@/store';
   import { ExamItem } from '@/api/types/exam';
@@ -49,6 +49,8 @@
 
   const appStore = useAppStore();
   const router = useRouter();
+  const route = useRoute();
+
   const formRef = ref<FormInstance>();
   const formData = reactive({
     examId: null,
@@ -83,9 +85,13 @@
       await switchExam(formData.examId);
       appStore.setInfo({ curExam: selectExam.value });
 
-      router.push({
-        name: DEFAULT_ROUTE_NAME,
-      });
+      if (route.query.backUrl) {
+        router.push(route.query.backUrl as string);
+      } else {
+        router.push({
+          name: DEFAULT_ROUTE_NAME,
+        });
+      }
     } catch (error) {
       console.error(error);
     } finally {

+ 0 - 29
src/views/system/comp-test/comp-test.vue

@@ -101,21 +101,6 @@
       >
     </section>
 
-    <section class="component-section">
-      <h2 class="section-title">SelectTeaching Component</h2>
-      <SelectTeaching
-        v-model="selectedTeaching"
-        placeholder="Select a teaching point"
-        clearable
-        prefix
-        @change="handleTeachingChange"
-      />
-      <p class="section-description"
-        >Selected Teaching ID: {{ selectedTeaching }}. (Note: Options are mocked
-        or need API integration)</p
-      >
-    </section>
-
     <section class="component-section">
       <h2 class="section-title">SelectImgArea Component</h2>
       <el-button @click="openSelector">选择图片区域</el-button>
@@ -148,7 +133,6 @@
   // SelectRangeDatetime is globally registered
   // SelectRangeTime is globally registered
   // import SelectExam from '@/components/select-exam/index.vue';
-  import SelectTeaching from '@/components/select-teaching/index.vue';
   import SelectImgArea from '@/components/select-img-area/index.vue';
 
   import Footer from '@/components/footer/index.vue';
@@ -218,19 +202,6 @@
   //   }
   // };
 
-  // For SelectTeaching
-  const selectedTeaching = ref<number | null>(null);
-  const handleTeachingChange = (teaching: any) => {
-    console.log('Teaching changed:', teaching);
-    if (teaching) {
-      ElMessage.info(
-        `Teaching point selected: ${teaching.label} (ID: ${teaching.value})`
-      );
-    } else {
-      ElMessage.info('Teaching point cleared');
-    }
-  };
-
   // For SelectImgArea
   const imgAreaRef = ref();
   const selectedAreas = ref<CoverArea[]>([]);

+ 36 - 24
src/views/user/ModifyUser.vue

@@ -14,14 +14,10 @@
       ref="formRef"
       :model="formModel"
       :rules="rules"
-      label-width="100px"
+      label-width="120px"
     >
       <el-form-item label="登录名" prop="loginName">
-        <el-input
-          v-model="formModel.loginName"
-          placeholder="请输入登录名"
-          :disabled="isEdit"
-        />
+        <el-input v-model="formModel.loginName" placeholder="请输入登录名" />
       </el-form-item>
       <el-form-item label="名称" prop="name">
         <el-input v-model="formModel.name" placeholder="请输入名称" />
@@ -57,7 +53,11 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item v-if="showSubjectCode" label="绑定科目代码">
+      <el-form-item
+        v-if="showSubjectCode"
+        label="绑定科目代码"
+        prop="subjectCodeString"
+      >
         <el-input
           v-model="formModel.subjectCodeString"
           placeholder="请输入"
@@ -151,7 +151,7 @@
 
   function getInitialFormState(): UserUpdateParam {
     return {
-      id: 0,
+      id: undefined,
       loginName: '',
       name: '',
       empno: '',
@@ -192,22 +192,27 @@
     }
   };
 
-  const rules: FormRules<keyof UserUpdateParam> = {
-    loginName: [
-      { required: true, message: '请输入登录名', trigger: 'change' },
-      { max: 50, message: '登录名最多50字符', trigger: 'change' },
-    ],
-    name: [
-      { required: true, message: '请输入名称', trigger: 'change' },
-      { max: 50, message: '名称最多50字符', trigger: 'change' },
-    ],
-    employeeId: [{ max: 50, message: '工号最多50字符', trigger: 'change' }],
-    password: [
-      { required: !isEdit.value, validator: validatePass, trigger: 'change' },
-    ],
-    enable: [{ required: true, message: '请选择状态', trigger: 'change' }],
-    role: [{ required: true, message: '请选择角色', trigger: 'change' }],
-  };
+  const rules = computed<FormRules<keyof UserUpdateParam>>(() => {
+    return {
+      loginName: [
+        { required: true, message: '请输入登录名', trigger: 'change' },
+        { max: 50, message: '登录名最多50字符', trigger: 'change' },
+      ],
+      name: [
+        { required: true, message: '请输入名称', trigger: 'change' },
+        { max: 50, message: '名称最多50字符', trigger: 'change' },
+      ],
+      employeeId: [{ max: 50, message: '工号最多50字符', trigger: 'change' }],
+      password: [
+        { required: !isEdit.value, validator: validatePass, trigger: 'change' },
+      ],
+      subjectCodeString: [
+        { required: true, message: '请输入科目代码', trigger: 'change' },
+      ],
+      enable: [{ required: true, message: '请选择状态', trigger: 'change' }],
+      role: [{ required: true, message: '请选择角色', trigger: 'change' }],
+    };
+  });
 
   function handleFileReady(result: {
     file: File;
@@ -234,6 +239,12 @@
       const valid = await formRef.value?.validate().catch(() => false);
       if (!valid) return;
 
+      if (formModel.role === 'SCHOOL_VIEWER') {
+        if (!formModel.examIdString && !formModel.colleges) {
+          ElMessage.error('请输入绑定考试ID或绑定学院');
+          return;
+        }
+      }
       setLoading(true);
       const datas = objAssign(formModel, {});
       let res = true;
@@ -256,6 +267,7 @@
     if (props.rowData?.id) {
       // 编辑时,填充表单
       objModifyAssign(formModel, props.rowData);
+      formModel.password = '';
     } else {
       // 新增时,重置为初始状态(确保密码字段为空)
       objModifyAssign(formModel, getInitialFormState());

+ 7 - 5
src/views/user/UserManage.vue

@@ -157,7 +157,7 @@
   <ImportDialog
     ref="importUserDialogRef"
     :title="importData.isHeader ? '导入科组长' : '导入复核员'"
-    upload-url="/api/admin/site/import"
+    upload-url="/api/admin/user/subject/import"
     :upload-data="importData"
     :format="['xls', 'xlsx']"
     :download-handle="
@@ -167,6 +167,7 @@
         })
     "
     download-filename="导入模板.xlsx"
+    @upload-success="getList"
   />
   <!-- 导入班级评卷员 -->
   <ImportDialog
@@ -176,6 +177,7 @@
     :format="['xls', 'xlsx']"
     :download-handle="() => downloadExport('markerClassTemplate')"
     download-filename="导入班级评卷员模板.xlsx"
+    @upload-success="getList"
   />
 
   <!-- 批量新增用户弹窗 -->
@@ -297,16 +299,16 @@
   const onExportCommand = async (
     command: 'exportUser' | 'exportUserByExam' | 'exportUserBySubject'
   ) => {
-    await downloadExport(command);
+    await downloadExport(command, searchModel);
   };
 
   // 批量启用、禁用
-  const onBatchEnable = async (enabled: boolean) => {
+  const onBatchEnable = async (enable: boolean) => {
     if (!canBatchAction.value) {
       ElMessage.error('请至少选择一个用户');
       return;
     }
-    const action = enabled ? '启用' : '禁用';
+    const action = enable ? '启用' : '禁用';
     const confirm = await modalConfirm(
       `确定要${action}选中的 ${selectedRows.value.length} 个用户吗?`,
       '提示'
@@ -317,7 +319,7 @@
       // 调用重置密码接口,不传递新密码,由后端处理
       await enableUser({
         ids: selectedRows.value.map((user) => user.id),
-        enabled,
+        enable,
       });
       ElMessage.success('操作成功!');
       getList();

+ 33 - 6
src/views/user/components/BatchCreateUserDialog.vue

@@ -17,7 +17,7 @@
       label-width="120px"
     >
       <el-form-item label="考试名称">
-        <el-input :value="curExamName" disabled />
+        {{ curExamName }}
       </el-form-item>
       <el-form-item label="角色" prop="role">
         <el-select v-model="formModel.role" placeholder="请选择角色">
@@ -30,9 +30,14 @@
         </el-select>
       </el-form-item>
       <el-form-item label="命名规则" prop="prefix">
-        <el-input v-model="formModel.prefix" placeholder="自定义前缀">
-          <template #append>+科目代码+流水号</template>
+        <el-input
+          v-if="appStore.system.authType === 'offline'"
+          v-model="formModel.prefix"
+          placeholder="自定义前缀"
+          style="width: 120px; margin-right: 5px"
+        >
         </el-input>
+        <span> {{ prefixLabel }} </span>
       </el-form-item>
       <el-form-item label="每分组账号数" prop="number">
         <el-input-number
@@ -58,7 +63,7 @@
         />
       </el-form-item>
       <el-form-item label="选择科目" prop="subjectCodes">
-        <el-button type="primary" @click="openSelectSubjectDialog"
+        <el-button type="primary" link @click="openSelectSubjectDialog"
           >设置</el-button
         >
         <div v-if="formModel.subjectCodes.length > 0" style="margin-left: 10px">
@@ -109,12 +114,18 @@
   const emit = defineEmits(['modified']);
 
   const appStore = useAppStore();
-  const curExamName = computed(() => appStore.curExam?.name || '');
+  const curExamName = computed(() => appStore.displayExamName);
 
   const formRef = ref<FormInstance>();
-
   const rules: FormRules<keyof BatchCreateUserParam> = {
     role: [{ required: true, message: '请选择角色', trigger: 'change' }],
+    prefix: [
+      {
+        required: appStore.system.authType === 'offline',
+        message: '请输入命名规则',
+        trigger: 'change',
+      },
+    ],
     number: [
       { required: true, message: '请输入每分组账号数', trigger: 'change' },
     ],
@@ -156,6 +167,22 @@
     typeof SelectSubjectDialog
   > | null>(null);
 
+  const prefixLabels = {
+    online: {
+      SUBJECT_HEADER: '机构ID+科目代码+流水号',
+      INSPECTOR: '机构ID+FH+科目代码+流水号',
+      MARKER: '机构ID+科目代码+分组号+流水号',
+    },
+    offline: {
+      SUBJECT_HEADER: '+科目代码+流水号',
+      INSPECTOR: '+FH+科目代码+流水号',
+      MARKER: '+科目代码+分组号+流水号',
+    },
+  };
+  const prefixLabel = computed(() => {
+    return prefixLabels[appStore.system.authType][formModel.role];
+  });
+
   function randomPasswordChange() {
     if (formModel.random) {
       formModel.password = '';

+ 39 - 28
src/views/user/components/SelectSubjectDialog.vue

@@ -5,8 +5,9 @@
     width="1000px"
     :close-on-click-modal="false"
     :close-on-press-escape="false"
-    top="10px"
+    top="0"
     append-to-body
+    modal-class="select-subject-dialog"
     @close="handleClose"
     @open="modalBeforeOpen"
   >
@@ -17,48 +18,56 @@
           placeholder="请选择科目"
           clearable
           filterable
+          default-first-option
         >
           <el-option
             v-for="item in subjectList"
             :key="item.code"
-            :label="item.name"
+            :label="`${item.code}-${item.name}`"
             :value="item.code"
           />
         </el-select>
       </el-form-item>
       <el-form-item>
         <el-button type="primary" @click="search">查询</el-button>
-      </el-form-item>
-    </el-form>
-
-    <el-table
-      ref="tableRef"
-      :data="dataList"
-      row-key="code"
-      :loading="loading"
-      border
-      stripe
-      @selection-change="handleSelectionChange"
-    >
-      <el-table-column type="selection" width="55" :reserve-selection="true" />
-      <el-table-column prop="name" label="科目名称" min-width="200" />
-      <el-table-column prop="objectiveScore" label="客观总分" />
-      <el-table-column prop="subjectiveScore" label="主观总分" />
-      <el-table-column prop="totalScore" label="试卷总分" />
-      <el-table-column prop="status" label="状态"> 正常 </el-table-column>
-    </el-table>
-
-    <template #footer>
-      <span class="dialog-footer">
-        <el-button @click="handleClose">取消</el-button>
         <el-button
           type="primary"
           :loading="loading"
           @click="handleConfirmSelection"
           >确定</el-button
         >
-      </span>
-    </template>
+        <el-button @click="close">取消</el-button>
+      </el-form-item>
+    </el-form>
+
+    <div class="subject-table">
+      <el-table
+        ref="tableRef"
+        :data="dataList"
+        row-key="code"
+        :loading="loading"
+        border
+        stripe
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column
+          type="selection"
+          width="55"
+          :reserve-selection="true"
+        />
+        <el-table-column prop="name" label="科目名称" min-width="200">
+          <template #default="scope">
+            {{ scope.row.code }}-{{ scope.row.name }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="objectiveScore" label="客观总分" />
+        <el-table-column prop="subjectiveScore" label="主观总分" />
+        <el-table-column prop="totalScore" label="试卷总分" />
+        <el-table-column prop="status" label="状态"> 正常 </el-table-column>
+      </el-table>
+    </div>
+
+    <template #footer> </template>
   </el-dialog>
 </template>
 
@@ -135,6 +144,7 @@
     }
 
     await nextTick();
+    tableRef.value?.clearSelection();
     tableRef.value?.toggleAllSelection();
   };
 
@@ -155,7 +165,8 @@
   };
 
   const handleClose = () => {
-    searchModel.name = '';
+    tableRef.value?.clearSelection();
+    searchModel.subjectCode = '';
     selectedSubjects.value = [];
   };