From 3a75b5c96ccf3e5be9d30321ee9e75d4e091f9a7 Mon Sep 17 00:00:00 2001 From: wecoding Date: Sat, 16 May 2026 14:36:02 +0800 Subject: [PATCH 1/3] Improve admin exam and user flows --- apps/admin/config/routes.ts | 8 +- apps/admin/src/locales/en-US/menu.ts | 1 + apps/admin/src/locales/en-US/pages.ts | 49 +++ apps/admin/src/locales/zh-CN/menu.ts | 1 + apps/admin/src/locales/zh-CN/pages.ts | 46 +++ .../Assessment/Results/JudgeTasks/index.tsx | 269 ++++++++++++-- .../Results/JudgeTasks/model.test.ts | 39 +++ .../Assessment/Results/JudgeTasks/model.ts | 43 +++ .../Assessment/Results/Submissions/index.tsx | 330 ++++++++++++++++-- .../Results/Submissions/model.test.ts | 31 ++ .../Assessment/Results/Submissions/model.ts | 24 ++ .../src/pages/Examination/ExamForm/index.tsx | 260 ++++++++++++++ .../pages/Examination/ExamForm/model.test.ts | 45 +++ .../src/pages/Examination/ExamForm/model.ts | 27 ++ .../src/pages/Examination/ExamList/index.less | 13 + .../src/pages/Examination/ExamList/index.tsx | 223 +++++++++++- .../src/pages/System/Settings/Users/index.tsx | 209 +++++------ apps/admin/src/utils/apiEnvelope.ts | 26 ++ packages/types/src/index.ts | 72 ++++ 19 files changed, 1565 insertions(+), 151 deletions(-) create mode 100644 apps/admin/src/pages/Assessment/Results/JudgeTasks/model.test.ts create mode 100644 apps/admin/src/pages/Assessment/Results/JudgeTasks/model.ts create mode 100644 apps/admin/src/pages/Assessment/Results/Submissions/model.test.ts create mode 100644 apps/admin/src/pages/Assessment/Results/Submissions/model.ts create mode 100644 apps/admin/src/pages/Examination/ExamForm/index.tsx create mode 100644 apps/admin/src/pages/Examination/ExamForm/model.test.ts create mode 100644 apps/admin/src/pages/Examination/ExamForm/model.ts create mode 100644 apps/admin/src/utils/apiEnvelope.ts diff --git a/apps/admin/config/routes.ts b/apps/admin/config/routes.ts index 7dabaef..ddfd1a4 100644 --- a/apps/admin/config/routes.ts +++ b/apps/admin/config/routes.ts @@ -137,7 +137,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', }, { diff --git a/apps/admin/src/locales/en-US/menu.ts b/apps/admin/src/locales/en-US/menu.ts index 819622f..4218154 100644 --- a/apps/admin/src/locales/en-US/menu.ts +++ b/apps/admin/src/locales/en-US/menu.ts @@ -24,6 +24,7 @@ export default { 'menu.content.paperDetail': 'Paper Detail', 'menu.examination.exams': 'Exams', 'menu.examCreate': 'Create Exam', + 'menu.examEdit': 'Edit Exam', 'menu.examPublish': 'Publish', 'menu.examination.candidates': 'Candidates', 'menu.proctoring': 'Proctoring', diff --git a/apps/admin/src/locales/en-US/pages.ts b/apps/admin/src/locales/en-US/pages.ts index 7a80ed9..d86775d 100644 --- a/apps/admin/src/locales/en-US/pages.ts +++ b/apps/admin/src/locales/en-US/pages.ts @@ -24,6 +24,8 @@ export default { 'pages.welcome.alertMessage': 'Welcome to Examora exam management system.', 'common.cancel': 'Cancel', 'common.publish': 'Publish', + 'common.save': 'Save', + 'common.view': 'View', // Login page 'pages.login.title': 'Sign In', 'pages.login.subtitle': @@ -565,6 +567,53 @@ 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.', + // 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.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..0e1ef16 100644 --- a/apps/admin/src/locales/zh-CN/menu.ts +++ b/apps/admin/src/locales/zh-CN/menu.ts @@ -24,6 +24,7 @@ export default { 'menu.content.paperDetail': '试卷详情', 'menu.examination.exams': '考试', 'menu.examCreate': '创建考试', + 'menu.examEdit': '编辑考试', 'menu.examPublish': '发布考试', 'menu.examination.candidates': '考生', 'menu.proctoring': '监考', diff --git a/apps/admin/src/locales/zh-CN/pages.ts b/apps/admin/src/locales/zh-CN/pages.ts index d49366f..62e2004 100644 --- a/apps/admin/src/locales/zh-CN/pages.ts +++ b/apps/admin/src/locales/zh-CN/pages.ts @@ -23,6 +23,8 @@ export default { 'pages.welcome.alertMessage': '欢迎使用 Examora 考试管理系统。', 'common.cancel': '取消', 'common.publish': '发布', + 'common.save': '保存', + 'common.view': '查看', // Login page 'pages.login.title': '登录后台', 'pages.login.subtitle': '使用管理员账号进入 Examora 控制台。', @@ -533,6 +535,50 @@ 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': '已发布或已结束考试不可编辑基础配置。', + // Results + 'pages.results.description': '按考试查看考生提交、总分和题目判分状态。', + 'pages.results.examsLoadError': '加载考试列表失败', + 'pages.results.detailLoadError': '加载结果详情失败', + 'pages.results.fetchError': '加载提交记录失败', + 'pages.results.examPlaceholder': '选择考试', + '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..cdbb012 100644 --- a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx @@ -1,36 +1,257 @@ -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..7c67164 100644 --- a/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx @@ -1,36 +1,320 @@ -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 ( - - + 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={ + + + + + + + + + +
+ + + + +
+ + + + + {selectedRows.length > 0 && ( { id: 'pages.users.modal.createTitle', defaultMessage: '添加用户', })} + width={600} open={modalOpen} onCancel={() => { setSaving(false); @@ -568,12 +569,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 +776,57 @@ const UserListContent: React.FC = () => { } > - - - - - - + + + + + + + + + + + + { /> - + { /> - + ( + path: string, + init: RequestInit = {}, +): Promise => { + const headers = new Headers(init.headers); + const token = getAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + const response = await fetch(path, { + ...init, + headers, + }); + const payload = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(payload?.message || `HTTP ${response.status}`); + } + if (payload?.code !== 0) { + throw new Error(payload?.message || 'Request failed'); + } + return payload.data as T; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 21ac34e..3f79659 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -86,6 +86,12 @@ export const API_PATHS = { examPublish: (examID: number | string) => `/api/v1/exams/${examID}/publish`, examBatchClose: "/api/v1/exams/batch/close", + examResults: (examID: number | string) => + `/api/v1/exams/${examID}/results`, + examResult: (resultID: number | string) => + `/api/v1/exam-results/${resultID}`, + judgeTasks: "/api/v1/judge/tasks", + judgeTask: (taskID: number | string) => `/api/v1/judge/tasks/${taskID}`, }, } as const; @@ -360,6 +366,72 @@ export interface PublishExamPayload { export type AdminExamPageResponse = PageResponse; +export type AdminExamResultStatus = "GRADED" | "JUDGING" | "MANUAL_REQUIRED"; + +export interface AdminQuestionResult { + id: number; + section_snapshot_id: number; + question_snapshot_id: number; + question_id: number; + type: QuestionType; + sort_order: number; + question_sort_order: number; + answer?: Record; + status: QuestionResultStatus; + score: number; + max_score: number; + result?: Record; + submission_id?: number; + judged_at?: string; +} + +export interface AdminExamResultSection { + section_snapshot_id: number; + title: string; + description?: string; + sort_order: number; + score: number; + max_score: number; + question_count: number; + questions?: AdminQuestionResult[]; +} + +export interface AdminExamResult { + id: number; + exam_id: number; + exam_snapshot_id: number; + exam_session_id: number; + user_id: number; + status: AdminExamResultStatus; + score: number; + max_score: number; + submitted_at: string; + graded_at?: string; + sections?: AdminExamResultSection[]; + questions?: AdminQuestionResult[]; +} + +export type AdminExamResultPageResponse = PageResponse; + +export interface AdminJudgeTask { + id: number; + submission_id: number; + question_id: number; + user_id: number; + language: string; + status: JudgeStatus; + retry_count: number; + max_retry_count: number; + error_message?: string; + result_summary?: Record; + created_at: string; + updated_at: string; + started_at?: string; + finished_at?: string; +} + +export type AdminJudgeTaskPageResponse = PageResponse; + // ===================================================================== // M1: Candidate-facing types (excludes answer and hidden test cases) // ===================================================================== From 5c382e5866e50e12baf48ce1306709e598355a1c Mon Sep 17 00:00:00 2001 From: wecoding Date: Sat, 16 May 2026 21:44:36 +0800 Subject: [PATCH 2/3] Add admin user group management --- apps/admin/config/routes.ts | 28 +- apps/admin/src/locales/en-US/menu.ts | 6 +- apps/admin/src/locales/en-US/pages.ts | 133 +++ apps/admin/src/locales/zh-CN/menu.ts | 6 +- apps/admin/src/locales/zh-CN/pages.ts | 141 ++- .../Assessment/Results/JudgeTasks/index.tsx | 4 +- .../Assessment/Results/Submissions/index.tsx | 29 +- .../Results/Submissions/model.test.ts | 24 +- .../Assessment/Results/Submissions/model.ts | 14 +- .../pages/Examination/Candidates/index.tsx | 36 - .../pages/Examination/ExamDetail/index.tsx | 948 ++++++++++++++++++ .../Examination/ExamDetail/model.test.ts | 27 + .../src/pages/Examination/ExamDetail/model.ts | 52 + .../src/pages/Examination/ExamForm/index.tsx | 65 +- .../pages/Examination/ExamForm/model.test.ts | 4 +- .../src/pages/Examination/ExamForm/model.ts | 4 +- .../src/pages/Examination/ExamList/index.tsx | 24 +- .../Settings/UserGroups/Detail/index.tsx | 779 ++++++++++++++ .../System/Settings/UserGroups/index.less | 104 ++ .../System/Settings/UserGroups/index.tsx | 393 ++++++++ .../src/pages/System/Settings/Users/index.tsx | 172 +++- internal/api/exam.go | 121 +++ internal/api/request.go | 12 + internal/api/routes_test.go | 16 + internal/api/server.go | 1 + internal/api/types.go | 132 +++ internal/api/user_group.go | 295 ++++++ internal/api/users.go | 49 +- internal/auth/ports.go | 8 +- internal/auth/service.go | 4 +- internal/auth/store/user.go | 36 +- internal/exam/errors.go | 14 +- internal/exam/exam.go | 59 +- internal/exam/snapshot_service.go | 16 + internal/exam/snapshot_service_test.go | 144 +++ internal/exam/store.go | 27 + internal/exam/store/client_event.go | 25 + internal/exam/store/session.go | 30 +- internal/exam/store/user_group.go | 281 ++++++ internal/exam/user_group.go | 372 +++++++ internal/infra/database/database.go | 3 + internal/infra/database/models.go | 44 + migrations/003_user_groups.sql | 45 + packages/types/src/index.ts | 131 +++ 44 files changed, 4727 insertions(+), 131 deletions(-) delete mode 100644 apps/admin/src/pages/Examination/Candidates/index.tsx create mode 100644 apps/admin/src/pages/Examination/ExamDetail/index.tsx create mode 100644 apps/admin/src/pages/Examination/ExamDetail/model.test.ts create mode 100644 apps/admin/src/pages/Examination/ExamDetail/model.ts create mode 100644 apps/admin/src/pages/System/Settings/UserGroups/Detail/index.tsx create mode 100644 apps/admin/src/pages/System/Settings/UserGroups/index.less create mode 100644 apps/admin/src/pages/System/Settings/UserGroups/index.tsx create mode 100644 internal/api/user_group.go create mode 100644 internal/exam/store/user_group.go create mode 100644 internal/exam/user_group.go create mode 100644 migrations/003_user_groups.sql diff --git a/apps/admin/config/routes.ts b/apps/admin/config/routes.ts index ddfd1a4..e08b731 100644 --- a/apps/admin/config/routes.ts +++ b/apps/admin/config/routes.ts @@ -124,10 +124,10 @@ 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', }, ], @@ -223,13 +223,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 4218154..a0a7f17 100644 --- a/apps/admin/src/locales/en-US/menu.ts +++ b/apps/admin/src/locales/en-US/menu.ts @@ -26,7 +26,8 @@ export default { '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', @@ -34,5 +35,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 d86775d..540fe9b 100644 --- a/apps/admin/src/locales/en-US/pages.ts +++ b/apps/admin/src/locales/en-US/pages.ts @@ -26,6 +26,9 @@ export default { 'common.publish': 'Publish', 'common.save': 'Save', 'common.view': 'View', + 'common.delete': 'Delete', + 'common.refresh': 'Refresh', + 'common.search': 'Search', // Login page 'pages.login.title': 'Sign In', 'pages.login.subtitle': @@ -92,6 +95,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...', @@ -104,6 +108,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', @@ -589,6 +600,127 @@ export default { '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.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.', @@ -596,6 +728,7 @@ export default { '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', diff --git a/apps/admin/src/locales/zh-CN/menu.ts b/apps/admin/src/locales/zh-CN/menu.ts index 0e1ef16..b783672 100644 --- a/apps/admin/src/locales/zh-CN/menu.ts +++ b/apps/admin/src/locales/zh-CN/menu.ts @@ -26,7 +26,8 @@ export default { 'menu.examCreate': '创建考试', 'menu.examEdit': '编辑考试', 'menu.examPublish': '发布考试', - 'menu.examination.candidates': '考生', + 'menu.examination.examDetail': '考试详情', + 'menu.examination.candidates': '用户组', 'menu.proctoring': '监考', 'menu.monitoring.events': '事件', 'menu.results': '评测', @@ -34,5 +35,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 62e2004..5b26848 100644 --- a/apps/admin/src/locales/zh-CN/pages.ts +++ b/apps/admin/src/locales/zh-CN/pages.ts @@ -25,6 +25,9 @@ export default { 'common.publish': '发布', 'common.save': '保存', 'common.view': '查看', + 'common.delete': '删除', + 'common.refresh': '刷新', + 'common.search': '搜索', // Login page 'pages.login.title': '登录后台', 'pages.login.subtitle': '使用管理员账号进入 Examora 控制台。', @@ -84,6 +87,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': '搜索用户名、邮箱...', @@ -96,6 +100,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': '添加用户', @@ -348,7 +359,7 @@ export default { // Exam pages 'pages.exams.title': '考试管理', 'pages.exams.description': - '创建和管理考试,设置考试时间、时长和参与考生,支持线上监考。', + '创建和管理考试,设置考试时间、时长和参与用户,支持线上监考。', 'pages.exams.listTitle': '考试列表', 'pages.exams.create': '创建考试', 'pages.exams.more': '更多', @@ -556,19 +567,141 @@ export default { '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.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.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.user': '用户ID', 'pages.results.columns.status': '状态', 'pages.results.columns.score': '成绩', 'pages.results.columns.submittedAt': '提交时间', // Judge tasks - 'pages.judgeTasks.description': '查看异步判题任务、重试次数、运行耗时和沙箱摘要。', + 'pages.judgeTasks.description': + '查看异步判题任务、重试次数、运行耗时和沙箱摘要。', 'pages.judgeTasks.fetchError': '加载判题任务失败', 'pages.judgeTasks.detailLoadError': '加载判题任务详情失败', 'pages.judgeTasks.detailTitle': '判题任务详情', diff --git a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx index cdbb012..b385637 100644 --- a/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/JudgeTasks/index.tsx @@ -223,7 +223,9 @@ const JudgeTasksContent: React.FC = () => { {detail.question_id} - {detail.user_id} + + {detail.user_id} + {detail.language} diff --git a/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx b/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx index 7c67164..02b686c 100644 --- a/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx +++ b/apps/admin/src/pages/Assessment/Results/Submissions/index.tsx @@ -57,10 +57,13 @@ const SubmissionsContent: React.FC = () => { const [detailLoading, setDetailLoading] = useState(false); useEffect(() => { - request<{ code: number; data: AdminExamPageResponse }>(API_PATHS.admin.exams, { - skipErrorHandler: true, - params: { page: 1, page_size: 100 }, - }) + 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); @@ -249,14 +252,18 @@ const SubmissionsContent: React.FC = () => { })} onChange={(value) => setExamID(value)} /> - - } - /> - - - ); -}; - -export default Index; diff --git a/apps/admin/src/pages/Examination/ExamDetail/index.tsx b/apps/admin/src/pages/Examination/ExamDetail/index.tsx new file mode 100644 index 0000000..db9770b --- /dev/null +++ b/apps/admin/src/pages/Examination/ExamDetail/index.tsx @@ -0,0 +1,948 @@ +import { + DeleteOutlined, + EyeOutlined, + PlusOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { + PageContainer, + type ProColumns, + ProTable, +} from '@ant-design/pro-components'; +import type { + AdminClientEvent, + AdminClientEventPageResponse, + AdminExam, + AdminExamAssignment, + AdminExamAssignmentListResponse, + AdminExamResult, + AdminExamResultPageResponse, + AdminExamSession, + AdminExamSessionListResponse, + AdminQuestionResult, + AdminUser, + AdminUserGroup, + AdminUserGroupStudentsResponse, + AdminUserGroupTreeResponse, + AdminUserPageResponse, + BatchResult, +} from '@examora/types'; +import { API_PATHS } from '@examora/types'; +import { history, useIntl } from '@umijs/max'; +import { + App as AntdApp, + Button, + Descriptions, + Drawer, + Empty, + Modal, + Segmented, + Select, + Space, + Spin, + Table, + Tabs, + Tag, + Tree, + Typography, +} from 'antd'; +import type { DataNode } from 'antd/es/tree'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + formatScore, + resultStatusTone, +} from '@/pages/Assessment/Results/Submissions/model'; +import { fetchEnvelope } from '@/utils/apiEnvelope'; +import { requestErrorMessage } from '@/utils/request'; +import { + canRemoveCandidate, + examAssignmentsPath, + examCandidatePath, + examCandidatesPath, + examDetailPath, + examEventsPath, + examResultPath, + examResultsPath, + examSessionsPath, + examStatusTone, + sessionStatusTone, +} from './model'; + +const { Text } = Typography; + +const userGroupTreePath = '/api/v1/user-groups/tree'; +const userGroupStudentsPath = (groupID: number | string) => + `/api/v1/user-groups/${groupID}/students`; + +const toTreeData = (groups: AdminUserGroup[]): DataNode[] => + groups.map((group) => ({ + key: String(group.id), + title: group.name, + children: toTreeData(group.children || []), + })); + +const ExamDetailContent: React.FC = () => { + const intl = useIntl(); + const examID = window.location.pathname.match( + /\/examination\/exams\/([^/]+)/, + )?.[1]; + const { message, modal } = AntdApp.useApp(); + const [exam, setExam] = useState(null); + const [examLoading, setExamLoading] = useState(false); + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [assignments, setAssignments] = useState([]); + const [results, setResults] = useState([]); + const [resultsTotal, setResultsTotal] = useState(0); + const [resultsLoading, setResultsLoading] = useState(false); + const [events, setEvents] = useState([]); + const [eventsTotal, setEventsTotal] = useState(0); + const [eventsLoading, setEventsLoading] = useState(false); + const [users, setUsers] = useState([]); + const [userGroups, setUserGroups] = useState([]); + const [assignOpen, setAssignOpen] = useState(false); + const [assignMode, setAssignMode] = useState<'users' | 'groups'>('users'); + const [selectedUserIDs, setSelectedUserIDs] = useState([]); + const [selectedGroupIDs, setSelectedGroupIDs] = useState([]); + const [groupCoverageCount, setGroupCoverageCount] = useState(0); + const [assigning, setAssigning] = useState(false); + const [resultDetail, setResultDetail] = useState( + null, + ); + const [resultDetailOpen, setResultDetailOpen] = useState(false); + const [resultDetailLoading, setResultDetailLoading] = useState(false); + const [eventDetail, setEventDetail] = useState(null); + + const loadExam = React.useCallback(async () => { + if (!examID) return; + setExamLoading(true); + try { + setExam(await fetchEnvelope(examDetailPath(examID))); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.loadError', + defaultMessage: '加载考试详情失败', + }), + ); + } finally { + setExamLoading(false); + } + }, [examID, intl, message]); + + const loadSessions = React.useCallback(async () => { + if (!examID) return; + setSessionsLoading(true); + try { + const data = await fetchEnvelope( + examSessionsPath(examID), + ); + setSessions(data.items || []); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.sessionsLoadError', + defaultMessage: '加载用户列表失败', + }), + ); + } finally { + setSessionsLoading(false); + } + }, [examID, intl, message]); + + const loadResults = React.useCallback(async () => { + if (!examID) return; + setResultsLoading(true); + try { + const data = await fetchEnvelope( + `${examResultsPath(examID)}?page=1&page_size=100`, + ); + setResults(data.items || []); + setResultsTotal(data.total || 0); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.resultsLoadError', + defaultMessage: '加载答卷失败', + }), + ); + } finally { + setResultsLoading(false); + } + }, [examID, intl, message]); + + const loadEvents = React.useCallback(async () => { + if (!examID) return; + setEventsLoading(true); + try { + const data = await fetchEnvelope( + `${examEventsPath(examID)}?page=1&page_size=100`, + ); + setEvents(data.items || []); + setEventsTotal(data.total || 0); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.eventsLoadError', + defaultMessage: '加载审计事件失败', + }), + ); + } finally { + setEventsLoading(false); + } + }, [examID, intl, message]); + + const loadUsers = React.useCallback(async () => { + try { + const data = await fetchEnvelope( + `${API_PATHS.admin.users}?page=1&page_size=200`, + ); + setUsers(data.items || []); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.usersLoadError', + defaultMessage: '加载用户失败', + }), + ); + } + }, [intl, message]); + + const loadUserGroups = React.useCallback(async () => { + try { + const data = + await fetchEnvelope(userGroupTreePath); + setUserGroups(data.items || []); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.groupsLoadError', + defaultMessage: '加载用户组失败', + }), + ); + } + }, [intl, message]); + + const loadAssignments = React.useCallback(async () => { + if (!examID) return; + try { + const data = await fetchEnvelope( + examAssignmentsPath(examID), + ); + setAssignments(data.items || []); + } catch (_error) { + setAssignments([]); + } + }, [examID]); + + const refreshAll = React.useCallback(() => { + loadExam(); + loadSessions(); + loadAssignments(); + loadResults(); + loadEvents(); + }, [loadAssignments, loadExam, loadEvents, loadResults, loadSessions]); + + useEffect(() => { + refreshAll(); + }, [refreshAll]); + + const userMap = useMemo(() => { + const map = new Map(); + users.forEach((user) => { + map.set(user.id, user); + }); + return map; + }, [users]); + + const assignedUserIDs = useMemo( + () => new Set(sessions.map((session) => session.user_id)), + [sessions], + ); + + const assignmentSourceByUserID = useMemo(() => { + const map = new Map(); + assignments.forEach((assignment) => { + if (assignment.target_type === 'USER') { + map.set( + assignment.target_id, + intl.formatMessage({ + id: 'pages.examDetail.directAssignment', + defaultMessage: '直接分配', + }), + ); + } + }); + return map; + }, [assignments, intl]); + + const userOptions = useMemo( + () => + users + .filter((user) => !assignedUserIDs.has(user.id)) + .map((user) => ({ + label: user.display_name + ? `${user.display_name} (${user.username})` + : user.username, + value: user.id, + })), + [assignedUserIDs, users], + ); + + const openAssign = async () => { + setSelectedUserIDs([]); + setSelectedGroupIDs([]); + setGroupCoverageCount(0); + setAssignMode('users'); + setAssignOpen(true); + await Promise.all([loadUsers(), loadUserGroups()]); + }; + + const assignCandidates = async () => { + if ( + !examID || + (assignMode === 'users' && selectedUserIDs.length === 0) || + (assignMode === 'groups' && selectedGroupIDs.length === 0) + ) { + return; + } + setAssigning(true); + try { + await fetchEnvelope( + assignMode === 'users' + ? examCandidatesPath(examID) + : examAssignmentsPath(examID), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + assignMode === 'users' + ? { ids: selectedUserIDs } + : { user_group_ids: selectedGroupIDs }, + ), + }, + ); + message.success( + intl.formatMessage({ + id: 'pages.examDetail.assignSuccess', + defaultMessage: '用户已分配', + }), + ); + setAssignOpen(false); + loadSessions(); + loadAssignments(); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.assignError', + defaultMessage: '分配用户失败', + }), + ); + } finally { + setAssigning(false); + } + }; + + const updateGroupCoverage = async (groupIDs: number[]) => { + if (!groupIDs.length) { + setGroupCoverageCount(0); + return; + } + const responses = await Promise.all( + groupIDs.map((groupID) => + fetchEnvelope( + `${userGroupStudentsPath(groupID)}?include_children=true`, + ), + ), + ); + const covered = new Set(); + responses.forEach((response) => { + response.ids?.forEach((id) => { + covered.add(id); + }); + }); + setGroupCoverageCount(covered.size); + }; + + const removeCandidate = (session: AdminExamSession) => { + if (!examID) return; + modal.confirm({ + title: intl.formatMessage({ + id: 'pages.examDetail.removeCandidateTitle', + defaultMessage: '移除用户', + }), + content: intl.formatMessage({ + id: 'pages.examDetail.removeCandidateContent', + defaultMessage: '仅未开始考试的用户可以移除,确定继续?', + }), + onOk: async () => { + await fetchEnvelope(examCandidatePath(examID, session.user_id), { + method: 'DELETE', + }); + loadSessions(); + }, + }); + }; + + const openResultDetail = async (record: AdminExamResult) => { + setResultDetailOpen(true); + setResultDetailLoading(true); + try { + setResultDetail( + await fetchEnvelope(examResultPath(record.id)), + ); + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.resultDetailError', + defaultMessage: '加载答卷详情失败', + }), + ); + } finally { + setResultDetailLoading(false); + } + }; + + const sessionColumns: ProColumns[] = [ + { + title: intl.formatMessage({ + id: 'pages.examDetail.user', + defaultMessage: '用户', + }), + dataIndex: 'user_id', + render: (_, record) => { + const user = userMap.get(record.user_id); + return user?.display_name || user?.username || record.user_id; + }, + }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.status', + defaultMessage: '状态', + }), + dataIndex: 'status', + render: (_, record) => ( + {record.status} + ), + }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.assignSource', + defaultMessage: '来源', + }), + width: 120, + render: (_, record) => + assignmentSourceByUserID.get(record.user_id) || + intl.formatMessage({ + id: 'pages.examDetail.userGroup', + defaultMessage: '用户组', + }), + }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.startedAt', + defaultMessage: '开始时间', + }), + dataIndex: 'started_at', + render: (_, record) => + record.started_at + ? dayjs(record.started_at).format('YYYY-MM-DD HH:mm:ss') + : '-', + }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.submittedAt', + defaultMessage: '交卷时间', + }), + dataIndex: 'submitted_at', + 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: 120, + render: (_, record) => [ + , + ], + }, + ]; + + const resultColumns: ProColumns[] = [ + { title: 'ID', dataIndex: 'id', width: 80 }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.user', + defaultMessage: '用户', + }), + dataIndex: 'user_id', + }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.status', + defaultMessage: '状态', + }), + dataIndex: 'status', + render: (_, record) => ( + {record.status} + ), + }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.score', + defaultMessage: '成绩', + }), + render: (_, record) => formatScore(record.score, record.max_score), + }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.submittedAt', + defaultMessage: '交卷时间', + }), + dataIndex: 'submitted_at', + render: (_, record) => + dayjs(record.submitted_at).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: intl.formatMessage({ + id: 'common.actions', + defaultMessage: '操作', + }), + valueType: 'option', + render: (_, record) => [ + , + ], + }, + ]; + + const eventColumns: ProColumns[] = [ + { title: 'ID', dataIndex: 'id', width: 80 }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.user', + defaultMessage: '用户', + }), + dataIndex: 'user_id', + }, + { title: 'Device', dataIndex: 'device_id' }, + { title: 'Type', dataIndex: 'event_type' }, + { + title: intl.formatMessage({ + id: 'pages.examDetail.createdAt', + defaultMessage: '时间', + }), + dataIndex: 'created_at', + render: (_, record) => + dayjs(record.created_at).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: intl.formatMessage({ + id: 'common.actions', + defaultMessage: '操作', + }), + valueType: 'option', + render: (_, record) => [ + , + ], + }, + ]; + + const questionRows: AdminQuestionResult[] = useMemo(() => { + if (!resultDetail) return []; + if (resultDetail.sections?.length) { + return resultDetail.sections.flatMap( + (section) => section.questions || [], + ); + } + return resultDetail.questions || []; + }, [resultDetail]); + + return ( + history.push('/examination/exams')} + extra={[ + , + ]} + > + + + {exam.id} + + {exam.status} + + + {exam.duration_minutes} min + + + {exam.paper_id || '-'} + + + {exam.start_time + ? dayjs(exam.start_time).format('YYYY-MM-DD HH:mm:ss') + : '-'} + + + {exam.end_time + ? dayjs(exam.end_time).format('YYYY-MM-DD HH:mm:ss') + : '-'} + + + {exam.description || '-'} + + + ) : ( + + ), + }, + { + key: 'candidates', + label: intl.formatMessage({ + id: 'pages.examDetail.candidates', + defaultMessage: '用户', + }), + children: ( + + rowKey="id" + columns={sessionColumns} + dataSource={sessions} + loading={sessionsLoading} + search={false} + pagination={false} + options={{ + reload: loadSessions, + density: true, + setting: true, + }} + toolBarRender={() => [ + , + ]} + /> + ), + }, + { + key: 'results', + label: intl.formatMessage({ + id: 'pages.examDetail.results', + defaultMessage: '答卷', + }), + children: ( + + rowKey="id" + columns={resultColumns} + dataSource={results} + loading={resultsLoading} + search={false} + pagination={{ total: resultsTotal, pageSize: 20 }} + options={{ + reload: loadResults, + density: true, + setting: true, + }} + /> + ), + }, + { + key: 'events', + label: intl.formatMessage({ + id: 'pages.examDetail.events', + defaultMessage: '审计事件', + }), + children: ( + + rowKey="id" + columns={eventColumns} + dataSource={events} + loading={eventsLoading} + search={false} + pagination={{ total: eventsTotal, pageSize: 20 }} + options={{ reload: loadEvents, density: true, setting: true }} + /> + ), + }, + ]} + /> + + setAssignOpen(false)} + onOk={assignCandidates} + > +
+ setAssignMode(value as 'users' | 'groups')} + options={[ + { + label: intl.formatMessage({ + id: 'pages.examDetail.assignByUser', + defaultMessage: '按用户', + }), + value: 'users', + }, + { + label: intl.formatMessage({ + id: 'pages.examDetail.assignByGroup', + defaultMessage: '按用户组', + }), + value: 'groups', + }, + ]} + /> + {assignMode === 'users' ? ( + @@ -197,7 +212,15 @@ const ExamFormContent: React.FC = () => { id: 'pages.exams.form.paper', defaultMessage: '关联试卷', })} - rules={[{ required: true, message: '请选择试卷' }]} + rules={[ + { + required: true, + message: intl.formatMessage({ + id: 'pages.exams.form.paperRequired', + defaultMessage: '请选择试卷', + }), + }, + ]} > + + + + + +
+ + {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..04ee131 --- /dev/null +++ b/apps/admin/src/pages/System/Settings/UserGroups/index.tsx @@ -0,0 +1,393 @@ +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 ( + + + actionRef={actionRef} + columns={columns} + 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)); + 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', defaultCollapsed: false }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + }} + options={{ + density: true, + 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 948744b..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 } : {}), }, }); @@ -906,6 +1044,34 @@ const UserListContent: React.FC = () => {
+ + setGroupModalOpen(false)} + > +