diff --git a/apps/admin/config/routes.ts b/apps/admin/config/routes.ts index 7dabaef..9533a4b 100644 --- a/apps/admin/config/routes.ts +++ b/apps/admin/config/routes.ts @@ -29,24 +29,18 @@ export default [ }, ], }, - // Overview section + // Dashboard { path: '/overview', - name: 'overview', + hideInMenu: true, + redirect: '/overview/dashboard', + }, + { + path: '/overview/dashboard', + name: 'dashboard', + icon: 'DashboardOutlined', + component: './Welcome', access: 'canAdmin', - routes: [ - { - path: '/overview', - redirect: '/overview/dashboard', - }, - { - path: '/overview/dashboard', - name: 'dashboard', - icon: 'DashboardOutlined', - component: './Welcome', - access: 'canAdmin', - }, - ], }, // Content section { @@ -124,10 +118,31 @@ export default [ access: 'canAdmin', }, { - path: '/examination/candidates', - name: 'candidates', - icon: 'TeamOutlined', - component: './Examination/Candidates', + path: '/examination/exams/:id', + name: 'examDetail', + hideInMenu: true, + component: './Examination/ExamDetail', + access: 'canAdmin', + }, + { + path: '/examination/submissions', + name: 'submissions', + icon: 'TrophyOutlined', + component: './Assessment/Results/Submissions', + access: 'canAdmin', + }, + { + path: '/examination/judge-tasks', + name: 'judgeTasks', + icon: 'ThunderboltOutlined', + component: './Assessment/Results/JudgeTasks', + access: 'canAdmin', + }, + { + path: '/examination/events', + name: 'events', + icon: 'SafetyOutlined', + component: './Monitoring/Proctoring/Events', access: 'canAdmin', }, ], @@ -137,7 +152,13 @@ export default [ path: '/examination/exams/create', name: 'examCreate', hideInMenu: true, - component: './ComingSoon', + redirect: '/examination/exams', + }, + { + path: '/examination/exams/:id/edit', + name: 'examEdit', + hideInMenu: true, + component: './Examination/ExamForm', access: 'canAdmin', }, { @@ -147,58 +168,42 @@ export default [ component: './Examination/ExamPublish', access: 'canAdmin', }, - // Monitoring section + // Legacy monitoring redirects { path: '/monitoring', - name: 'monitoring', - access: 'canAdmin', - routes: [ - { - path: '/monitoring', - redirect: '/monitoring/proctoring/events', - }, - { - path: '/monitoring/proctoring', - redirect: '/monitoring/proctoring/events', - }, - { - path: '/monitoring/proctoring/events', - name: 'events', - icon: 'SafetyOutlined', - component: './Monitoring/Proctoring/Events', - access: 'canAdmin', - }, - ], + hideInMenu: true, + redirect: '/examination/events', + }, + { + path: '/monitoring/proctoring', + hideInMenu: true, + redirect: '/examination/events', + }, + { + path: '/monitoring/proctoring/events', + hideInMenu: true, + redirect: '/examination/events', }, - // Assessment section + // Legacy assessment redirects { path: '/assessment', - name: 'assessment', - access: 'canAdmin', - routes: [ - { - path: '/assessment', - redirect: '/assessment/results/submissions', - }, - { - path: '/assessment/results', - redirect: '/assessment/results/submissions', - }, - { - path: '/assessment/results/submissions', - name: 'submissions', - icon: 'TrophyOutlined', - component: './Assessment/Results/Submissions', - access: 'canAdmin', - }, - { - path: '/assessment/results/judge-tasks', - name: 'judgeTasks', - icon: 'ThunderboltOutlined', - component: './Assessment/Results/JudgeTasks', - access: 'canAdmin', - }, - ], + hideInMenu: true, + redirect: '/examination/submissions', + }, + { + path: '/assessment/results', + hideInMenu: true, + redirect: '/examination/submissions', + }, + { + path: '/assessment/results/submissions', + hideInMenu: true, + redirect: '/examination/submissions', + }, + { + path: '/assessment/results/judge-tasks', + hideInMenu: true, + redirect: '/examination/judge-tasks', }, // System section { @@ -217,13 +222,31 @@ export default [ { path: '/system/settings/users', name: 'users', - icon: 'SettingOutlined', + icon: 'UserOutlined', component: './System/Settings/Users', access: 'canAdmin', }, + { + path: '/system/settings/user-groups', + name: 'userGroups', + icon: 'TeamOutlined', + component: './System/Settings/UserGroups', + access: 'canAdmin', + }, + { + path: '/system/settings/user-groups/:id', + name: 'userGroupDetail', + hideInMenu: true, + component: './System/Settings/UserGroups/Detail', + access: 'canAdmin', + }, ], }, // Legacy redirects + { + path: '/examination/candidates', + redirect: '/system/settings/user-groups', + }, { path: '/admin/exams', redirect: '/examination/exams', diff --git a/apps/admin/src/locales/en-US/menu.ts b/apps/admin/src/locales/en-US/menu.ts index 819622f..6e379b7 100644 --- a/apps/admin/src/locales/en-US/menu.ts +++ b/apps/admin/src/locales/en-US/menu.ts @@ -1,12 +1,12 @@ export default { - 'menu.overview': 'OVERVIEW', + 'menu.overview': 'Dashboard', 'menu.dashboard': 'Dashboard', 'menu.overview.dashboard': 'Dashboard', - 'menu.content': 'CONTENT', - 'menu.examination': 'EXAMS', + 'menu.content': 'Library', + 'menu.examination': 'Exams', 'menu.monitoring': 'MONITORING', 'menu.assessment': 'ASSESSMENT', - 'menu.system': 'SYSTEM', + 'menu.system': 'System', 'menu.home': 'Home', 'menu.login': 'Login', 'menu.account': 'Account', @@ -23,9 +23,14 @@ export default { 'menu.content.paperCreate': 'New Paper', 'menu.content.paperDetail': 'Paper Detail', 'menu.examination.exams': 'Exams', + 'menu.examination.submissions': 'Submissions', + 'menu.examination.judgeTasks': 'Judge Tasks', + 'menu.examination.events': 'Events', 'menu.examCreate': 'Create Exam', + 'menu.examEdit': 'Edit Exam', 'menu.examPublish': 'Publish', - 'menu.examination.candidates': 'Candidates', + 'menu.examination.examDetail': 'Exam Detail', + 'menu.examination.candidates': 'User Groups', 'menu.proctoring': 'Proctoring', 'menu.monitoring.events': 'Events', 'menu.results': 'Results', @@ -33,5 +38,8 @@ export default { 'menu.assessment.judgeTasks': 'Judge Tasks', 'menu.settings': 'Settings', 'menu.system.users': 'Users', + 'menu.system.userGroups': 'User Groups', + 'menu.system.userGroupDetail': 'User Group Detail', 'menu.system.settings.users': 'Users', + 'menu.system.settings.userGroups': 'User Groups', }; diff --git a/apps/admin/src/locales/en-US/pages.ts b/apps/admin/src/locales/en-US/pages.ts index 7a80ed9..c6a040d 100644 --- a/apps/admin/src/locales/en-US/pages.ts +++ b/apps/admin/src/locales/en-US/pages.ts @@ -24,6 +24,12 @@ export default { 'pages.welcome.alertMessage': 'Welcome to Examora exam management system.', 'common.cancel': 'Cancel', 'common.publish': 'Publish', + 'common.save': 'Save', + 'common.view': 'View', + 'common.delete': 'Delete', + 'common.refresh': 'Refresh', + 'common.search': 'Search', + 'common.reset': 'Reset', // Login page 'pages.login.title': 'Sign In', 'pages.login.subtitle': @@ -90,6 +96,7 @@ export default { 'pages.users.columns.email': 'Email', 'pages.users.columns.role': 'Role', 'pages.users.columns.status': 'Status', + 'pages.users.columns.source': 'Source', 'pages.users.columns.createdAt': 'Created At', 'pages.users.columns.keyword': 'Keyword', 'pages.users.search.placeholder': 'Search username, email...', @@ -102,6 +109,13 @@ export default { 'pages.users.statuses.ACTIVE': 'Active', 'pages.users.statuses.INACTIVE': 'Inactive', 'pages.users.statuses.SUSPENDED': 'Suspended', + 'pages.users.source.LOCAL': 'Local', + 'pages.users.groupsLoadError': 'Failed to load user groups', + 'pages.users.joinGroup': 'Add to User Group', + 'pages.users.joinGroupSuccess': 'Users added to user group', + 'pages.users.joinGroupError': 'Failed to add users to user group', + 'pages.users.joinGroupPlaceholder': 'Select a user group', + 'pages.users.clearSelection': 'Clear', 'pages.users.modal.createTitle': 'Add User', 'pages.users.modal.description': 'Provide at least one of the following fields to continue', @@ -565,6 +579,177 @@ export default { 'pages.comingSoon.judgeTasks.title': 'Judge Tasks', 'pages.comingSoon.judgeTasks.description': 'Async judge tasks, retry status, and sandbox results will be tracked here.', + // Exam form + 'pages.exams.createTitle': 'Create Exam', + 'pages.exams.editTitle': 'Edit Exam', + 'pages.exams.edit': 'Edit', + 'pages.exams.createSuccess': 'Exam created', + 'pages.exams.createError': 'Failed to create exam', + 'pages.exams.form.description': + 'Configure exam basics and bind a paper. Save it before publishing from the exam list.', + 'pages.exams.form.title': 'Exam Name', + 'pages.exams.form.titleRequired': 'Please enter an exam name', + 'pages.exams.form.descriptionField': 'Description', + 'pages.exams.form.paper': 'Paper', + 'pages.exams.form.paperRequired': 'Please select a paper', + 'pages.exams.form.paperPlaceholder': 'Select a paper', + 'pages.exams.form.duration': 'Default Duration (minutes)', + 'pages.exams.form.durationRequired': 'Please enter exam duration', + 'pages.exams.form.durationUnit': 'min', + 'pages.exams.form.loadError': 'Failed to load exam configuration', + 'pages.exams.form.saveSuccess': 'Exam saved', + 'pages.exams.form.saveError': 'Failed to save exam', + 'pages.exams.form.readonlyHint': + 'Published or closed exams cannot be edited here.', + // Exam detail + 'pages.examDetail.title': 'Exam Detail', + 'pages.examDetail.loadError': 'Failed to load exam detail', + 'pages.examDetail.sessionsLoadError': 'Failed to load users', + 'pages.examDetail.resultsLoadError': 'Failed to load submissions', + 'pages.examDetail.eventsLoadError': 'Failed to load audit events', + 'pages.examDetail.usersLoadError': 'Failed to load users', + 'pages.examDetail.assignSuccess': 'Users assigned', + 'pages.examDetail.assignError': 'Failed to assign users', + 'pages.examDetail.removeCandidateTitle': 'Remove User', + 'pages.examDetail.removeCandidateContent': + 'Only users who have not started can be removed. Continue?', + 'pages.examDetail.resultDetailError': 'Failed to load submission detail', + 'pages.examDetail.user': 'User', + 'pages.examDetail.status': 'Status', + 'pages.examDetail.startedAt': 'Started At', + 'pages.examDetail.submittedAt': 'Submitted At', + 'pages.examDetail.score': 'Score', + 'pages.examDetail.createdAt': 'Time', + 'pages.examDetail.overview': 'Overview', + 'pages.examDetail.candidates': 'Users', + 'pages.examDetail.results': 'Submissions', + 'pages.examDetail.events': 'Audit Events', + 'pages.examDetail.assign': 'Assign Users', + 'pages.examDetail.assignPlaceholder': 'Select unassigned users', + 'pages.examDetail.assignSource': 'Source', + 'pages.examDetail.directAssignment': 'Direct Assignment', + 'pages.examDetail.userGroup': 'User Group', + 'pages.examDetail.assignByUser': 'By User', + 'pages.examDetail.assignByGroup': 'By User Group', + 'pages.examDetail.groupCoverage': 'Estimated {count} users', + 'pages.examDetail.resultDetail': 'Submission Detail', + 'pages.examDetail.eventDetail': 'Event Detail', + 'pages.examDetail.duration': 'Duration', + 'pages.examDetail.paper': 'Paper', + 'pages.examDetail.startTime': 'Start Time', + 'pages.examDetail.endTime': 'End Time', + 'pages.examDetail.description': 'Description', + // Candidates + 'pages.candidates.description': + 'Maintain user group hierarchy and group members. Exams can be assigned by user or user group.', + 'pages.candidates.groups': 'User Groups', + 'pages.candidates.groupsLoadError': 'Failed to load user groups', + 'pages.candidates.usersLoadError': 'Failed to load users', + 'pages.candidates.membersLoadError': 'Failed to load group users', + 'pages.candidates.groupSaveSuccess': 'User group saved', + 'pages.candidates.groupSaveError': 'Failed to save user group', + 'pages.candidates.groupDeleteTitle': 'Delete User Group', + 'pages.candidates.groupDeleteContent': 'Delete "{name}"?', + 'pages.candidates.memberSaveSuccess': 'User added to group', + 'pages.candidates.memberSaveError': 'Failed to add user', + 'pages.candidates.columns.student': 'User', + 'pages.candidates.columns.role': 'Role', + 'pages.candidates.columns.status': 'Status', + 'pages.candidates.columns.group': 'Current Scope', + 'pages.candidates.columns.createdAt': 'Created At', + 'pages.candidates.removeMember': 'Remove', + 'pages.candidates.addChildGroup': 'Add Child Group', + 'pages.candidates.editGroup': 'Edit User Group', + 'pages.candidates.deleteGroup': 'Delete', + 'pages.candidates.emptyGroups': 'No user groups', + 'pages.candidates.allStudents': 'All Users', + 'pages.candidates.addMember': 'Add User', + 'pages.candidates.createGroup': 'New User Group', + 'pages.candidates.groupName': 'User Group Name', + 'pages.candidates.groupDescription': 'Description', + 'pages.candidates.memberPlaceholder': + 'Select users to add to the current group', + // User groups + 'pages.userGroups.description': + 'Maintain reusable user scopes. Exams can be assigned by user or user group.', + 'pages.userGroups.listTitle': 'User Group List', + 'pages.userGroups.total': 'Total {total} records', + 'pages.userGroups.keyword': 'Keyword', + 'pages.userGroups.searchPlaceholder': 'Search name or description', + 'pages.userGroups.source': 'Source', + 'pages.userGroups.sourceLocal': 'Local', + 'pages.userGroups.name': 'User Group', + 'pages.userGroups.members': 'Members', + 'pages.userGroups.createdAt': 'Created At', + 'pages.userGroups.more': 'More', + 'pages.userGroups.create': 'New User Group', + 'pages.userGroups.loadError': 'Failed to load user groups', + 'pages.userGroups.saveSuccess': 'User group saved', + 'pages.userGroups.saveError': 'Failed to save user group', + 'pages.userGroups.deleteTitle': 'Delete User Group', + 'pages.userGroups.deleteContent': 'Delete "{name}"?', + 'pages.userGroups.deleteSuccess': 'User group deleted', + 'pages.userGroups.deleteError': 'Failed to delete user group', + 'pages.userGroups.form.name': 'Name', + 'pages.userGroups.form.nameRequired': 'Please enter a user group name', + 'pages.userGroups.form.namePlaceholder': 'Example: Spring 2026', + 'pages.userGroups.form.description': 'Description', + 'pages.userGroups.form.optional': 'Optional', + 'pages.userGroups.detail.title': 'User Group Detail', + 'pages.userGroups.detail.examLoadError': 'Failed to load exams', + 'pages.userGroups.detail.addMemberSuccess': 'User added to group', + 'pages.userGroups.detail.addMemberError': 'Failed to add user', + 'pages.userGroups.detail.removeMemberTitle': 'Remove User', + 'pages.userGroups.detail.removeMemberContent': + 'Remove "{name}" from this user group?', + 'pages.userGroups.detail.removeMemberError': 'Failed to remove user', + 'pages.userGroups.detail.user': 'User', + 'pages.userGroups.detail.role': 'Role', + 'pages.userGroups.detail.directMember': 'Direct Member', + 'pages.userGroups.detail.inheritedMember': 'Inherited Member', + 'pages.userGroups.detail.joinedAt': 'Joined At', + 'pages.userGroups.detail.remove': 'Remove', + 'pages.userGroups.detail.userSearchPlaceholder': 'Search username or email', + 'pages.userGroups.detail.examId': 'Exam ID', + 'pages.userGroups.detail.type': 'Type', + 'pages.userGroups.detail.time': 'Time', + 'pages.userGroups.detail.syncMode': 'Sync {mode}', + 'pages.userGroups.detail.memberCount': 'Members {count}', + 'pages.userGroups.detail.examCount': 'Exams {count}', + 'pages.userGroups.detail.basic': 'Basic Info', + 'pages.userGroups.detail.createdAtMeta': 'Created at {time}', + 'pages.userGroups.detail.updatedAtMeta': 'Updated at {time}', + 'pages.userGroups.detail.membersLoadError': 'Failed to load members', + 'pages.userGroups.detail.addMember': 'Add User', + 'pages.userGroups.detail.exams': 'Exams', + 'pages.userGroups.detail.examSearchPlaceholder': 'Search exam ID', + 'pages.userGroups.detail.usersLoadError': 'Failed to load users', + // Results + 'pages.results.description': + 'Review candidate submissions, total scores, and per-question grading status by exam.', + 'pages.results.examsLoadError': 'Failed to load exams', + 'pages.results.detailLoadError': 'Failed to load result detail', + 'pages.results.fetchError': 'Failed to load submission records', + 'pages.results.examPlaceholder': 'Select an exam', + 'pages.results.selectExamEmpty': 'Please select an exam', + 'pages.results.detailTitle': 'Submission Detail', + 'pages.results.columns.user': 'User ID', + 'pages.results.columns.status': 'Status', + 'pages.results.columns.score': 'Score', + 'pages.results.columns.submittedAt': 'Submitted At', + // Judge tasks + 'pages.judgeTasks.description': + 'Review async judge tasks, retries, runtime duration, and sandbox summaries.', + 'pages.judgeTasks.fetchError': 'Failed to load judge tasks', + 'pages.judgeTasks.detailLoadError': 'Failed to load judge task detail', + 'pages.judgeTasks.detailTitle': 'Judge Task Detail', + 'pages.judgeTasks.columns.submission': 'Submission ID', + 'pages.judgeTasks.columns.question': 'Question ID', + 'pages.judgeTasks.columns.language': 'Language', + 'pages.judgeTasks.columns.status': 'Status', + 'pages.judgeTasks.columns.retry': 'Retry', + 'pages.judgeTasks.columns.summary': 'Summary', + 'pages.judgeTasks.columns.createdAt': 'Created At', // Forbidden 'pages.forbidden.title': 'Admin Access Denied', 'pages.forbidden.subTitle': diff --git a/apps/admin/src/locales/zh-CN/menu.ts b/apps/admin/src/locales/zh-CN/menu.ts index bcb1c1f..0034727 100644 --- a/apps/admin/src/locales/zh-CN/menu.ts +++ b/apps/admin/src/locales/zh-CN/menu.ts @@ -1,12 +1,12 @@ export default { - 'menu.overview': 'OVERVIEW', - 'menu.dashboard': 'Dashboard', - 'menu.overview.dashboard': 'Dashboard', - 'menu.content': 'CONTENT', - 'menu.examination': 'EXAMS', + 'menu.overview': '工作台', + 'menu.dashboard': '工作台', + 'menu.overview.dashboard': '工作台', + 'menu.content': '资源库', + 'menu.examination': '考试', 'menu.monitoring': 'MONITORING', 'menu.assessment': 'ASSESSMENT', - 'menu.system': 'SYSTEM', + 'menu.system': '系统', 'menu.home': '首页', 'menu.login': '登录', 'menu.account': '账户', @@ -23,9 +23,14 @@ export default { 'menu.content.paperCreate': '新建试卷', 'menu.content.paperDetail': '试卷详情', 'menu.examination.exams': '考试', + 'menu.examination.submissions': '答卷', + 'menu.examination.judgeTasks': '判题', + 'menu.examination.events': '事件', 'menu.examCreate': '创建考试', + 'menu.examEdit': '编辑考试', 'menu.examPublish': '发布考试', - 'menu.examination.candidates': '考生', + 'menu.examination.examDetail': '考试详情', + 'menu.examination.candidates': '用户组', 'menu.proctoring': '监考', 'menu.monitoring.events': '事件', 'menu.results': '评测', @@ -33,5 +38,8 @@ export default { 'menu.assessment.judgeTasks': '判题', 'menu.settings': '设置', 'menu.system.users': '用户', + 'menu.system.userGroups': '用户组', + 'menu.system.userGroupDetail': '用户组详情', 'menu.system.settings.users': '用户', + 'menu.system.settings.userGroups': '用户组', }; diff --git a/apps/admin/src/locales/zh-CN/pages.ts b/apps/admin/src/locales/zh-CN/pages.ts index d49366f..a134c6d 100644 --- a/apps/admin/src/locales/zh-CN/pages.ts +++ b/apps/admin/src/locales/zh-CN/pages.ts @@ -23,6 +23,12 @@ export default { 'pages.welcome.alertMessage': '欢迎使用 Examora 考试管理系统。', 'common.cancel': '取消', 'common.publish': '发布', + 'common.save': '保存', + 'common.view': '查看', + 'common.delete': '删除', + 'common.refresh': '刷新', + 'common.search': '搜索', + 'common.reset': '重置', // Login page 'pages.login.title': '登录后台', 'pages.login.subtitle': '使用管理员账号进入 Examora 控制台。', @@ -82,6 +88,7 @@ export default { 'pages.users.columns.email': '邮箱', 'pages.users.columns.role': '角色', 'pages.users.columns.status': '状态', + 'pages.users.columns.source': '来源', 'pages.users.columns.createdAt': '创建时间', 'pages.users.columns.keyword': '关键词', 'pages.users.search.placeholder': '搜索用户名、邮箱...', @@ -94,6 +101,13 @@ export default { 'pages.users.statuses.ACTIVE': '启用', 'pages.users.statuses.INACTIVE': '停用', 'pages.users.statuses.SUSPENDED': '封禁', + 'pages.users.source.LOCAL': '本地', + 'pages.users.groupsLoadError': '加载用户组失败', + 'pages.users.joinGroup': '加入用户组', + 'pages.users.joinGroupSuccess': '用户已加入用户组', + 'pages.users.joinGroupError': '加入用户组失败', + 'pages.users.joinGroupPlaceholder': '选择要加入的用户组', + 'pages.users.clearSelection': '清空', 'pages.users.modal.createTitle': '添加用户', 'pages.users.modal.description': '提供以下至少一项字段才能继续', 'pages.users.modal.submit': '添加用户', @@ -346,7 +360,7 @@ export default { // Exam pages 'pages.exams.title': '考试管理', 'pages.exams.description': - '创建和管理考试,设置考试时间、时长和参与考生,支持线上监考。', + '创建和管理考试,设置考试时间、时长和参与用户,支持线上监考。', 'pages.exams.listTitle': '考试列表', 'pages.exams.create': '创建考试', 'pages.exams.more': '更多', @@ -533,6 +547,174 @@ export default { 'pages.comingSoon.judgeTasks.title': '判题任务', 'pages.comingSoon.judgeTasks.description': '异步判题任务、重试状态和沙箱结果将在这里跟踪。', + // Exam form + 'pages.exams.createTitle': '创建考试', + 'pages.exams.editTitle': '编辑考试', + 'pages.exams.edit': '编辑', + 'pages.exams.createSuccess': '考试已创建', + 'pages.exams.createError': '创建考试失败', + 'pages.exams.form.description': + '配置考试基本信息并绑定试卷,保存后可在考试列表发布。', + 'pages.exams.form.title': '考试名称', + 'pages.exams.form.titleRequired': '请输入考试名称', + 'pages.exams.form.descriptionField': '考试说明', + 'pages.exams.form.paper': '关联试卷', + 'pages.exams.form.paperRequired': '请选择试卷', + 'pages.exams.form.paperPlaceholder': '选择一份试卷', + 'pages.exams.form.duration': '默认时长(分钟)', + 'pages.exams.form.durationRequired': '请输入考试时长', + 'pages.exams.form.durationUnit': '分钟', + 'pages.exams.form.loadError': '加载考试配置失败', + 'pages.exams.form.saveSuccess': '考试已保存', + 'pages.exams.form.saveError': '保存考试失败', + 'pages.exams.form.readonlyHint': '已发布或已结束考试不可编辑基础配置。', + // Exam detail + 'pages.examDetail.title': '考试详情', + 'pages.examDetail.loadError': '加载考试详情失败', + 'pages.examDetail.sessionsLoadError': '加载用户列表失败', + 'pages.examDetail.resultsLoadError': '加载答卷失败', + 'pages.examDetail.eventsLoadError': '加载审计事件失败', + 'pages.examDetail.usersLoadError': '加载用户失败', + 'pages.examDetail.assignSuccess': '用户已分配', + 'pages.examDetail.assignError': '分配用户失败', + 'pages.examDetail.removeCandidateTitle': '移除用户', + 'pages.examDetail.removeCandidateContent': + '仅未开始考试的用户可以移除,确定继续?', + 'pages.examDetail.resultDetailError': '加载答卷详情失败', + 'pages.examDetail.user': '用户', + 'pages.examDetail.status': '状态', + 'pages.examDetail.startedAt': '开始时间', + 'pages.examDetail.submittedAt': '交卷时间', + 'pages.examDetail.score': '成绩', + 'pages.examDetail.createdAt': '时间', + 'pages.examDetail.overview': '概览', + 'pages.examDetail.candidates': '用户', + 'pages.examDetail.results': '答卷', + 'pages.examDetail.events': '审计事件', + 'pages.examDetail.assign': '分配用户', + 'pages.examDetail.assignPlaceholder': '选择未分配用户', + 'pages.examDetail.assignSource': '来源', + 'pages.examDetail.directAssignment': '直接分配', + 'pages.examDetail.userGroup': '用户组', + 'pages.examDetail.assignByUser': '按用户', + 'pages.examDetail.assignByGroup': '按用户组', + 'pages.examDetail.groupCoverage': '预计覆盖 {count} 名用户', + 'pages.examDetail.resultDetail': '答卷详情', + 'pages.examDetail.eventDetail': '事件详情', + 'pages.examDetail.duration': '时长', + 'pages.examDetail.paper': '试卷', + 'pages.examDetail.startTime': '开始时间', + 'pages.examDetail.endTime': '结束时间', + 'pages.examDetail.description': '说明', + // Candidates + 'pages.candidates.description': + '维护用户组层级和组内用户,考试可按用户或用户组分配。', + 'pages.candidates.groups': '用户组', + 'pages.candidates.groupsLoadError': '加载用户组失败', + 'pages.candidates.usersLoadError': '加载用户失败', + 'pages.candidates.membersLoadError': '加载组内用户失败', + 'pages.candidates.groupSaveSuccess': '用户组已保存', + 'pages.candidates.groupSaveError': '保存用户组失败', + 'pages.candidates.groupDeleteTitle': '删除用户组', + 'pages.candidates.groupDeleteContent': '确定删除「{name}」吗?', + 'pages.candidates.memberSaveSuccess': '用户已加入用户组', + 'pages.candidates.memberSaveError': '添加用户失败', + 'pages.candidates.columns.student': '用户', + 'pages.candidates.columns.role': '角色', + 'pages.candidates.columns.status': '状态', + 'pages.candidates.columns.group': '当前范围', + 'pages.candidates.columns.createdAt': '创建时间', + 'pages.candidates.removeMember': '移出', + 'pages.candidates.addChildGroup': '添加子组', + 'pages.candidates.editGroup': '编辑用户组', + 'pages.candidates.deleteGroup': '删除', + 'pages.candidates.emptyGroups': '暂无用户组', + 'pages.candidates.allStudents': '全部用户', + 'pages.candidates.addMember': '加入用户', + 'pages.candidates.createGroup': '新建用户组', + 'pages.candidates.groupName': '用户组名称', + 'pages.candidates.groupDescription': '说明', + 'pages.candidates.memberPlaceholder': '选择要加入当前用户组的用户', + // User groups + 'pages.userGroups.description': + '维护可复用的用户范围,考试可按用户或用户组分配。', + 'pages.userGroups.listTitle': '用户组列表', + 'pages.userGroups.total': '共 {total} 条', + 'pages.userGroups.keyword': '关键词', + 'pages.userGroups.searchPlaceholder': '搜索名称、说明', + 'pages.userGroups.source': '来源', + 'pages.userGroups.sourceLocal': '本地', + 'pages.userGroups.name': '用户组', + 'pages.userGroups.members': '成员', + 'pages.userGroups.createdAt': '创建时间', + 'pages.userGroups.more': '更多', + 'pages.userGroups.create': '新建用户组', + 'pages.userGroups.loadError': '加载用户组失败', + 'pages.userGroups.saveSuccess': '用户组已保存', + 'pages.userGroups.saveError': '保存用户组失败', + 'pages.userGroups.deleteTitle': '删除用户组', + 'pages.userGroups.deleteContent': '确定删除「{name}」吗?', + 'pages.userGroups.deleteSuccess': '用户组已删除', + 'pages.userGroups.deleteError': '删除用户组失败', + 'pages.userGroups.form.name': '名称', + 'pages.userGroups.form.nameRequired': '请输入用户组名称', + 'pages.userGroups.form.namePlaceholder': '例如:2026 春季班', + 'pages.userGroups.form.description': '说明', + 'pages.userGroups.form.optional': '可选', + 'pages.userGroups.detail.title': '用户组详情', + 'pages.userGroups.detail.examLoadError': '加载考试失败', + 'pages.userGroups.detail.addMemberSuccess': '用户已加入用户组', + 'pages.userGroups.detail.addMemberError': '添加用户失败', + 'pages.userGroups.detail.removeMemberTitle': '移出用户', + 'pages.userGroups.detail.removeMemberContent': + '确定将「{name}」移出当前用户组吗?', + 'pages.userGroups.detail.removeMemberError': '移出用户失败', + 'pages.userGroups.detail.user': '用户', + 'pages.userGroups.detail.role': '角色', + 'pages.userGroups.detail.directMember': '直接成员', + 'pages.userGroups.detail.inheritedMember': '继承成员', + 'pages.userGroups.detail.joinedAt': '加入时间', + 'pages.userGroups.detail.remove': '移出', + 'pages.userGroups.detail.userSearchPlaceholder': '搜索用户名、邮箱', + 'pages.userGroups.detail.examId': '考试 ID', + 'pages.userGroups.detail.type': '类型', + 'pages.userGroups.detail.time': '时间', + 'pages.userGroups.detail.syncMode': '同步 {mode}', + 'pages.userGroups.detail.memberCount': '成员 {count}', + 'pages.userGroups.detail.examCount': '考试 {count}', + 'pages.userGroups.detail.basic': '基础信息', + 'pages.userGroups.detail.createdAtMeta': '创建时间 {time}', + 'pages.userGroups.detail.updatedAtMeta': '更新时间 {time}', + 'pages.userGroups.detail.membersLoadError': '加载成员失败', + 'pages.userGroups.detail.addMember': '加入用户', + 'pages.userGroups.detail.exams': '考试', + 'pages.userGroups.detail.examSearchPlaceholder': '搜索考试 ID', + 'pages.userGroups.detail.usersLoadError': '加载用户失败', + // Results + 'pages.results.description': '按考试查看用户提交、总分和题目判分状态。', + 'pages.results.examsLoadError': '加载考试列表失败', + 'pages.results.detailLoadError': '加载结果详情失败', + 'pages.results.fetchError': '加载提交记录失败', + 'pages.results.examPlaceholder': '选择考试', + 'pages.results.selectExamEmpty': '请选择考试', + 'pages.results.detailTitle': '提交详情', + 'pages.results.columns.user': '用户ID', + 'pages.results.columns.status': '状态', + 'pages.results.columns.score': '成绩', + 'pages.results.columns.submittedAt': '提交时间', + // Judge tasks + 'pages.judgeTasks.description': + '查看异步判题任务、重试次数、运行耗时和沙箱摘要。', + 'pages.judgeTasks.fetchError': '加载判题任务失败', + 'pages.judgeTasks.detailLoadError': '加载判题任务详情失败', + 'pages.judgeTasks.detailTitle': '判题任务详情', + 'pages.judgeTasks.columns.submission': '提交ID', + 'pages.judgeTasks.columns.question': '题目ID', + 'pages.judgeTasks.columns.language': '语言', + 'pages.judgeTasks.columns.status': '状态', + 'pages.judgeTasks.columns.retry': '重试', + 'pages.judgeTasks.columns.summary': '摘要', + 'pages.judgeTasks.columns.createdAt': '创建时间', // Forbidden 'pages.forbidden.title': '无权访问后台', 'pages.forbidden.subTitle': diff --git a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx index cef70ad..b385637 100644 --- a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx @@ -1,36 +1,259 @@ -import { PageContainer } from '@ant-design/pro-components'; -import { history, useIntl } from '@umijs/max'; -import { Button, Card, Result } from 'antd'; -import React from 'react'; +import { EyeOutlined } from '@ant-design/icons'; +import { + PageContainer, + type ProColumns, + ProTable, +} from '@ant-design/pro-components'; +import type { + AdminJudgeTask, + AdminJudgeTaskPageResponse, +} from '@examora/types'; +import { useIntl } from '@umijs/max'; +import { + App as AntdApp, + Button, + Descriptions, + Drawer, + Space, + Spin, + Tag, + Typography, +} from 'antd'; +import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +import { fetchEnvelope } from '@/utils/apiEnvelope'; +import { requestErrorMessage } from '@/utils/request'; +import { + judgeTaskDurationLabel, + judgeTaskStatusTone, + summarizeJudgeTask, +} from './model'; -const Index: React.FC = () => { +const { Paragraph } = Typography; +const JUDGE_TASKS_PATH = '/api/v1/judge/tasks'; +const judgeTaskPath = (taskID: number | string) => + `/api/v1/judge/tasks/${taskID}`; + +const JudgeTasksContent: React.FC = () => { const intl = useIntl(); + const { message } = AntdApp.useApp(); + const [tasks, setTasks] = useState([]); + const [taskTotal, setTaskTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [detail, setDetail] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + + const fetchTasks = React.useCallback(async () => { + setLoading(true); + try { + const data = await fetchEnvelope( + `${JUDGE_TASKS_PATH}?page=1&page_size=100`, + ); + setTasks(data.items || []); + setTaskTotal(data.total || 0); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.judgeTasks.fetchError', + defaultMessage: '加载判题任务失败', + }), + ); + } finally { + setLoading(false); + } + }, [intl, message]); + + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + const openDetail = async (record: AdminJudgeTask) => { + setDetailOpen(true); + setDetailLoading(true); + try { + const data = await fetchEnvelope( + judgeTaskPath(record.id), + ); + setDetail(data); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.judgeTasks.detailLoadError', + defaultMessage: '加载判题任务详情失败', + }), + ); + } finally { + setDetailLoading(false); + } + }; + + const columns: ProColumns[] = [ + { title: 'ID', dataIndex: 'id', width: 80, search: false }, + { + title: intl.formatMessage({ + id: 'pages.judgeTasks.columns.submission', + defaultMessage: '提交ID', + }), + dataIndex: 'submission_id', + width: 100, + search: false, + }, + { + title: intl.formatMessage({ + id: 'pages.judgeTasks.columns.question', + defaultMessage: '题目ID', + }), + dataIndex: 'question_id', + width: 100, + search: false, + }, + { + title: intl.formatMessage({ + id: 'pages.judgeTasks.columns.language', + defaultMessage: '语言', + }), + dataIndex: 'language', + width: 110, + search: false, + }, + { + title: intl.formatMessage({ + id: 'pages.judgeTasks.columns.status', + defaultMessage: '状态', + }), + dataIndex: 'status', + width: 150, + search: false, + render: (_, record) => ( + {record.status} + ), + }, + { + title: intl.formatMessage({ + id: 'pages.judgeTasks.columns.retry', + defaultMessage: '重试', + }), + dataIndex: 'retry_count', + width: 90, + search: false, + render: (_, record) => `${record.retry_count}/${record.max_retry_count}`, + }, + { + title: intl.formatMessage({ + id: 'pages.judgeTasks.columns.summary', + defaultMessage: '摘要', + }), + search: false, + ellipsis: true, + render: (_, record) => summarizeJudgeTask(record), + }, + { + title: intl.formatMessage({ + id: 'pages.judgeTasks.columns.createdAt', + defaultMessage: '创建时间', + }), + dataIndex: 'created_at', + width: 180, + search: false, + render: (_, record) => + dayjs(record.created_at).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: intl.formatMessage({ + id: 'common.actions', + defaultMessage: '操作', + }), + valueType: 'option', + width: 100, + render: (_, record) => [ + , + ], + }, + ]; return ( - - history.push('/overview/dashboard')} - > - {intl.formatMessage({ id: 'pages.comingSoon.backDashboard' })} - - } - /> - + + rowKey="id" + columns={columns} + dataSource={tasks} + loading={loading} + pagination={{ total: taskTotal, pageSize: 20 }} + search={false} + options={{ reload: fetchTasks, density: true, setting: true }} + cardBordered={{ table: true }} + columnEmptyText="-" + /> + setDetailOpen(false)} + > + + {detail && ( + + + {detail.id} + + {detail.submission_id} + + + {detail.question_id} + + + {detail.user_id} + + + {detail.language} + + + + {detail.status} + + + + {detail.retry_count}/{detail.max_retry_count} + + + {judgeTaskDurationLabel(detail)} + + + {summarizeJudgeTask(detail)} + + )} + + ); }; -export default Index; +const JudgeTasks: React.FC = () => ( + + + +); + +export default JudgeTasks; diff --git a/apps/admin/src/pages/Assessment/Results/JudgeTasks/model.test.ts b/apps/admin/src/pages/Assessment/Results/JudgeTasks/model.test.ts new file mode 100644 index 0000000..675f145 --- /dev/null +++ b/apps/admin/src/pages/Assessment/Results/JudgeTasks/model.test.ts @@ -0,0 +1,39 @@ +import { + judgeTaskDurationLabel, + judgeTaskStatusTone, + summarizeJudgeTask, +} from './model'; + +describe('judge task model', () => { + test('summarizes failed tasks with error messages first', () => { + const task = { + error_message: 'sandbox unavailable', + result_summary: { passed: 2, total: 5 }, + }; + + expect(summarizeJudgeTask(task)).toBe('sandbox unavailable'); + }); + + test('summarizes result summary when no error exists', () => { + const task = { + result_summary: { passed: 2, total: 5, score: 40 }, + }; + + expect(summarizeJudgeTask(task)).toBe('passed: 2, total: 5, score: 40'); + }); + + test('calculates task duration labels', () => { + const task = { + started_at: '2026-01-01T00:00:00Z', + finished_at: '2026-01-01T00:00:03Z', + }; + + expect(judgeTaskDurationLabel(task)).toBe('3s'); + }); + + test('maps judge statuses to table tones', () => { + expect(judgeTaskStatusTone('ACCEPTED')).toBe('success'); + expect(judgeTaskStatusTone('RUNNING')).toBe('processing'); + expect(judgeTaskStatusTone('SYSTEM_ERROR')).toBe('error'); + }); +}); diff --git a/apps/admin/src/pages/Assessment/Results/JudgeTasks/model.ts b/apps/admin/src/pages/Assessment/Results/JudgeTasks/model.ts new file mode 100644 index 0000000..50acf23 --- /dev/null +++ b/apps/admin/src/pages/Assessment/Results/JudgeTasks/model.ts @@ -0,0 +1,43 @@ +import type { AdminJudgeTask, JudgeStatus } from '@examora/types'; + +export const summarizeJudgeTask = ( + task: Pick, +) => { + if (task.error_message) return task.error_message; + if (!task.result_summary) return '-'; + const parts = Object.entries(task.result_summary).map( + ([key, value]) => `${key}: ${String(value)}`, + ); + return parts.length > 0 ? parts.join(', ') : '-'; +}; + +export const judgeTaskDurationLabel = ( + task: Pick, +) => { + if (!task.started_at || !task.finished_at) return '-'; + const durationMS = + new Date(task.finished_at).getTime() - new Date(task.started_at).getTime(); + if (!Number.isFinite(durationMS) || durationMS < 0) return '-'; + return `${Math.round(durationMS / 1000)}s`; +}; + +export const judgeTaskStatusTone = ( + status: JudgeStatus, +): 'success' | 'processing' | 'warning' | 'error' | 'default' => { + if (status === 'ACCEPTED') return 'success'; + if (status === 'RUNNING' || status === 'QUEUED' || status === 'PENDING') { + return 'processing'; + } + if (status === 'CANCELED') return 'warning'; + if ( + status === 'WRONG_ANSWER' || + status === 'COMPILE_ERROR' || + status === 'RUNTIME_ERROR' || + status === 'TIME_LIMIT_EXCEEDED' || + status === 'MEMORY_LIMIT_EXCEEDED' || + status === 'SYSTEM_ERROR' + ) { + return 'error'; + } + return 'default'; +}; diff --git a/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx b/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx index 4cd3883..02b686c 100644 --- a/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx @@ -1,36 +1,329 @@ -import { PageContainer } from '@ant-design/pro-components'; -import { history, useIntl } from '@umijs/max'; -import { Button, Card, Result } from 'antd'; -import React from 'react'; +import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'; +import { + PageContainer, + type ProColumns, + ProTable, +} from '@ant-design/pro-components'; +import type { + AdminExam, + AdminExamPageResponse, + AdminExamResult, + AdminExamResultPageResponse, + AdminQuestionResult, +} from '@examora/types'; +import { API_PATHS } from '@examora/types'; +import { request, useIntl } from '@umijs/max'; +import { + App as AntdApp, + Button, + Descriptions, + Drawer, + Empty, + Progress, + Select, + Space, + Spin, + Table, + Tag, + Typography, +} from 'antd'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { fetchEnvelope } from '@/utils/apiEnvelope'; +import { requestErrorMessage } from '@/utils/request'; +import { + formatScore, + isResultPending, + resultProgressPercent, + resultStatusTone, +} from './model'; -const Index: React.FC = () => { +const { Text } = Typography; +const examResultsPath = (examID: number | string) => + `/api/v1/exams/${examID}/results`; +const examResultPath = (resultID: number | string) => + `/api/v1/exam-results/${resultID}`; + +const SubmissionsContent: React.FC = () => { const intl = useIntl(); + const { message } = AntdApp.useApp(); + const [exams, setExams] = useState([]); + const [examID, setExamID] = useState(); + const [results, setResults] = useState([]); + const [resultTotal, setResultTotal] = useState(0); + const [resultsLoading, setResultsLoading] = useState(false); + const [detail, setDetail] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + + useEffect(() => { + request<{ code: number; data: AdminExamPageResponse }>( + API_PATHS.admin.exams, + { + skipErrorHandler: true, + params: { page: 1, page_size: 100 }, + }, + ) + .then((response) => { + const items = response.data?.items || []; + setExams(items); + setExamID((current) => current ?? items[0]?.id); + }) + .catch((error) => + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.results.examsLoadError', + defaultMessage: '加载考试列表失败', + }), + ), + ); + }, [intl, message]); + + const examOptions = useMemo( + () => exams.map((exam) => ({ label: exam.title, value: exam.id })), + [exams], + ); + + const fetchResults = React.useCallback(async () => { + if (!examID) return; + setResultsLoading(true); + try { + const data = await fetchEnvelope( + `${examResultsPath(examID)}?page=1&page_size=100`, + ); + setResults(data.items || []); + setResultTotal(data.total || 0); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.results.fetchError', + defaultMessage: '加载提交记录失败', + }), + ); + } finally { + setResultsLoading(false); + } + }, [examID, intl, message]); + + useEffect(() => { + fetchResults(); + }, [fetchResults]); + + const openDetail = async (record: AdminExamResult) => { + setDetailOpen(true); + setDetailLoading(true); + try { + const data = await fetchEnvelope( + examResultPath(record.id), + ); + setDetail(data); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.results.detailLoadError', + defaultMessage: '加载结果详情失败', + }), + ); + } finally { + setDetailLoading(false); + } + }; + + const columns: ProColumns[] = [ + { + title: 'ID', + dataIndex: 'id', + width: 80, + search: false, + }, + { + title: intl.formatMessage({ + id: 'pages.results.columns.user', + defaultMessage: '考生ID', + }), + dataIndex: 'user_id', + width: 100, + search: false, + }, + { + title: intl.formatMessage({ + id: 'pages.results.columns.status', + defaultMessage: '状态', + }), + dataIndex: 'status', + width: 140, + search: false, + render: (_, record) => ( + {record.status} + ), + }, + { + title: intl.formatMessage({ + id: 'pages.results.columns.score', + defaultMessage: '成绩', + }), + dataIndex: 'score', + search: false, + render: (_, record) => ( + + {formatScore(record.score, record.max_score)} + + + ), + }, + { + title: intl.formatMessage({ + id: 'pages.results.columns.submittedAt', + defaultMessage: '提交时间', + }), + dataIndex: 'submitted_at', + width: 180, + search: false, + render: (_, record) => + record.submitted_at + ? dayjs(record.submitted_at).format('YYYY-MM-DD HH:mm:ss') + : '-', + }, + { + title: intl.formatMessage({ + id: 'common.actions', + defaultMessage: '操作', + }), + valueType: 'option', + width: 100, + render: (_, record) => [ + , + ], + }, + ]; + + const questionRows: AdminQuestionResult[] = useMemo(() => { + if (!detail) return []; + if (detail.sections?.length) { + return detail.sections.flatMap((section) => section.questions || []); + } + return detail.questions || []; + }, [detail]); return ( - - history.push('/overview/dashboard')} - > - {intl.formatMessage({ id: 'pages.comingSoon.backDashboard' })} - - } - /> - + + rowKey="id" + columns={columns} + dataSource={results} + loading={resultsLoading} + pagination={{ total: resultTotal, pageSize: 20 }} + search={false} + options={{ reload: fetchResults, density: true, setting: true }} + cardBordered={{ table: true }} + columnEmptyText="-" + headerTitle={ + + + ) : ( +
+ { + const keys = Array.isArray(checked) + ? checked + : checked.checked; + const ids = keys.map((key) => Number(key)); + setSelectedGroupIDs(ids); + updateGroupCoverage(ids).catch(() => + setGroupCoverageCount(0), + ); + }} + /> + + {intl.formatMessage( + { + id: 'pages.examDetail.groupCoverage', + defaultMessage: '预计覆盖 {count} 名用户', + }, + { count: groupCoverageCount }, + )} + +
+ )} + + + setResultDetailOpen(false)} + > + + {resultDetail && ( + + + + {resultDetail.id} + + + {resultDetail.user_id} + + + + {resultDetail.status} + + + + {formatScore(resultDetail.score, resultDetail.max_score)} + + + + rowKey="id" + size="small" + pagination={false} + dataSource={questionRows} + columns={[ + { title: 'Question', dataIndex: 'question_id', width: 100 }, + { title: 'Type', dataIndex: 'type', width: 140 }, + { + title: 'Status', + dataIndex: 'status', + render: (status) => {status}, + }, + { + title: 'Score', + render: (_, row) => formatScore(row.score, row.max_score), + }, + { + title: 'Result', + render: (_, row) => + row.result ? ( + {JSON.stringify(row.result)} + ) : ( + '-' + ), + }, + ]} + /> + + )} + + + setEventDetail(null)} + > + {eventDetail && ( + + {eventDetail.id} + + {eventDetail.user_id} + + + {eventDetail.device_id || '-'} + + + {eventDetail.event_type} + + + {JSON.stringify(eventDetail.payload)} + + + )} + +
+ ); +}; + +const ExamDetail: React.FC = () => ( + + + +); + +export default ExamDetail; diff --git a/apps/admin/src/pages/Examination/ExamDetail/model.test.ts b/apps/admin/src/pages/Examination/ExamDetail/model.test.ts new file mode 100644 index 0000000..1f79114 --- /dev/null +++ b/apps/admin/src/pages/Examination/ExamDetail/model.test.ts @@ -0,0 +1,27 @@ +import type { AdminExamSession } from '@examora/types'; +import { canRemoveCandidate, examStatusTone, sessionStatusTone } from './model'; + +describe('ExamDetail model', () => { + it('maps session statuses to tag tones', () => { + expect(sessionStatusTone('NOT_STARTED')).toBe('default'); + expect(sessionStatusTone('IN_PROGRESS')).toBe('processing'); + expect(sessionStatusTone('SUBMITTED')).toBe('success'); + expect(sessionStatusTone('EXPIRED')).toBe('warning'); + }); + + it('maps exam statuses to tag tones', () => { + expect(examStatusTone('DRAFT')).toBe('default'); + expect(examStatusTone('PUBLISHED')).toBe('processing'); + expect(examStatusTone('RUNNING')).toBe('success'); + expect(examStatusTone('CLOSED')).toBe('warning'); + }); + + it('only allows removing candidates before they start', () => { + expect( + canRemoveCandidate({ status: 'NOT_STARTED' } as AdminExamSession), + ).toBe(true); + expect( + canRemoveCandidate({ status: 'IN_PROGRESS' } as AdminExamSession), + ).toBe(false); + }); +}); diff --git a/apps/admin/src/pages/Examination/ExamDetail/model.ts b/apps/admin/src/pages/Examination/ExamDetail/model.ts new file mode 100644 index 0000000..8ccaf73 --- /dev/null +++ b/apps/admin/src/pages/Examination/ExamDetail/model.ts @@ -0,0 +1,52 @@ +import type { + AdminExamSession, + ExamSessionStatus, + ExamStatus, +} from '@examora/types'; + +export const examDetailPath = (examID: number | string) => + `/api/v1/exams/${examID}`; + +export const examSessionsPath = (examID: number | string) => + `/api/v1/exams/${examID}/sessions`; + +export const examCandidatesPath = (examID: number | string) => + `/api/v1/exams/${examID}/candidates`; + +export const examCandidatePath = ( + examID: number | string, + userID: number | string, +) => `/api/v1/exams/${examID}/candidates/${userID}`; + +export const examAssignmentsPath = (examID: number | string) => + `/api/v1/exams/${examID}/assignments`; + +export const examEventsPath = (examID: number | string) => + `/api/v1/exams/${examID}/events`; + +export const examResultsPath = (examID: number | string) => + `/api/v1/exams/${examID}/results`; + +export const examResultPath = (resultID: number | string) => + `/api/v1/exam-results/${resultID}`; + +export const sessionStatusTone = ( + status: ExamSessionStatus, +): 'default' | 'processing' | 'success' | 'warning' => { + if (status === 'IN_PROGRESS') return 'processing'; + if (status === 'SUBMITTED') return 'success'; + if (status === 'EXPIRED') return 'warning'; + return 'default'; +}; + +export const examStatusTone = ( + status: ExamStatus, +): 'default' | 'processing' | 'success' | 'warning' => { + if (status === 'PUBLISHED') return 'processing'; + if (status === 'RUNNING') return 'success'; + if (status === 'CLOSED' || status === 'ARCHIVED') return 'warning'; + return 'default'; +}; + +export const canRemoveCandidate = (session: Pick) => + session.status === 'NOT_STARTED'; diff --git a/apps/admin/src/pages/Examination/ExamForm/index.tsx b/apps/admin/src/pages/Examination/ExamForm/index.tsx new file mode 100644 index 0000000..1be1502 --- /dev/null +++ b/apps/admin/src/pages/Examination/ExamForm/index.tsx @@ -0,0 +1,291 @@ +import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons'; +import { PageContainer } from '@ant-design/pro-components'; +import type { + AdminExam, + AdminPaper, + AdminPaperPageResponse, +} from '@examora/types'; +import { API_PATHS } from '@examora/types'; +import { history, request, useIntl } from '@umijs/max'; +import { + Alert, + App as AntdApp, + Button, + Card, + Form, + Input, + InputNumber, + Select, + Space, + Spin, + Tag, +} from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; +import { requestErrorMessage } from '@/utils/request'; +import { + buildExamPayload, + canEditExam, + type ExamFormValues, + paperOptionLabel, +} from './model'; + +const ExamFormContent: React.FC = () => { + const intl = useIntl(); + const { message } = AntdApp.useApp(); + const [form] = Form.useForm(); + const [exam, setExam] = useState(null); + const [papers, setPapers] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const pathParts = window.location.pathname.split('/'); + const isEdit = pathParts[pathParts.length - 1] === 'edit'; + const examId = isEdit ? pathParts[pathParts.length - 2] : undefined; + + const title = intl.formatMessage({ + id: isEdit ? 'pages.exams.editTitle' : 'pages.exams.createTitle', + defaultMessage: isEdit ? '编辑考试' : '创建考试', + }); + + const paperOptions = useMemo( + () => + papers.map((paper) => ({ + label: paperOptionLabel(paper), + value: paper.id, + })), + [papers], + ); + + useEffect(() => { + let mounted = true; + setLoading(true); + const papersRequest = request<{ + code: number; + data: AdminPaperPageResponse; + }>(API_PATHS.admin.papers, { + method: 'GET', + skipErrorHandler: true, + params: { page: 1, page_size: 100 }, + }); + const examRequest = examId + ? request<{ code: number; data: AdminExam }>( + API_PATHS.admin.exam(examId), + { + method: 'GET', + skipErrorHandler: true, + }, + ) + : Promise.resolve(null); + + Promise.all([papersRequest, examRequest]) + .then(([paperResponse, examResponse]) => { + if (!mounted) return; + setPapers(paperResponse.data?.items || []); + const loadedExam = examResponse?.data; + if (loadedExam) { + setExam(loadedExam); + form.setFieldsValue({ + title: loadedExam.title, + description: loadedExam.description, + paper_id: loadedExam.paper_id || undefined, + duration_minutes: loadedExam.duration_minutes || 60, + }); + } else { + form.setFieldsValue({ duration_minutes: 60 }); + } + }) + .catch((error) => + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.exams.form.loadError', + defaultMessage: '加载考试配置失败', + }), + ), + ) + .finally(() => { + if (mounted) setLoading(false); + }); + + return () => { + mounted = false; + }; + }, [examId, form, intl, message]); + + const handleSubmit = async () => { + const values = await form.validateFields(); + const payload = buildExamPayload(values); + setSaving(true); + try { + await request( + examId ? API_PATHS.admin.exam(examId) : API_PATHS.admin.exams, + { + method: examId ? 'PUT' : 'POST', + data: payload, + skipErrorHandler: true, + }, + ); + message.success( + intl.formatMessage({ + id: 'pages.exams.form.saveSuccess', + defaultMessage: '考试已保存', + }), + ); + history.push('/examination/exams'); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.exams.form.saveError', + defaultMessage: '保存考试失败', + }), + ); + } finally { + setSaving(false); + } + }; + + const editable = canEditExam(exam); + + return ( + history.push('/examination/exams')} + content={intl.formatMessage({ + id: 'pages.exams.form.description', + defaultMessage: '配置考试基本信息并绑定试卷,保存后可在考试列表发布。', + })} + extra={ + exam ? ( + {exam.status} + ) : null + } + > + + + + form={form} + layout="vertical" + disabled={!editable} + onFinish={handleSubmit} + > + {!editable && ( + + )} + + + + + + + + + +
+ + + + +
+ + + + + {selectedRows.length > 0 && ( , +) => { + const search = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + search.set(key, String(value)); + } + }); + return search.toString(); +}; + +const UserGroupDetailContent: React.FC = () => { + const intl = useIntl(); + const groupID = Number( + window.location.pathname.split('/').filter(Boolean).at(-1), + ); + const { message, modal } = AntdApp.useApp(); + const memberActionRef = useRef(null); + const userActionRef = useRef(null); + const [form] = Form.useForm(); + const [group, setGroup] = useState(null); + const [assignments, setAssignments] = useState([]); + const [saving, setSaving] = useState(false); + const [basicDirty, setBasicDirty] = useState(false); + const [memberKeywordInput, setMemberKeywordInput] = useState(''); + const [memberKeyword, setMemberKeyword] = useState(''); + const [assignmentKeywordInput, setAssignmentKeywordInput] = useState(''); + const [assignmentKeyword, setAssignmentKeyword] = useState(''); + const [memberModalOpen, setMemberModalOpen] = useState(false); + const [selectedUserIDs, setSelectedUserIDs] = useState([]); + + const loadGroup = React.useCallback(async () => { + try { + const data = await fetchEnvelope( + API_PATHS.admin.userGroup(groupID), + ); + setGroup(data); + form.setFieldsValue({ + name: data.name, + description: data.description, + }); + setBasicDirty(false); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.loadError', + defaultMessage: '加载用户组失败', + }), + ); + } + }, [form, groupID, intl, message]); + + const loadAssignments = React.useCallback(async () => { + try { + const data = await fetchEnvelope<{ items: AdminExamAssignment[] }>( + API_PATHS.admin.userGroupExamAssignments(groupID), + ); + setAssignments(data.items || []); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.detail.examLoadError', + defaultMessage: '加载考试失败', + }), + ); + } + }, [groupID, intl, message]); + + useEffect(() => { + if (!Number.isFinite(groupID)) return; + loadGroup(); + loadAssignments(); + }, [groupID, loadAssignments, loadGroup]); + + useEffect(() => { + memberActionRef.current?.reloadAndRest?.(); + }, [memberKeyword]); + + const searchMembers = () => { + const nextKeyword = memberKeywordInput.trim(); + if (nextKeyword === memberKeyword) { + memberActionRef.current?.reloadAndRest?.(); + return; + } + setMemberKeyword(nextKeyword); + }; + + const searchAssignments = () => { + setAssignmentKeyword(assignmentKeywordInput.trim()); + }; + + const saveGroup = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await fetchEnvelope(API_PATHS.admin.userGroup(groupID), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: values.name, + description: values.description || '', + }), + }); + message.success( + intl.formatMessage({ + id: 'pages.userGroups.saveSuccess', + defaultMessage: '用户组已保存', + }), + ); + await loadGroup(); + setBasicDirty(false); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.saveError', + defaultMessage: '保存用户组失败', + }), + ); + } finally { + setSaving(false); + } + }; + + const addMembers = async () => { + if (selectedUserIDs.length === 0) return; + setSaving(true); + try { + await fetchEnvelope(API_PATHS.admin.userGroupMembers(groupID), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: selectedUserIDs }), + }); + message.success( + intl.formatMessage({ + id: 'pages.userGroups.detail.addMemberSuccess', + defaultMessage: '用户已加入用户组', + }), + ); + setMemberModalOpen(false); + setSelectedUserIDs([]); + memberActionRef.current?.reload(); + await loadGroup(); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.detail.addMemberError', + defaultMessage: '添加用户失败', + }), + ); + } finally { + setSaving(false); + } + }; + + const removeMember = (user: AdminUser) => { + modal.confirm({ + title: intl.formatMessage({ + id: 'pages.userGroups.detail.removeMemberTitle', + defaultMessage: '移出用户', + }), + content: intl.formatMessage( + { + id: 'pages.userGroups.detail.removeMemberContent', + defaultMessage: '确定将「{name}」移出当前用户组吗?', + }, + { name: user.display_name || user.username }, + ), + okType: 'danger', + onOk: async () => { + try { + await fetchEnvelope( + API_PATHS.admin.userGroupMember(groupID, user.id), + { + method: 'DELETE', + }, + ); + memberActionRef.current?.reload(); + await loadGroup(); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.detail.removeMemberError', + defaultMessage: '移出用户失败', + }), + ); + return Promise.resolve(); + } + }, + }); + }; + + const memberColumns: ProColumns[] = [ + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.user', + defaultMessage: '用户', + }), + dataIndex: ['user', 'username'], + width: 300, + search: false, + render: (_, item) => ( +
+
+ {(item.user.display_name || item.user.username) + .charAt(0) + .toUpperCase()} +
+
+ + {item.user.display_name || item.user.username} + + + {item.user.email || item.user.username} + +
+
+ ), + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.role', + defaultMessage: '角色', + }), + dataIndex: ['user', 'role'], + width: 110, + search: false, + render: (_, item) => {item.user.role}, + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.source', + defaultMessage: '来源', + }), + dataIndex: 'direct', + width: 140, + search: false, + render: (_, item) => + item.direct ? ( + + {intl.formatMessage({ + id: 'pages.userGroups.detail.directMember', + defaultMessage: '直接成员', + })} + + ) : ( + + {intl.formatMessage({ + id: 'pages.userGroups.detail.inheritedMember', + defaultMessage: '继承成员', + })} + + ), + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.joinedAt', + defaultMessage: '加入时间', + }), + dataIndex: 'created_at', + width: 170, + search: false, + render: (_, item) => dayjs(item.created_at).format('YYYY-MM-DD HH:mm'), + }, + { + title: intl.formatMessage({ + id: 'common.actions', + defaultMessage: '操作', + }), + width: 90, + fixed: 'right', + search: false, + hideInSetting: true, + render: (_, item) => + item.direct ? ( + + ) : ( + '-' + ), + }, + ]; + + const userColumns: ProColumns[] = [ + { + title: intl.formatMessage({ + id: 'pages.userGroups.keyword', + defaultMessage: '关键词', + }), + dataIndex: 'keyword', + hideInTable: true, + fieldProps: { + allowClear: true, + placeholder: intl.formatMessage({ + id: 'pages.userGroups.detail.userSearchPlaceholder', + defaultMessage: '搜索用户名、邮箱', + }), + }, + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.user', + defaultMessage: '用户', + }), + dataIndex: 'username', + search: false, + render: (_, user) => ( +
+ {user.display_name || user.username} + {user.email || user.username} +
+ ), + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.role', + defaultMessage: '角色', + }), + dataIndex: 'role', + width: 120, + search: false, + render: (_, user) => {user.role}, + }, + ]; + + const assignmentColumns: ProColumns[] = [ + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.examId', + defaultMessage: '考试 ID', + }), + dataIndex: 'exam_id', + width: 120, + render: (_, item) => ( + + ), + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.type', + defaultMessage: '类型', + }), + dataIndex: 'target_type', + render: () => ( + + {intl.formatMessage({ + id: 'pages.userGroups.name', + defaultMessage: '用户组', + })} + + ), + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.detail.time', + defaultMessage: '时间', + }), + dataIndex: 'created_at', + render: (_, item) => dayjs(item.created_at).format('YYYY-MM-DD HH:mm'), + }, + ]; + + const filteredAssignments = assignmentKeyword + ? assignments.filter((item) => + String(item.exam_id).includes(assignmentKeyword), + ) + : assignments; + + return ( + history.push('/system/settings/user-groups')} + content={ + group ? ( +
+ + {group.source} + + {group.source !== 'LOCAL' && ( + + {intl.formatMessage( + { + id: 'pages.userGroups.detail.syncMode', + defaultMessage: '同步 {mode}', + }, + { mode: group.sync_mode }, + )} + + )} + + {intl.formatMessage( + { + id: 'pages.userGroups.detail.memberCount', + defaultMessage: '成员 {count}', + }, + { count: group.member_count || 0 }, + )} + + + {intl.formatMessage( + { + id: 'pages.userGroups.detail.examCount', + defaultMessage: '考试 {count}', + }, + { count: assignments.length }, + )} + +
+ ) : null + } + extra={[ + , + ]} + > + + {basicDirty && ( +
+ +
+ )} +
setBasicDirty(true)} + > + + + + + + +
+
+ + {intl.formatMessage( + { + id: 'pages.userGroups.detail.createdAtMeta', + defaultMessage: '创建时间 {time}', + }, + { + time: dayjs(group.created_at).format( + 'YYYY-MM-DD HH:mm', + ), + }, + )} + + + {intl.formatMessage( + { + id: 'pages.userGroups.detail.updatedAtMeta', + defaultMessage: '更新时间 {time}', + }, + { + time: dayjs(group.updated_at).format( + 'YYYY-MM-DD HH:mm', + ), + }, + )} + +
+ + ) : null, + }, + { + key: 'members', + label: intl.formatMessage({ + id: 'pages.userGroups.members', + defaultMessage: '成员', + }), + children: ( + + className="user-group-detail-table" + actionRef={memberActionRef} + columns={memberColumns} + rowKey={(item) => `${item.user_group_id}-${item.user.id}`} + request={async ({ current, pageSize }) => { + const params = queryString({ + page: current || 1, + page_size: pageSize || 20, + keyword: memberKeyword || undefined, + }); + try { + const data = await fetchEnvelope<{ + items: AdminUserGroupMember[]; + total: number; + }>( + `${API_PATHS.admin.userGroupMembers(groupID)}?${params}`, + ); + return { + data: data.items || [], + total: data.total || 0, + success: true, + }; + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.detail.membersLoadError', + defaultMessage: '加载成员失败', + }), + ); + return { data: [], total: 0, success: false }; + } + }} + search={false} + pagination={{ defaultPageSize: 20, showSizeChanger: true }} + options={{ density: true, reload: true, setting: true }} + scroll={{ x: 900 }} + headerTitle={ +
+ } + placeholder={intl.formatMessage({ + id: 'pages.userGroups.detail.userSearchPlaceholder', + defaultMessage: '搜索用户名、邮箱', + })} + value={memberKeywordInput} + onChange={(event) => { + const value = event.target.value; + setMemberKeywordInput(value); + if (!value) setMemberKeyword(''); + }} + onPressEnter={searchMembers} + /> + +
+ } + toolBarRender={() => [ + , + ]} + /> + ), + }, + { + key: 'assignments', + label: intl.formatMessage({ + id: 'pages.userGroups.detail.exams', + defaultMessage: '考试', + }), + children: ( + + className="user-group-detail-table" + columns={assignmentColumns} + dataSource={filteredAssignments} + rowKey="id" + search={false} + pagination={{ defaultPageSize: 10 }} + options={{ reload: loadAssignments, setting: true }} + headerTitle={ +
+ } + placeholder={intl.formatMessage({ + id: 'pages.userGroups.detail.examSearchPlaceholder', + defaultMessage: '搜索考试 ID', + })} + value={assignmentKeywordInput} + onChange={(event) => { + const value = event.target.value; + setAssignmentKeywordInput(value); + if (!value) setAssignmentKeyword(''); + }} + onPressEnter={searchAssignments} + /> + +
+ } + /> + ), + }, + ]} + /> + + setMemberModalOpen(false)} + > + + actionRef={userActionRef} + columns={userColumns} + rowKey="id" + search={{ labelWidth: 'auto', defaultCollapsed: false }} + options={false} + pagination={{ defaultPageSize: 10, showSizeChanger: true }} + rowSelection={{ + selectedRowKeys: selectedUserIDs, + preserveSelectedRowKeys: true, + onChange: (keys) => setSelectedUserIDs(keys.map(Number)), + }} + request={async ({ current, pageSize, keyword }) => { + const params = queryString({ + page: current || 1, + page_size: pageSize || 10, + keyword: keyword ? String(keyword).trim() : undefined, + exclude_user_group_id: groupID, + }); + try { + const data = await fetchEnvelope<{ + items: AdminUser[]; + total: number; + }>(`${API_PATHS.admin.users}?${params}`); + return { + data: data.items || [], + total: data.total || 0, + success: true, + }; + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.detail.usersLoadError', + defaultMessage: '加载用户失败', + }), + ); + return { data: [], total: 0, success: false }; + } + }} + tableAlertRender={false} + /> + +
+ ); +}; + +const UserGroupDetail: React.FC = () => ( + + + +); + +export default UserGroupDetail; diff --git a/apps/admin/src/pages/System/Settings/UserGroups/index.less b/apps/admin/src/pages/System/Settings/UserGroups/index.less new file mode 100644 index 0000000..eaacc5c --- /dev/null +++ b/apps/admin/src/pages/System/Settings/UserGroups/index.less @@ -0,0 +1,104 @@ +.user-group-name-cell { + display: flex; + align-items: center; + min-width: 0; +} + +.user-group-name-main { + display: flex; + flex-direction: column; + min-width: 0; + gap: 3px; +} + +.user-group-name-link { + height: auto; + padding: 0; + font-weight: 600; + line-height: 20px; +} + +.user-group-actions-cell { + display: inline-flex; + align-items: center; + justify-content: flex-start; + min-width: 64px; + text-align: left; +} + +.user-group-actions-cell a { + color: var(--examora-text); + transition: color 150ms ease; +} + +.user-group-actions-cell a:hover { + color: #1677ff; +} + +.user-group-expand-button { + width: 24px; + height: 24px; + padding: 0; + color: var(--examora-text-secondary, #6b7280); + border: 0; + box-shadow: none; +} + +.user-group-expand-placeholder { + display: inline-block; + width: 24px; +} + +.user-group-detail-tabs { + padding: 0 16px 16px; + background: var(--examora-bg-container, #fff); + border-radius: 8px; +} + +.user-group-detail-tabs .ant-tabs-content-holder { + padding-top: 0; +} + +.user-group-detail-table .ant-pro-table-list-toolbar { + padding-block: 4px 8px; +} + +.user-group-toolbar-search { + display: flex; + align-items: center; + gap: 8px; + width: min(420px, 100%); + max-width: 100%; +} + +.user-group-toolbar-search .ant-input-affix-wrapper { + flex: 1; + min-width: 220px; +} + +.user-group-header-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.user-group-basic { + position: relative; + padding-top: 4px; +} + +.user-group-basic-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.user-group-basic-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + padding-top: 4px; + font-size: 13px; +} diff --git a/apps/admin/src/pages/System/Settings/UserGroups/index.tsx b/apps/admin/src/pages/System/Settings/UserGroups/index.tsx new file mode 100644 index 0000000..ce25c96 --- /dev/null +++ b/apps/admin/src/pages/System/Settings/UserGroups/index.tsx @@ -0,0 +1,455 @@ +import { DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons'; +import { + type ActionType, + PageContainer, + type ProColumns, + ProTable, +} from '@ant-design/pro-components'; +import type { AdminUserGroup } from '@examora/types'; +import { API_PATHS } from '@examora/types'; +import { history, useIntl } from '@umijs/max'; +import { + App as AntdApp, + Button, + Dropdown, + Form, + Input, + Modal, + Space, + Tag, +} from 'antd'; +import dayjs from 'dayjs'; +import React, { useRef, useState } from 'react'; +import { fetchEnvelope } from '@/utils/apiEnvelope'; +import { requestErrorMessage } from '@/utils/request'; +import './index.less'; + +interface GroupFormValues { + name: string; + description?: string; +} + +const sourceColor = (source?: string) => { + if (!source || source === 'LOCAL') return 'default'; + if (source === 'LOGTO') return 'blue'; + if (source === 'SCIM') return 'purple'; + return 'cyan'; +}; + +const UserGroupsContent: React.FC = () => { + const intl = useIntl(); + const { message, modal } = AntdApp.useApp(); + const actionRef = useRef(null); + const [form] = Form.useForm(); + const [modalOpen, setModalOpen] = useState(false); + const [saving, setSaving] = useState(false); + + const openCreate = () => { + form.resetFields(); + setModalOpen(true); + }; + + const saveGroup = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await fetchEnvelope(API_PATHS.admin.userGroups, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: values.name, + description: values.description || '', + }), + }); + message.success( + intl.formatMessage({ + id: 'pages.userGroups.saveSuccess', + defaultMessage: '用户组已保存', + }), + ); + setModalOpen(false); + actionRef.current?.reload(); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.saveError', + defaultMessage: '保存用户组失败', + }), + ); + } finally { + setSaving(false); + } + }; + + const deleteGroup = (group: AdminUserGroup) => { + modal.confirm({ + title: intl.formatMessage({ + id: 'pages.userGroups.deleteTitle', + defaultMessage: '删除用户组', + }), + content: intl.formatMessage( + { + id: 'pages.userGroups.deleteContent', + defaultMessage: '确定删除「{name}」吗?', + }, + { name: group.name }, + ), + okType: 'danger', + onOk: async () => { + try { + await fetchEnvelope(API_PATHS.admin.userGroup(group.id), { + method: 'DELETE', + }); + message.success( + intl.formatMessage({ + id: 'pages.userGroups.deleteSuccess', + defaultMessage: '用户组已删除', + }), + ); + actionRef.current?.reload(); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.deleteError', + defaultMessage: '删除用户组失败', + }), + ); + return Promise.resolve(); + } + }, + }); + }; + + const columns: ProColumns[] = [ + { + title: intl.formatMessage({ + id: 'pages.userGroups.keyword', + defaultMessage: '关键词', + }), + dataIndex: 'keyword', + hideInTable: true, + fieldProps: { + allowClear: true, + placeholder: intl.formatMessage({ + id: 'pages.userGroups.searchPlaceholder', + defaultMessage: '搜索名称、说明', + }), + }, + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.source', + defaultMessage: '来源', + }), + dataIndex: 'source', + hideInTable: true, + valueEnum: { + LOCAL: { + text: intl.formatMessage({ + id: 'pages.userGroups.sourceLocal', + defaultMessage: '本地', + }), + }, + LOGTO: { text: 'Logto' }, + OIDC: { text: 'OIDC' }, + SCIM: { text: 'SCIM' }, + }, + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.name', + defaultMessage: '用户组', + }), + dataIndex: 'name', + width: 320, + search: false, + render: (_, group) => ( +
+ +
+ ), + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.members', + defaultMessage: '成员', + }), + dataIndex: 'member_count', + width: 90, + search: false, + render: (_, group) => group.member_count || 0, + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.source', + defaultMessage: '来源', + }), + dataIndex: 'source', + width: 110, + search: false, + render: (_, group) => ( + {group.source} + ), + }, + { + title: intl.formatMessage({ + id: 'pages.userGroups.createdAt', + defaultMessage: '创建时间', + }), + dataIndex: 'created_at', + width: 160, + search: false, + render: (_, group) => dayjs(group.created_at).format('YYYY-MM-DD HH:mm'), + }, + { + title: intl.formatMessage({ + id: 'common.actions', + defaultMessage: '操作', + }), + width: 80, + fixed: 'right', + search: false, + hideInSetting: true, + render: (_, group) => ( +
e.stopPropagation()} + > + , + danger: true, + disabled: group.source !== 'LOCAL', + onClick: () => deleteGroup(group), + }, + ], + }} + trigger={['click']} + > + e.preventDefault()}> + + {intl.formatMessage({ + id: 'pages.userGroups.more', + defaultMessage: '更多', + })} + + + + +
+ ), + }, + ]; + + return ( + + {intl.formatMessage({ + id: 'pages.userGroups.description', + defaultMessage: '维护可复用的用户范围,考试可按用户或用户组分配。', + })} +

+ } + > + + actionRef={actionRef} + cardBordered={{ + search: true, + table: true, + }} + columns={columns} + columnsState={{ + persistenceKey: 'examora-system-user-groups-table-columns', + persistenceType: 'localStorage', + }} + columnEmptyText="-" + dateFormatter="string" + debounceTime={300} + defaultSize="middle" + headerTitle={intl.formatMessage({ + id: 'pages.userGroups.listTitle', + defaultMessage: '用户组列表', + })} + rowKey="id" + request={async ({ current, pageSize, keyword, source }) => { + try { + const params = new URLSearchParams({ + page: String(current || 1), + page_size: String(pageSize || 20), + }); + if (keyword) params.set('keyword', String(keyword).trim()); + if (source) params.set('source', String(source)); + const data = await fetchEnvelope<{ + items: AdminUserGroup[]; + total: number; + }>(`${API_PATHS.admin.userGroups}?${params.toString()}`); + return { + data: data.items || [], + total: data.total || 0, + success: true, + }; + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.userGroups.loadError', + defaultMessage: '加载用户组失败', + }), + ); + return { data: [], total: 0, success: false }; + } + }} + search={{ + labelWidth: 'auto', + span: { + xs: 24, + sm: 12, + md: 8, + lg: 6, + xl: 6, + xxl: 4, + }, + defaultCollapsed: true, + searchText: intl.formatMessage({ + id: 'common.search', + defaultMessage: '搜索', + }), + resetText: intl.formatMessage({ + id: 'common.reset', + defaultMessage: '重置', + }), + }} + beforeSearchSubmit={(params) => ({ + ...params, + keyword: + typeof params.keyword === 'string' + ? params.keyword.trim() + : params.keyword, + })} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (total) => + intl.formatMessage( + { + id: 'pages.userGroups.total', + defaultMessage: '共 {total} 条', + }, + { total }, + ), + }} + options={{ + density: true, + fullScreen: false, + reload: true, + setting: true, + }} + scroll={{ x: 1100 }} + toolBarRender={() => [ + , + ]} + /> + + setModalOpen(false)} + > +
+ + + + + + +
+
+
+ ); +}; + +const UserGroups: React.FC = () => ( + + + +); + +export default UserGroups; diff --git a/apps/admin/src/pages/System/Settings/Users/index.tsx b/apps/admin/src/pages/System/Settings/Users/index.tsx index 2795f7a..96f3584 100644 --- a/apps/admin/src/pages/System/Settings/Users/index.tsx +++ b/apps/admin/src/pages/System/Settings/Users/index.tsx @@ -3,6 +3,7 @@ import { DownOutlined, EditOutlined, PlusOutlined, + TeamOutlined, } from '@ant-design/icons'; import { type ActionType, @@ -10,6 +11,10 @@ import { type ProColumns, ProTable, } from '@ant-design/pro-components'; +import type { + AdminUserGroup, + AdminUserGroupTreeResponse, +} from '@examora/types'; import { API_PATHS } from '@examora/types'; import { request, useIntl } from '@umijs/max'; import { @@ -39,6 +44,7 @@ interface User { role: string; status: string; created_at: string; + source?: string; } interface UserFormValues { @@ -60,6 +66,21 @@ const normalizeRole = (role?: string) => { const normalizeStatus = (status?: string) => status?.toUpperCase() || ''; +const flattenGroups = (groups: AdminUserGroup[]): AdminUserGroup[] => + groups.flatMap((group) => [group, ...flattenGroups(group.children || [])]); + +const groupPath = (groupID: number, groups: AdminUserGroup[]) => { + const flat = flattenGroups(groups); + const byID = new Map(flat.map((group) => [group.id, group])); + const parts: string[] = []; + let current = byID.get(groupID); + while (current) { + parts.unshift(current.name); + current = current.parent_id ? byID.get(current.parent_id) : undefined; + } + return parts.join(' / '); +}; + const UserListContent: React.FC = () => { const intl = useIntl(); const { message: antdMessage } = AntdApp.useApp(); @@ -70,6 +91,10 @@ const UserListContent: React.FC = () => { const [drawerOpen, setDrawerOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); + const [selectedUserIDs, setSelectedUserIDs] = useState([]); + const [groups, setGroups] = useState([]); + const [groupID, setGroupID] = useState(); + const [groupModalOpen, setGroupModalOpen] = useState(false); // i18n label maps const roleLabelMap: Record = useMemo( @@ -134,6 +159,32 @@ const UserListContent: React.FC = () => { [statusLabelMap], ); + const groupOptions = useMemo( + () => + flattenGroups(groups).map((group) => ({ + value: group.id, + label: groupPath(group.id, groups), + })), + [groups], + ); + + const loadGroups = async () => { + try { + const response = await request<{ + code: number; + data: AdminUserGroupTreeResponse; + }>(API_PATHS.admin.userGroupTree); + setGroups(response.data?.items || []); + } catch (_error) { + antdMessage.error( + intl.formatMessage({ + id: 'pages.users.groupsLoadError', + defaultMessage: '加载用户组失败', + }), + ); + } + }; + const openCreate = () => { setEditing(null); userForm.resetFields(); @@ -243,6 +294,41 @@ const UserListContent: React.FC = () => { }); }; + const openJoinGroup = () => { + setGroupID(undefined); + setGroupModalOpen(true); + loadGroups(); + }; + + const joinGroup = async () => { + if (!groupID || selectedUserIDs.length === 0) return; + setSaving(true); + try { + await request(API_PATHS.admin.userGroupMembers(groupID), { + method: 'POST', + data: { ids: selectedUserIDs }, + }); + antdMessage.success( + intl.formatMessage({ + id: 'pages.users.joinGroupSuccess', + defaultMessage: '用户已加入用户组', + }), + ); + setGroupModalOpen(false); + setSelectedUserIDs([]); + actionRef.current?.reload(); + } catch (_error) { + antdMessage.error( + intl.formatMessage({ + id: 'pages.users.joinGroupError', + defaultMessage: '加入用户组失败', + }), + ); + } finally { + setSaving(false); + } + }; + const columns: ProColumns[] = [ { title: intl.formatMessage({ @@ -310,7 +396,7 @@ const UserListContent: React.FC = () => { dataIndex: 'role', key: 'role', width: 90, - search: false, + search: true, valueEnum: roleValueEnum, render: (_, user) => { const roleKey = normalizeRole(user.role); @@ -329,7 +415,7 @@ const UserListContent: React.FC = () => { dataIndex: 'status', key: 'status', width: 90, - search: false, + search: true, valueEnum: statusValueEnum, render: (_, user) => { const statusKey = normalizeStatus(user.status); @@ -342,6 +428,27 @@ const UserListContent: React.FC = () => { ); }, }, + { + title: intl.formatMessage({ + id: 'pages.users.columns.source', + defaultMessage: '来源', + }), + dataIndex: 'source', + key: 'source', + width: 90, + valueEnum: { + LOCAL: { + text: intl.formatMessage({ + id: 'pages.users.source.LOCAL', + defaultMessage: '本地', + }), + }, + LOGTO: { text: 'Logto' }, + OIDC: { text: 'OIDC' }, + SCIM: { text: 'SCIM' }, + }, + render: (_, user) => {user.source || 'LOCAL'}, + }, { title: intl.formatMessage({ id: 'pages.users.columns.createdAt', @@ -490,7 +597,35 @@ const UserListContent: React.FC = () => { ? params.keyword.trim() : params.keyword, })} - request={async ({ current, pageSize, keyword }) => { + rowSelection={{ + selectedRowKeys: selectedUserIDs, + preserveSelectedRowKeys: true, + onChange: (keys) => setSelectedUserIDs(keys.map(Number)), + }} + tableAlertOptionRender={() => ( + + + + + )} + request={async ({ + current, + pageSize, + keyword, + role, + status, + source, + }) => { try { const trimmedKeyword = typeof keyword === 'string' ? keyword.trim() : undefined; @@ -502,6 +637,9 @@ const UserListContent: React.FC = () => { page: current, page_size: pageSize, ...(trimmedKeyword ? { keyword: trimmedKeyword } : {}), + ...(role ? { role } : {}), + ...(status ? { status } : {}), + ...(source ? { source } : {}), }, }); @@ -560,6 +698,7 @@ const UserListContent: React.FC = () => { id: 'pages.users.modal.createTitle', defaultMessage: '添加用户', })} + width={600} open={modalOpen} onCancel={() => { setSaving(false); @@ -568,12 +707,6 @@ const UserListContent: React.FC = () => { footer={null} centered > -

- {intl.formatMessage({ - id: 'pages.users.modal.description', - defaultMessage: '提供以下至少一项字段才能继续', - })} -

{ } }} > - - - - - - + + + + + + + + + + + + { })} /> - - + + { /> - + { defaultMessage: '创建用户', }) } - size={480} + size={520} open={drawerOpen} onClose={() => { setSaving(false); @@ -775,51 +914,57 @@ const UserListContent: React.FC = () => { } > - - - - - - + + + + + + + + + + + + { /> - + { /> - + { + + setGroupModalOpen(false)} + > +