Browse Source

feat: 用户管理

zhangjie 1 week ago
parent
commit
94ec08a41b

+ 9 - 0
components.d.ts

@@ -12,15 +12,20 @@ declare module '@vue/runtime-core' {
     ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'];
     ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'];
     ElButton: typeof import('element-plus/es')['ElButton'];
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox'];
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'];
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker'];
     ElDescriptions: typeof import('element-plus/es')['ElDescriptions'];
     ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'];
     ElDialog: typeof import('element-plus/es')['ElDialog'];
+    ElDropdown: typeof import('element-plus/es')['ElDropdown'];
+    ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'];
+    ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'];
     ElForm: typeof import('element-plus/es')['ElForm'];
     ElFormItem: typeof import('element-plus/es')['ElFormItem'];
     ElIcon: typeof import('element-plus/es')['ElIcon'];
     ElInput: typeof import('element-plus/es')['ElInput'];
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber'];
     ElMenu: typeof import('element-plus/es')['ElMenu'];
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem'];
     ElOption: typeof import('element-plus/es')['ElOption'];
@@ -34,6 +39,7 @@ declare module '@vue/runtime-core' {
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn'];
     ElTabPane: typeof import('element-plus/es')['ElTabPane'];
     ElTabs: typeof import('element-plus/es')['ElTabs'];
+    ElTag: typeof import('element-plus/es')['ElTag'];
     ElTooltip: typeof import('element-plus/es')['ElTooltip'];
     ElUpload: typeof import('element-plus/es')['ElUpload'];
     FileUpload: typeof import('./src/components/file-upload/index.vue')['default'];
@@ -48,4 +54,7 @@ declare module '@vue/runtime-core' {
     SvgIcon: typeof import('./src/components/svg-icon/index.vue')['default'];
     UploadButton: typeof import('./src/components/upload-button/index.vue')['default'];
   }
+  export interface ComponentCustomProperties {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective'];
+  }
 }

+ 10 - 2
src/api/base.ts

@@ -1,8 +1,16 @@
 import axios from 'axios';
-import type { OptionItem } from './types/base';
+import type { ExamItem, SubjectItem } from './types/base';
 
 // 通用查询
 // 通用查询-考试
-export function examQuery(): Promise<OptionItem[]> {
+export function examQuery(): Promise<ExamItem[]> {
   return axios.post('/api/admin/apply/exam/list', {});
 }
+// 通用查询-科目
+export function subjectQuery(examId: number): Promise<SubjectItem[]> {
+  return axios.post(
+    '/api/admin/apply/subject/list',
+    {},
+    { params: { examId } }
+  );
+}

+ 11 - 0
src/api/types/base.ts

@@ -94,3 +94,14 @@ export interface RoomUpdateParams {
   teachingId: number | null;
   examSiteId: number | null;
 }
+
+export interface ExamItem {
+  id: number;
+  name: string;
+  code: string;
+}
+export interface SubjectItem {
+  id: number;
+  name: string;
+  code: string;
+}

+ 49 - 0
src/api/types/user.ts

@@ -3,8 +3,57 @@ export interface LoginData {
   password: string;
 }
 
+export interface UserItem {
+  id: number;
+  loginName: string; // 登录名
+  name: string; // 名称
+  employeeId?: string; // 工号 (可选)
+  source: string; // 来源 (例如:内部用户)
+  role: string; // 角色 (例如:学校管理员, 扫描员)
+  status: 'enabled' | 'disabled'; // 状态 (启用/禁用)
+  wechatLinked?: boolean; // 关联微信 (可选, 可能为显示字段)
+  // 根据实际需要添加更多字段
+}
+
+export interface UserListFilter {
+  name?: string; // 按名称搜索
+  loginName?: string; // 按登录名搜索
+  status?: 'enabled' | 'disabled'; // 按状态筛选
+  role?: string; // 按角色筛选
+  // 根据实际需要添加更多筛选字段
+}
+
+export interface UserUpdateParam {
+  id?: number;
+  loginName: string;
+  name: string;
+  employeeId: string;
+  status: 'enabled' | 'disabled';
+  role: string;
+  password: string;
+}
+
+export interface ResetPasswordParam {
+  ids: number[];
+  password?: string; // 如果不传,后端可能会生成随机密码
+}
+export interface EnableUserParam {
+  ids: number[];
+  enabled: boolean;
+}
+
 export interface UpdatePwdData {
   // id: number;
   // oldPassword: string;
   password: string;
 }
+
+export interface BatchCreateUserParam {
+  examId: number;
+  role: string;
+  namingRule: string;
+  accountsPerGroup: number;
+  randomPassword: boolean;
+  password?: string;
+  subjectIds: number[];
+}

+ 39 - 1
src/api/user.ts

@@ -1,6 +1,16 @@
 import axios from 'axios';
 import { UserState } from '@/store/modules/user/types';
-import type { LoginData, UpdatePwdData } from './types/user';
+import type {
+  LoginData,
+  UpdatePwdData,
+  UserListFilter,
+  UserItem,
+  UserUpdateParam,
+  ResetPasswordParam,
+  EnableUserParam,
+  BatchCreateUserParam,
+} from './types/user';
+import type { ListResult } from './types/base';
 
 // 登录
 export function login(data: LoginData): Promise<UserState> {
@@ -14,3 +24,31 @@ export function updatePwd(datas: UpdatePwdData): Promise<UserState> {
 export function userLogout() {
   return axios.post('/api/user/logout', {});
 }
+
+// 用户管理
+// 获取用户列表 (分页)
+export function userListPage(
+  params: UserListFilter & { pageNumber: number; pageSize: number }
+): Promise<ListResult<UserItem>> {
+  return axios.get('/api/user/list', { params });
+}
+// 新增或编辑用户
+export function updateUser(data: UserUpdateParam): Promise<any> {
+  return axios.post('/api/user/update', data);
+}
+// 批量新增用户
+export function batchAddUser(datas: BatchCreateUserParam[]): Promise<any> {
+  return axios.post('/api/user/addList', datas);
+}
+// 删除用户
+export function deleteUser(id: number): Promise<any> {
+  return axios.post(`/api/user/delete/${id}`);
+}
+// 重置用户密码
+export function resetUserPassword(data: ResetPasswordParam): Promise<any> {
+  return axios.post('/api/user/resetPassword', data);
+}
+// 启用禁用用户
+export function enableUser(datas: EnableUserParam): Promise<any> {
+  return axios.post(`/api/user/enable`, datas);
+}

+ 0 - 3
src/assets/style/base.less

@@ -37,9 +37,6 @@
 }
 
 .filter-line {
-  .arco-select-view-value {
-    // display: block;
-  }
   .arco-input-wrapper,
   .arco-select {
     width: 200px;

+ 9 - 33
src/assets/style/home.less

@@ -17,55 +17,31 @@
   overflow: auto;
   font-size: 14px;
   background: var(--color-white);
-  .arco-menu-home {
+  .el-menu-home {
     padding: 16px 8px;
 
-    .arco-menu-inner {
-      padding: 0;
-    }
-
-    .arco-menu-inline {
-      margin-bottom: 20px;
-    }
     .svg-icon {
       margin-top: -3px;
     }
 
-    .arco-menu-inline-header {
-      padding: 9px 40px !important;
-      min-height: 38px;
-      line-height: 20px;
-      font-weight: 300;
-
-      .arco-menu-icon {
-        position: absolute;
-        left: 16px;
-        font-size: 16px;
-      }
-
-      &.arco-menu-selected {
-        &:hover {
-          background-color: var(--color-primary-light);
-        }
-      }
-    }
-
-    .arco-menu-item {
+    .el-menu-item {
       height: auto;
       min-height: 38px;
       line-height: 20px;
-      padding: 9px 40px !important;
       white-space: normal;
-      .arco-menu-indent-list {
-        display: none;
-      }
 
-      &.arco-menu-selected {
+      &.is-active {
         font-weight: 500;
         color: var(--color-primary);
         background-color: var(--color-primary-light);
       }
     }
+    .el-sub-menu__title {
+      height: auto;
+      min-height: 38px;
+      line-height: 20px;
+      white-space: normal;
+    }
   }
 }
 

+ 7 - 1
src/hooks/table.ts

@@ -7,6 +7,7 @@ export default function useTable<T extends Record<string, any>>(
   initAutoFetch = false
 ) {
   const dataList = ref<T[]>([]);
+  const loading = ref(false);
   const pagination = reactive({
     pageNumber: 1,
     pageSize: 20,
@@ -15,12 +16,16 @@ export default function useTable<T extends Record<string, any>>(
   });
 
   async function getList() {
+    loading.value = true;
     const datas = {
       ...(isRef(searchModel || {}) ? searchModel.value : searchModel),
       pageNumber: pagination.pageNumber,
       pageSize: pagination.pageSize,
     };
-    const data = await apiFunc(datas);
+    const data = await apiFunc(datas).catch(() => {});
+    loading.value = false;
+    if (!data) return;
+
     dataList.value = data.result;
     pagination.total = data.totalCount;
   }
@@ -49,6 +54,7 @@ export default function useTable<T extends Record<string, any>>(
   }
 
   return {
+    loading,
     dataList,
     pagination,
     getRowIndex,

+ 24 - 0
src/router/routes/modules/base.ts

@@ -0,0 +1,24 @@
+import { DEFAULT_LAYOUT } from '../base';
+import { AppRouteRecordRaw } from '../types';
+
+const BASE: AppRouteRecordRaw = {
+  path: '/base',
+  name: 'base',
+  component: DEFAULT_LAYOUT,
+  meta: {
+    requiresAuth: true,
+  },
+  children: [
+    {
+      path: '/user-manage',
+      name: 'UserManage',
+      component: () => import('@/views/user/UserManage.vue'),
+      meta: {
+        title: '用户管理',
+        requiresAuth: true,
+      },
+    },
+  ],
+};
+
+export default BASE;

+ 3 - 2
src/store/modules/app/index.ts

@@ -1,6 +1,7 @@
 import { defineStore } from 'pinia';
 import { omit } from 'lodash';
-import { menus } from './menuData';
+// import { sysAdminMenus as menus } from './menuData';
+import { adminMenus as menus } from './menuData';
 
 import { AppState, AppMenuItem, UserMenuItem } from './types';
 import { RoleType } from '../../../constants/enumerate';
@@ -48,7 +49,7 @@ const useAppStore = defineStore('app', {
     validRoutes: [],
     breadcrumbs: [],
     device: 'desktop',
-    examId: 0,
+    curExam: {} as AppState['curExam'],
   }),
 
   getters: {

+ 220 - 1
src/store/modules/app/menuData.ts

@@ -1,4 +1,4 @@
-export const menus = [
+export const sysAdminMenus = [
   {
     id: 1,
     name: '学校管理',
@@ -28,3 +28,222 @@ export const menus = [
     enable: true,
   },
 ];
+
+export const adminMenus = [
+  {
+    id: 1,
+    name: '主页导览',
+    url: 'HomeGuide',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 1,
+    enable: true,
+  },
+  {
+    id: 2,
+    name: '用户管理',
+    url: 'UserManage',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 3,
+    name: '考试管理',
+    url: 'ExamManage',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 4,
+    name: '考生管理',
+    url: 'StudentManage',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 5,
+    name: '扫描进度',
+    url: 'ScanProgress',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 6,
+    name: '科目管理',
+    url: 'SubjectManage',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 7,
+    name: '评卷管理',
+    url: 'mark',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 8,
+    name: '评卷进度',
+    url: 'MarkProgress',
+    type: 'MENU',
+    parentId: 7,
+    sequence: 1,
+    enable: true,
+  },
+  {
+    id: 9,
+    name: '科目评卷管理',
+    url: 'SubjectMarkManage',
+    type: 'MENU',
+    parentId: 7,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 10,
+    name: '打回管理',
+    url: 'back',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 11,
+    name: '打回卷',
+    url: 'BackManage',
+    type: 'MENU',
+    parentId: 10,
+    sequence: 1,
+    enable: true,
+  },
+  {
+    id: 12,
+    name: '打回统计',
+    url: 'BackStatistics',
+    type: 'MENU',
+    parentId: 10,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 13,
+    name: '问题卷管理',
+    url: 'IssuePaperManage',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 14,
+    name: '成绩复核',
+    url: 'ScoreReview',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 15,
+    name: '复核进度统计',
+    url: 'ScoreReviewStatistics',
+    type: 'MENU',
+    parentId: 14,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 16,
+    name: '全卷复核',
+    url: 'AllReview',
+    type: 'MENU',
+    parentId: 14,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 17,
+    name: '成绩校验',
+    url: 'ScoreCheck',
+    type: 'MENU',
+    parentId: 14,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 18,
+    name: '成绩查询',
+    url: 'ScoreQuery',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 19,
+    name: '科目分析',
+    url: 'SubjectAnalysis',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 20,
+    name: '数据检查',
+    url: 'DataCheck',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 21,
+    name: '人工确认',
+    url: 'ManualConfirm',
+    type: 'MENU',
+    parentId: 20,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 22,
+    name: '识别结果检查',
+    url: 'ResultCheck',
+    type: 'MENU',
+    parentId: 20,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 23,
+    name: '图片检查',
+    url: 'ImageCheck',
+    type: 'MENU',
+    parentId: 20,
+    sequence: 2,
+    enable: true,
+  },
+  {
+    id: 24,
+    name: '操作日志',
+    url: 'OperationLog',
+    type: 'MENU',
+    parentId: -1,
+    sequence: 2,
+    enable: true,
+  },
+];

+ 3 - 1
src/store/modules/app/types.ts

@@ -1,3 +1,5 @@
+import { ExamItem } from '@/api/types/base';
+
 export interface UserMenuItem {
   id: number;
   name: string;
@@ -25,5 +27,5 @@ export interface AppState {
   validRoutes: string[];
   breadcrumbs: string[];
   device: string;
-  examId: number;
+  curExam: ExamItem;
 }

+ 6 - 4
src/views/admin/school-manage/SchoolManage.vue

@@ -31,14 +31,16 @@
       <el-table-column property="city" label="城市" />
       <el-table-column label="操作">
         <template #default="scope">
-          <el-button size="small" @click="onEdit(scope.row)"> 修改 </el-button>
-          <el-button size="small" @click="onEditAdmin(scope.row)">
+          <el-button size="small" link @click="onEdit(scope.row)">
+            修改
+          </el-button>
+          <el-button size="small" link @click="onEditAdmin(scope.row)">
             编辑管理员
           </el-button>
-          <el-button size="small" @click="onEditRoleAuth(scope.row)">
+          <el-button size="small" link @click="onEditRoleAuth(scope.row)">
             角色权限
           </el-button>
-          <el-button size="small" @click="onSplitCourse(scope.row)">
+          <el-button size="small" link @click="onSplitCourse(scope.row)">
             科目拆分
           </el-button>
         </template>

+ 16 - 1
src/views/login/login/SwitchExam.vue

@@ -11,7 +11,11 @@
       label-position="top"
     >
       <el-form-item label="" prop="exam">
-        <select-exam v-model="formData.examId" size="large" />
+        <select-exam
+          v-model="formData.examId"
+          size="large"
+          @change="onExamChange"
+        />
       </el-form-item>
 
       <el-form-item>
@@ -26,7 +30,10 @@
   import { ref, reactive } from 'vue';
   import { useRouter } from 'vue-router';
   import type { FormInstance, FormRules } from 'element-plus';
+  import { ExamItem } from '@/api/types/base';
+  import { useAppStore } from '@/store';
 
+  const appStore = useAppStore();
   const router = useRouter();
   const formRef = ref<FormInstance>();
   const formData = reactive({
@@ -42,11 +49,19 @@
     ],
   };
 
+  const selectExam = ref({} as ExamItem);
+
+  const onExamChange = (val: ExamItem) => {
+    console.log('选择的考试ID:', val);
+    selectExam.value = val || ({} as ExamItem);
+  };
+
   // 确认按钮点击事件
   const handleSubmit = async () => {
     const valid = await formRef.value?.validate().catch(() => {});
     if (!valid) return;
     console.log('选择的考试ID:', formData.examId);
+    appStore.setInfo({ curExam: selectExam.value });
     // 提交成功后的操作,例如跳转到其他页面
   };
 

+ 175 - 0
src/views/user/ModifyUser.vue

@@ -0,0 +1,175 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="title"
+    width="500px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    top="10vh"
+    append-to-body
+    @close="handleClose"
+    @open="modalBeforeOpen"
+  >
+    <el-form
+      ref="formRef"
+      :model="formModel"
+      :rules="rules"
+      label-width="100px"
+    >
+      <el-form-item label="登录名" prop="loginName">
+        <el-input
+          v-model="formModel.loginName"
+          placeholder="请输入登录名"
+          :disabled="isEdit"
+        />
+      </el-form-item>
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="formModel.name" placeholder="请输入名称" />
+      </el-form-item>
+      <el-form-item label="工号" prop="employeeId">
+        <el-input v-model="formModel.employeeId" placeholder="请输入工号" />
+      </el-form-item>
+      <el-form-item label="密码" prop="password">
+        <el-input
+          v-model="formModel.password"
+          type="password"
+          show-password
+          placeholder="请输入密码"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="formModel.status" placeholder="请选择状态">
+          <el-option label="启用" value="enabled" />
+          <el-option label="禁用" value="disabled" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="角色" prop="role">
+        <el-select v-model="formModel.role" placeholder="请选择角色">
+          <!-- 角色选项应从后端获取或在常量中定义 -->
+          <el-option label="学校管理员" value="school_admin" />
+          <el-option label="扫描员" value="scanner" />
+          <el-option label="普通用户" value="user" />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" :loading="loading" @click="confirm"
+          >确定</el-button
+        >
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, computed } from 'vue';
+  import type { FormInstance, FormRules } from 'element-plus';
+  import { ElMessage } from 'element-plus';
+  import type { UserItem, UserUpdateParam } from '@/api/types/user';
+  import { updateUser } from '@/api/user';
+  import useModal from '@/hooks/modal';
+  import useLoading from '@/hooks/loading';
+  import { objAssign, objModifyAssign } from '@/utils/utils';
+
+  defineOptions({
+    name: 'ModifyUser',
+  });
+
+  /* modal */
+  const { visible, open, close } = useModal();
+  defineExpose({ open, close });
+
+  interface Props {
+    rowData?: UserItem; // 编辑时传入,新增时为空
+  }
+
+  const props = defineProps<Props>();
+  const emit = defineEmits(['modified']);
+
+  const isEdit = computed(() => !!props.rowData?.id);
+  const title = computed(() => (isEdit.value ? '编辑用户' : '新增用户'));
+
+  const formRef = ref<FormInstance>();
+
+  const initialFormState: UserUpdateParam = {
+    id: 0,
+    loginName: '',
+    name: '',
+    employeeId: '',
+    password: '',
+    status: 'enabled',
+    role: 'user', // 默认角色
+  };
+
+  const formModel = reactive<UserUpdateParam>({ ...initialFormState });
+
+  const validatePass = (rule: any, value: any, callback: any) => {
+    if (!isEdit.value && value === '') {
+      callback(new Error('请输入密码'));
+    } else if (!isEdit.value && value.length < 6) {
+      callback(new Error('密码长度至少为6位'));
+    } else {
+      callback();
+    }
+  };
+
+  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' },
+    ],
+    status: [{ required: true, message: '请选择状态', trigger: 'change' }],
+    role: [{ required: true, message: '请选择角色', trigger: 'change' }],
+  };
+
+  const handleClose = () => {
+    formRef.value?.resetFields();
+    Object.assign(formModel, initialFormState);
+  };
+
+  /* confirm */
+  const { loading, setLoading } = useLoading();
+  async function confirm() {
+    if (!formRef.value) return;
+    try {
+      const valid = await formRef.value?.validate().catch(() => false);
+      if (!valid) return;
+
+      setLoading(true);
+      const datas = objAssign(formModel, {});
+      let res = true;
+      await updateUser(datas).catch(() => {
+        res = false;
+      });
+      setLoading(false);
+      if (!res) return;
+      ElMessage.success(`${isEdit.value ? '修改' : '新增'}成功!`);
+      emit('modified');
+      close();
+    } catch (error) {
+      console.error('表单校验失败或API请求错误:', error);
+      setLoading(false);
+    }
+  }
+
+  /* init modal */
+  function modalBeforeOpen() {
+    if (props.rowData?.id) {
+      // 编辑时,填充表单
+      objModifyAssign(formModel, props.rowData);
+    } else {
+      // 新增时,重置为初始状态(确保密码字段为空)
+      objModifyAssign(formModel, initialFormState);
+    }
+  }
+</script>

+ 360 - 0
src/views/user/UserManage.vue

@@ -0,0 +1,360 @@
+<template>
+  <div class="part-box is-filter">
+    <el-form :model="searchModel" inline @submit.prevent="toPage(1)">
+      <el-form-item label="登录名">
+        <el-input
+          v-model.trim="searchModel.loginName"
+          placeholder="请输入登录名"
+          clearable
+        />
+      </el-form-item>
+      <el-form-item label="名称">
+        <el-input
+          v-model.trim="searchModel.name"
+          placeholder="请输入名称"
+          clearable
+        />
+      </el-form-item>
+      <el-form-item label="状态">
+        <el-select
+          v-model="searchModel.status"
+          placeholder="请选择状态"
+          clearable
+          style="width: 100px"
+        >
+          <el-option label="启用" value="enabled" />
+          <el-option label="禁用" value="disabled" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="角色">
+        <el-select
+          v-model="searchModel.role"
+          placeholder="请选择角色"
+          clearable
+          style="width: 200px"
+        >
+          <el-option label="学校管理员" value="school_admin" />
+          <el-option label="扫描员" value="scanner" />
+          <el-option label="普通用户" value="user" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-space wrap>
+          <el-button type="primary" @click="toPage(1)">查询</el-button>
+          <el-button type="success" @click="onAdd">新建用户</el-button>
+
+          <el-dropdown @command="onImportCommand">
+            <el-button type="primary">
+              导入
+              <el-icon class="el-icon--right"><ArrowDown /> </el-icon>
+            </el-button>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="kzz">科组长</el-dropdown-item>
+                <el-dropdown-item command="fhy">复核员</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+          <el-button type="primary" @click="onImportMarker"
+            >导入班级评卷员</el-button
+          >
+
+          <el-dropdown @command="onExportCommand">
+            <el-button type="primary">
+              导出
+              <el-icon class="el-icon--right"><ArrowDown /> </el-icon>
+            </el-button>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="kzz">导出</el-dropdown-item>
+                <el-dropdown-item command="fhy"
+                  >按考试导出全部</el-dropdown-item
+                >
+                <el-dropdown-item command="fhy"
+                  >按考试导出科目拆分表</el-dropdown-item
+                >
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+          <el-button type="success" @click="onBatchAdd">批量新增</el-button>
+
+          <el-button
+            type="primary"
+            :disabled="!canBatchAction"
+            @click="onBatchEnable(true)"
+            >启用</el-button
+          >
+          <el-button
+            type="warning"
+            :disabled="!canBatchAction"
+            @click="onBatchEnable(false)"
+            >禁用</el-button
+          >
+          <el-button
+            type="danger"
+            :disabled="!canBatchAction"
+            @click="onBatchResetPassword"
+            >重置密码</el-button
+          >
+        </el-space>
+      </el-form-item>
+    </el-form>
+  </div>
+
+  <div class="part-box">
+    <el-table
+      v-loading="loadingTable"
+      :data="dataList"
+      class="page-table"
+      @selection-change="handleSelectionChange"
+    >
+      <el-table-column type="selection" width="55" />
+      <el-table-column prop="loginName" label="登录名" />
+      <el-table-column prop="name" label="名称" />
+      <el-table-column prop="employeeId" label="工号" />
+      <el-table-column prop="source" label="来源" />
+      <el-table-column prop="role" label="角色">
+        <template #default="{ row }">
+          {{ formatRole(row.role) }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="status" label="状态">
+        <template #default="{ row }">
+          <el-tag :type="row.status === 'enabled' ? 'success' : 'danger'">
+            {{ row.status === 'enabled' ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="140">
+        <template #default="{ row }">
+          <el-button size="small" type="primary" link @click="onEdit(row)"
+            >编辑</el-button
+          >
+          <el-button
+            size="small"
+            type="danger"
+            link
+            @click="onResetPassword(row)"
+            >重置密码</el-button
+          >
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-pagination
+      v-model:current-page="pagination.pageNumber"
+      v-model:page-size="pagination.pageSize"
+      :layout="pagination.layout"
+      :total="pagination.total"
+      @size-change="pageSizeChange"
+      @current-change="toPage"
+    />
+  </div>
+
+  <!-- 新增/编辑用户弹窗 -->
+  <ModifyUser ref="modifyUserRef" :row-data="curRow" @modified="getList" />
+
+  <!-- 导入用户 -->
+  <ImportDialog
+    ref="importUserDialogRef"
+    title="导入用户"
+    upload-url="/api/admin/site/import"
+    :format="['xls', 'xlsx']"
+    :download-handle="downloadTemplate"
+    download-filename="用户导入模板.xlsx"
+  />
+  <!-- 导入班级评卷员 -->
+  <ImportDialog
+    ref="importMarkerDialogRef"
+    title="导入班级评卷员"
+    upload-url="/api/admin/site/import"
+    :format="['xls', 'xlsx']"
+    :download-handle="downloadTemplate"
+    download-filename="导入班级评卷员模板.xlsx"
+  />
+
+  <!-- 批量新增用户弹窗 -->
+  <BatchCreateUserDialog ref="batchCreateUserDialogRef" @modified="getList" />
+</template>
+
+<script setup lang="ts">
+  import { reactive, ref, onMounted, computed } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import { ArrowDown } from '@element-plus/icons-vue';
+  import { userListPage, resetUserPassword, enableUser } from '@/api/user';
+  import type { UserItem, UserListFilter } from '@/api/types/user';
+  import useTable from '@/hooks/table';
+  import { modalConfirm } from '@/utils/ui';
+
+  import ModifyUser from './ModifyUser.vue'; // 引入弹窗组件
+  import BatchCreateUserDialog from './components/BatchCreateUserDialog.vue';
+
+  defineOptions({
+    name: 'UserManage',
+  });
+
+  const searchModel = reactive<UserListFilter>({});
+
+  const {
+    dataList,
+    pagination,
+    getList,
+    toPage,
+    pageSizeChange,
+    loading: loadingTable,
+  } = useTable<UserItem>(userListPage, searchModel);
+
+  const modifyUserRef = ref<InstanceType<typeof ModifyUser> | null>(null);
+  const batchCreateUserDialogRef = ref<InstanceType<
+    typeof BatchCreateUserDialog
+  > | null>(null);
+  const curRow = ref<UserItem | undefined>(undefined);
+  const selectedRows = ref<UserItem[]>([]);
+
+  const canBatchAction = computed(() => {
+    return selectedRows.value.length > 0;
+  });
+
+  const onAdd = () => {
+    curRow.value = undefined; // 清除当前行数据,表示新增
+    modifyUserRef.value?.open();
+  };
+
+  const onBatchAdd = () => {
+    batchCreateUserDialogRef.value?.open();
+  };
+
+  const onEdit = (row: UserItem) => {
+    curRow.value = row;
+    modifyUserRef.value?.open();
+  };
+
+  const handleSelectionChange = (selection: UserItem[]) => {
+    selectedRows.value = selection;
+  };
+
+  const onResetPassword = async (row: UserItem) => {
+    const confirm = await modalConfirm(
+      `确定要重置用户 "${row.name}" 的密码吗?`,
+      '提示'
+    ).catch(() => false);
+    if (!confirm) return;
+
+    try {
+      // 调用重置密码接口,不传递新密码,由后端处理
+      await resetUserPassword({ ids: [row.id] });
+      ElMessage.success('密码重置成功!');
+    } catch (error) {
+      console.error('操作失败:', error);
+    }
+  };
+
+  // const onDelete = async (row: UserItem) => {
+  //   const confirm = await modalConfirm(
+  //     `确定要删除用户 "${row.name}" 吗?`,
+  //     '警告'
+  //   ).catch(() => false);
+  //   if (!confirm) return;
+
+  //   try {
+  //     await deleteUser(row.id);
+  //     ElMessage.success('用户删除成功!');
+  //     getList(); // 刷新列表
+  //   } catch (error) {
+  //     if (error !== 'cancel') {
+  //       console.error('删除用户失败:', error);
+  //       // ElMessage.error('删除用户失败');
+  //     }
+  //   }
+  // };
+
+  const formatRole = (roleKey: string) => {
+    const roleMap: Record<string, string> = {
+      school_admin: '学校管理员',
+      scanner: '扫描员',
+      user: '普通用户',
+    };
+    return roleMap[roleKey] || roleKey;
+  };
+
+  // 导入用户
+  const importUserDialogRef = ref();
+  const importData = reactive({
+    importType: 'kzz',
+  });
+  const onImportCommand = (command: string) => {
+    console.log('导入命令:', command);
+    importData.importType = command;
+    importUserDialogRef.value?.open();
+  };
+  async function downloadTemplate() {
+    // const res = await downloadByApi(() => agentTemplate()).catch((e) => {
+    //   Message.error(e || '下载失败,请重新尝试!');
+    // });
+    // if (!res) return;
+    // Message.success('下载成功!');
+  }
+
+  // 导入班级评卷员
+  const importMarkerDialogRef = ref();
+  const onImportMarker = () => {
+    importMarkerDialogRef.value?.open();
+  };
+
+  // 导出
+  const onExportCommand = (command: string) => {
+    console.log('导出命令:', command);
+  };
+
+  // 批量启用、禁用
+  const onBatchEnable = async (enabled: boolean) => {
+    if (canBatchAction.value) {
+      ElMessage.warning('请至少选择一个用户');
+      return;
+    }
+    const action = enabled ? '启用' : '禁用';
+    const confirm = await modalConfirm(
+      `确定要${action}选中的 ${selectedRows.value.length} 个用户吗?`,
+      '提示'
+    ).catch(() => false);
+    if (!confirm) return;
+
+    try {
+      // 调用重置密码接口,不传递新密码,由后端处理
+      await enableUser({
+        ids: selectedRows.value.map((user) => user.id),
+        enabled,
+      });
+      ElMessage.success('操作成功!');
+      getList();
+    } catch (error) {
+      console.error('操作失败:', error);
+    }
+  };
+
+  // 批量重置密码
+  const onBatchResetPassword = async () => {
+    if (canBatchAction.value) {
+      ElMessage.warning('请至少选择一个用户');
+      return;
+    }
+    const confirm = await modalConfirm(
+      `确定要重置选中 ${selectedRows.value.length} 个用户的密码吗?`,
+      '提示'
+    ).catch(() => false);
+    if (!confirm) return;
+
+    try {
+      // 调用重置密码接口,不传递新密码,由后端处理
+      await resetUserPassword({
+        ids: selectedRows.value.map((user) => user.id),
+      });
+      ElMessage.success('操作成功!');
+    } catch (error) {
+      console.error('操作失败:', error);
+    }
+  };
+
+  onMounted(() => {
+    // getList();
+  });
+</script>

+ 220 - 0
src/views/user/components/BatchCreateUserDialog.vue

@@ -0,0 +1,220 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="批量新增用户"
+    width="500px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    top="10vh"
+    append-to-body
+    @close="handleClose"
+    @open="modalBeforeOpen"
+  >
+    <el-form
+      ref="formRef"
+      :model="formModel"
+      :rules="rules"
+      label-width="120px"
+    >
+      <el-form-item label="考试名称">
+        <el-input :value="curExamName" disabled />
+      </el-form-item>
+      <el-form-item label="角色" prop="role">
+        <el-select v-model="formModel.role" placeholder="请选择角色">
+          <el-option label="学校管理员" value="school_admin" />
+          <el-option label="扫描员" value="scanner" />
+          <el-option label="普通用户" value="user" />
+          <el-option label="科组长" value="subject_leader" />
+          <el-option label="复核员" value="reviewer" />
+          <el-option label="评卷员" value="marker" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="命名规则" prop="namingRule">
+        <el-input v-model="formModel.namingRule" placeholder="自定义前缀">
+          <template #append>+科目代码+流水号</template>
+        </el-input>
+      </el-form-item>
+      <el-form-item label="每分组账号数" prop="accountsPerGroup">
+        <el-input-number
+          v-model="formModel.accountsPerGroup"
+          :min="1"
+          :max="20"
+          :step="1"
+          :precision="0"
+          :controls="false"
+          step-strictly
+        />
+      </el-form-item>
+      <el-form-item label="随机密码" prop="randomPassword">
+        <el-switch
+          v-model="formModel.randomPassword"
+          @change="randomPasswordChange"
+        />
+      </el-form-item>
+      <el-form-item
+        v-if="!formModel.randomPassword"
+        label="密码"
+        prop="password"
+      >
+        <el-input
+          v-model="formModel.password"
+          type="password"
+          placeholder="请输入密码"
+          :disabled="formModel.randomPassword"
+          show-password
+        />
+      </el-form-item>
+      <el-form-item label="选择科目" prop="subjectIds">
+        <el-button type="primary" @click="openSelectSubjectDialog"
+          >设置</el-button
+        >
+        <div v-if="selectedSubjects.length > 0" style="margin-left: 10px">
+          已选择 {{ selectedSubjects.length }} 个科目
+        </div>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" :loading="loading" @click="handleSave"
+          >确定</el-button
+        >
+      </span>
+    </template>
+  </el-dialog>
+
+  <SelectSubjectDialog
+    ref="selectSubjectDialogRef"
+    :select-ids="formModel.subjectIds"
+    @selected="handleSubjectsSelected"
+  />
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, computed } from 'vue';
+  import type { FormInstance } from 'element-plus';
+  import { FormRules } from '@/types/global';
+  import { ElMessage } from 'element-plus';
+  import { useAppStore } from '@/store';
+  import useLoading from '@/hooks/loading';
+  import useModal from '@/hooks/modal';
+
+  import type { SubjectItem } from '@/api/types/base';
+  import type { BatchCreateUserParam } from '@/api/types/user';
+  import { batchAddUser } from '@/api/user';
+
+  import SelectSubjectDialog from './SelectSubjectDialog.vue';
+
+  defineOptions({
+    name: 'BatchCreateUserDialog',
+  });
+
+  /* modal */
+  const { visible, open, close } = useModal();
+  defineExpose({ open, close });
+
+  const emit = defineEmits(['modified']);
+
+  const appStore = useAppStore();
+  const curExamName = computed(() => appStore.curExam?.name || '');
+
+  const formRef = ref<FormInstance>();
+
+  const rules: FormRules<keyof BatchCreateUserParam> = {
+    role: [{ required: true, message: '请选择角色', trigger: 'change' }],
+    namingRule: [
+      { required: true, message: '请输入命名规则', trigger: 'change' },
+    ],
+    accountsPerGroup: [
+      { required: true, message: '请输入每分组账号数', trigger: 'change' },
+    ],
+    password: [
+      {
+        required: true,
+        message: '请输入密码或选择随机密码',
+        trigger: 'change',
+      },
+    ],
+    subjectIds: [
+      {
+        required: true,
+        validator: (rule: any, value: number[], callback: any) => {
+          if (!value || value.length === 0) {
+            callback(new Error('请选择科目'));
+          } else {
+            callback();
+          }
+        },
+        trigger: 'change',
+      },
+    ],
+  };
+
+  function getInitialFormModel(): BatchCreateUserParam {
+    return {
+      examId: appStore.curExam?.id || 0,
+      role: '',
+      namingRule: '',
+      accountsPerGroup: 1,
+      randomPassword: false,
+      password: '',
+      subjectIds: [],
+    };
+  }
+
+  const formModel = reactive<BatchCreateUserParam>(getInitialFormModel());
+  const selectedSubjects = ref<SubjectItem[]>([]);
+  const selectSubjectDialogRef = ref<InstanceType<
+    typeof SelectSubjectDialog
+  > | null>(null);
+
+  function randomPasswordChange() {
+    if (formModel.randomPassword) {
+      formModel.password = '';
+    }
+  }
+
+  const handleClose = () => {
+    formRef.value?.resetFields();
+  };
+
+  const openSelectSubjectDialog = () => {
+    selectSubjectDialogRef.value?.open(selectedSubjects.value.map((s) => s.id));
+  };
+
+  const handleSubjectsSelected = (subjects: SubjectItem[]) => {
+    selectedSubjects.value = subjects;
+    formModel.subjectIds = subjects.map((s) => s.id);
+    ElMessage.success(`已选择 ${subjects.length} 个科目`);
+    formRef.value?.validateField('subjectIds');
+  };
+
+  const { loading, setLoading } = useLoading();
+  const handleSave = async () => {
+    if (!formRef.value) return;
+
+    const valid = await formRef.value.validate().catch(() => false);
+    if (!valid) return;
+
+    try {
+      setLoading(true);
+      let res = true;
+      await batchAddUser({ ...formModel }).catch(() => {
+        res = false;
+      });
+      setLoading(false);
+      if (!res) return;
+      ElMessage.success(`批量新增用户成功!`);
+      emit('modified');
+      close();
+    } catch (error) {
+      console.error('API request error:', error);
+      ElMessage.error('操作失败,请重试');
+    }
+  };
+
+  const modalBeforeOpen = () => {
+    Object.assign(formModel, getInitialFormModel());
+    selectedSubjects.value = [];
+  };
+</script>

+ 213 - 0
src/views/user/components/SelectSubjectDialog.vue

@@ -0,0 +1,213 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="选择科目"
+    width="1000px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    top="10px"
+    append-to-body
+    @close="handleClose"
+    @open="modalBeforeOpen"
+  >
+    <el-form :model="searchModel" inline>
+      <el-form-item label="科目">
+        <el-select
+          v-model="searchModel.subjectId"
+          placeholder="请选择科目"
+          clearable
+          filterable
+        >
+          <el-option
+            v-for="item in subjectList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </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="id"
+      @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="状态">
+        <template #default="{ row }">
+          <el-tag :type="row.status === 'normal' ? 'success' : 'danger'">
+            {{ row.status === 'normal' ? '正常' : '异常' }}
+          </el-tag>
+        </template>
+      </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-dialog>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, nextTick } from 'vue';
+  import { ElMessage, ElTable } from 'element-plus';
+  import type { TableInstance } from 'element-plus';
+  import useLoading from '@/hooks/loading';
+  import useModal from '@/hooks/modal';
+  import type { SubjectItem } from '@/api/types/base'; // Assuming SubjectItem type exists
+  import { subjectQuery } from '@/api/base'; // Assuming API for subjects exists
+  import { useAppStore } from '@/store';
+
+  defineOptions({
+    name: 'SelectSubjectDialog',
+  });
+
+  interface Props {
+    selectIds?: number[];
+  }
+
+  const props = withDefaults(defineProps<Props>(), {
+    selectIds: () => [],
+  });
+
+  interface SearchModel {
+    subjectId: number;
+  }
+
+  /* modal */
+  const { visible, open, close } = useModal();
+  defineExpose({ open, close });
+
+  const appStore = useAppStore();
+
+  const emit = defineEmits(['selected']);
+
+  // Mock API call for subject list
+  const mockSubjectList: SubjectItem[] = [
+    {
+      id: 1,
+      name: '201904516-工程图学2',
+      objectiveScore: 0,
+      subjectiveScore: 0,
+      totalScore: 0,
+      status: 'normal',
+    },
+    {
+      id: 2,
+      name: '201904516_1-工程图学2',
+      objectiveScore: 60,
+      subjectiveScore: 40,
+      totalScore: 100,
+      status: 'normal',
+    },
+    {
+      id: 3,
+      name: '语文',
+      objectiveScore: 50,
+      subjectiveScore: 50,
+      totalScore: 100,
+      status: 'normal',
+    },
+    {
+      id: 4,
+      name: '数学',
+      objectiveScore: 70,
+      subjectiveScore: 30,
+      totalScore: 100,
+      status: 'disabled',
+    },
+  ];
+
+  const { loading, setLoading } = useLoading();
+  const searchModel = reactive<SearchModel>({ subjectId: undefined });
+  const subjectList = ref<SubjectItem[]>([]);
+  const dataList = ref<SubjectItem[]>([]);
+  const selectedSubjects = ref<SubjectItem[]>([]);
+  const tableRef = ref<TableInstance>();
+
+  const fetchSubjectList = async () => {
+    setLoading(true);
+    try {
+      if (!appStore.curExam?.id) return;
+
+      const res = await subjectQuery(appStore.curExam?.id);
+      subjectList.value = res || [];
+      dataList.value = res || [];
+      await nextTick();
+      tableRef.value?.toggleAllSelection();
+    } catch (error) {
+      subjectList.value = [];
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const search = async () => {
+    if (!searchModel.subjectId) {
+      dataList.value = subjectList.value;
+    } else {
+      dataList.value = subjectList.value.filter((item) => {
+        return item.id === searchModel.subjectId;
+      });
+    }
+
+    await nextTick();
+    tableRef.value?.toggleAllSelection();
+  };
+
+  const handleSelectionChange = (selection: SubjectItem[]) => {
+    selectedSubjects.value = selection;
+  };
+
+  const handleConfirmSelection = () => {
+    if (selectedSubjects.value.length === 0) {
+      ElMessage.warning('请至少选择一个科目');
+      return;
+    }
+    emit('selected', [...selectedSubjects.value]); // Emit a copy to avoid reactivity issues
+    close();
+  };
+
+  const handleClose = () => {
+    searchModel.name = '';
+    selectedSubjects.value = [];
+  };
+
+  const modalBeforeOpen = async () => {
+    // ceshi
+    subjectList.value = mockSubjectList;
+    dataList.value = mockSubjectList;
+
+    await fetchSubjectList();
+    if (props.selectIds.length > 0) {
+      await nextTick();
+      selectedSubjects.value = dataList.value.filter((item) => {
+        return props.selectIds.includes(item.id);
+      });
+      if (selectedSubjects.value.length > 0) {
+        tableRef.value?.toggleRowSelection(selectedSubjects.value, true);
+      } else {
+        tableRef.value?.toggleAllSelection();
+      }
+    } else {
+      tableRef.value?.toggleAllSelection();
+    }
+  };
+</script>