From a4714925a7399cb83c6cbaea89d23c43240ee8df Mon Sep 17 00:00:00 2001 From: wecoding Date: Sun, 17 May 2026 14:15:20 +0800 Subject: [PATCH 1/3] feat(admin): add exam operations detail --- apps/admin/src/locales/en-US/pages.ts | 7 + apps/admin/src/locales/zh-CN/pages.ts | 7 + .../pages/Examination/ExamDetail/index.tsx | 332 +++++++++++++----- .../Examination/ExamDetail/model.test.ts | 31 +- .../src/pages/Examination/ExamDetail/model.ts | 34 ++ .../2026-05-17-examora-module-batches.md | 10 +- internal/api/exam.go | 9 +- internal/api/exam_test.go | 138 +++++++- internal/api/types.go | 26 ++ internal/exam/exam.go | 69 ++++ internal/exam/store.go | 2 + internal/exam/store/session.go | 26 ++ packages/types/src/index.ts | 12 +- 13 files changed, 598 insertions(+), 105 deletions(-) diff --git a/apps/admin/src/locales/en-US/pages.ts b/apps/admin/src/locales/en-US/pages.ts index 11f2289..ca9c85a 100644 --- a/apps/admin/src/locales/en-US/pages.ts +++ b/apps/admin/src/locales/en-US/pages.ts @@ -621,6 +621,13 @@ export default { 'pages.examDetail.eventDetail': 'Event Detail', 'pages.examDetail.duration': 'Duration', 'pages.examDetail.paper': 'Paper', + 'pages.examDetail.publishedAt': 'Published At', + 'pages.examDetail.snapshotQuestions': 'Snapshot Questions', + 'pages.examDetail.snapshotScore': 'Snapshot Score', + 'pages.examDetail.candidateCount': 'Users', + 'pages.examDetail.submittedCount': 'Submitted', + 'pages.examDetail.resultCount': 'Results', + 'pages.examDetail.auditEventCount': 'Audit Events', 'pages.examDetail.startTime': 'Start Time', 'pages.examDetail.endTime': 'End Time', 'pages.examDetail.description': 'Description', diff --git a/apps/admin/src/locales/zh-CN/pages.ts b/apps/admin/src/locales/zh-CN/pages.ts index 0c1bca7..1152042 100644 --- a/apps/admin/src/locales/zh-CN/pages.ts +++ b/apps/admin/src/locales/zh-CN/pages.ts @@ -585,6 +585,13 @@ export default { 'pages.examDetail.eventDetail': '事件详情', 'pages.examDetail.duration': '时长', 'pages.examDetail.paper': '试卷', + 'pages.examDetail.publishedAt': '发布时间', + 'pages.examDetail.snapshotQuestions': '快照题量', + 'pages.examDetail.snapshotScore': '快照总分', + 'pages.examDetail.candidateCount': '用户数', + 'pages.examDetail.submittedCount': '已交卷', + 'pages.examDetail.resultCount': '答卷数', + 'pages.examDetail.auditEventCount': '审计事件', 'pages.examDetail.startTime': '开始时间', 'pages.examDetail.endTime': '结束时间', 'pages.examDetail.description': '说明', diff --git a/apps/admin/src/pages/Examination/ExamDetail/index.tsx b/apps/admin/src/pages/Examination/ExamDetail/index.tsx index cde1ae2..eb71a2c 100644 --- a/apps/admin/src/pages/Examination/ExamDetail/index.tsx +++ b/apps/admin/src/pages/Examination/ExamDetail/index.tsx @@ -6,6 +6,7 @@ import { } from '@ant-design/icons'; import { PageContainer, + type ActionType, type ProColumns, ProTable, } from '@ant-design/pro-components'; @@ -39,6 +40,7 @@ import { Segmented, Select, Space, + Statistic, Spin, Table, Tabs, @@ -56,8 +58,11 @@ import { import { fetchEnvelope } from '@/utils/apiEnvelope'; import { requestErrorMessage } from '@/utils/request'; import { + buildPagedQuery, canRemoveCandidate, + examOperationStats, examStatusTone, + normalizeExamDetailTab, sessionStatusTone, } from './model'; @@ -79,14 +84,13 @@ const ExamDetailContent: React.FC = () => { 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 sessionsActionRef = React.useRef(undefined); + const resultsActionRef = React.useRef(undefined); + const eventsActionRef = React.useRef(undefined); + const [activeTab, setActiveTab] = useState(() => + normalizeExamDetailTab(new URLSearchParams(history.location.search).get('tab')), + ); const [users, setUsers] = useState([]); const [userGroups, setUserGroups] = useState([]); const [assignOpen, setAssignOpen] = useState(false); @@ -120,71 +124,6 @@ const ExamDetailContent: React.FC = () => { } }, [examID, intl, message]); - const loadSessions = React.useCallback(async () => { - if (!examID) return; - setSessionsLoading(true); - try { - const data = await fetchEnvelope( - API_PATHS.admin.examSessions(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( - `${API_PATHS.admin.examResults(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( - `${API_PATHS.admin.examEvents(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( @@ -233,11 +172,15 @@ const ExamDetailContent: React.FC = () => { const refreshAll = React.useCallback(() => { loadExam(); - loadSessions(); loadAssignments(); - loadResults(); - loadEvents(); - }, [loadAssignments, loadExam, loadEvents, loadResults, loadSessions]); + if (activeTab === 'candidates') { + sessionsActionRef.current?.reload(); + } else if (activeTab === 'results') { + resultsActionRef.current?.reload(); + } else if (activeTab === 'events') { + eventsActionRef.current?.reload(); + } + }, [activeTab, loadAssignments, loadExam]); useEffect(() => { refreshAll(); @@ -325,7 +268,8 @@ const ExamDetailContent: React.FC = () => { }), ); setAssignOpen(false); - loadSessions(); + loadExam(); + sessionsActionRef.current?.reload(); loadAssignments(); } catch (error) { message.error( @@ -379,7 +323,8 @@ const ExamDetailContent: React.FC = () => { method: 'DELETE', }, ); - loadSessions(); + loadExam(); + sessionsActionRef.current?.reload(); }, }); }; @@ -406,6 +351,98 @@ const ExamDetailContent: React.FC = () => { } }; + const handleTabChange = (key: string) => { + const nextTab = normalizeExamDetailTab(key); + setActiveTab(nextTab); + const query = new URLSearchParams(history.location.search); + if (nextTab === 'overview') { + query.delete('tab'); + } else { + query.set('tab', nextTab); + } + history.replace({ + pathname: history.location.pathname, + search: query.toString() ? `?${query.toString()}` : '', + }); + }; + + const requestSessions = async ({ + current, + pageSize, + }: { + current?: number; + pageSize?: number; + }) => { + if (!examID) return { data: [], total: 0, success: false }; + try { + const data = await fetchEnvelope( + `${API_PATHS.admin.examSessions(examID)}?${buildPagedQuery(current, pageSize)}`, + ); + const items = data.items || []; + setSessions(items); + return { data: items, total: data.total || 0, success: true }; + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.sessionsLoadError', + defaultMessage: '加载用户列表失败', + }), + ); + return { data: [], total: 0, success: false }; + } + }; + + const requestResults = async ({ + current, + pageSize, + }: { + current?: number; + pageSize?: number; + }) => { + if (!examID) return { data: [], total: 0, success: false }; + try { + const data = await fetchEnvelope( + `${API_PATHS.admin.examResults(examID)}?${buildPagedQuery(current, pageSize)}`, + ); + return { data: data.items || [], total: data.total || 0, success: true }; + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.resultsLoadError', + defaultMessage: '加载答卷失败', + }), + ); + return { data: [], total: 0, success: false }; + } + }; + + const requestEvents = async ({ + current, + pageSize, + }: { + current?: number; + pageSize?: number; + }) => { + if (!examID) return { data: [], total: 0, success: false }; + try { + const data = await fetchEnvelope( + `${API_PATHS.admin.examEvents(examID)}?${buildPagedQuery(current, pageSize)}`, + ); + return { data: data.items || [], total: data.total || 0, success: true }; + } catch (error) { + message.error( + requestErrorMessage(error) || + intl.formatMessage({ + id: 'pages.examDetail.eventsLoadError', + defaultMessage: '加载审计事件失败', + }), + ); + return { data: [], total: 0, success: false }; + } + }; + const sessionColumns: ProColumns[] = [ { title: intl.formatMessage({ @@ -591,6 +628,8 @@ const ExamDetailContent: React.FC = () => { return resultDetail.questions || []; }, [resultDetail]); + const operationStats = examOperationStats(exam); + return ( { ]} > + {exam ? ( +
+
+ + + {exam.status} + + Paper #{exam.paper_id || '-'} + Snapshot #{exam.exam_snapshot_id || '-'} + + {exam.published_at + ? dayjs(exam.published_at).format('YYYY-MM-DD HH:mm:ss') + : '-'} + + + + {exam.start_time + ? dayjs(exam.start_time).format('YYYY-MM-DD HH:mm') + : '-'} + {' - '} + {exam.end_time + ? dayjs(exam.end_time).format('YYYY-MM-DD HH:mm') + : '-'} + +
+
+ + + + + + +
+
+ ) : null} { > {exam.paper_id || '-'} + + {exam.exam_snapshot_id || '-'} + + + {exam.published_at + ? dayjs(exam.published_at).format('YYYY-MM-DD HH:mm:ss') + : '-'} + { }), children: ( + actionRef={sessionsActionRef} rowKey="id" columns={sessionColumns} - dataSource={sessions} - loading={sessionsLoading} + request={requestSessions} search={false} - pagination={false} + pagination={{ pageSize: 20 }} options={{ - reload: loadSessions, + reload: true, density: true, setting: true, }} @@ -726,14 +878,14 @@ const ExamDetailContent: React.FC = () => { }), children: ( + actionRef={resultsActionRef} rowKey="id" columns={resultColumns} - dataSource={results} - loading={resultsLoading} + request={requestResults} search={false} - pagination={{ total: resultsTotal, pageSize: 20 }} + pagination={{ pageSize: 20 }} options={{ - reload: loadResults, + reload: true, density: true, setting: true, }} @@ -748,13 +900,13 @@ const ExamDetailContent: React.FC = () => { }), children: ( + actionRef={eventsActionRef} rowKey="id" columns={eventColumns} - dataSource={events} - loading={eventsLoading} + request={requestEvents} search={false} - pagination={{ total: eventsTotal, pageSize: 20 }} - options={{ reload: loadEvents, density: true, setting: true }} + pagination={{ pageSize: 20 }} + options={{ reload: true, density: true, setting: true }} /> ), }, diff --git a/apps/admin/src/pages/Examination/ExamDetail/model.test.ts b/apps/admin/src/pages/Examination/ExamDetail/model.test.ts index 1f79114..f87a25b 100644 --- a/apps/admin/src/pages/Examination/ExamDetail/model.test.ts +++ b/apps/admin/src/pages/Examination/ExamDetail/model.test.ts @@ -1,5 +1,12 @@ import type { AdminExamSession } from '@examora/types'; -import { canRemoveCandidate, examStatusTone, sessionStatusTone } from './model'; +import { + buildPagedQuery, + canRemoveCandidate, + examOperationStats, + examStatusTone, + normalizeExamDetailTab, + sessionStatusTone, +} from './model'; describe('ExamDetail model', () => { it('maps session statuses to tag tones', () => { @@ -24,4 +31,26 @@ describe('ExamDetail model', () => { canRemoveCandidate({ status: 'IN_PROGRESS' } as AdminExamSession), ).toBe(false); }); + + it('normalizes detail tab query values', () => { + expect(normalizeExamDetailTab('results')).toBe('results'); + expect(normalizeExamDetailTab('bad')).toBe('overview'); + expect(normalizeExamDetailTab(null)).toBe('overview'); + }); + + it('builds paged table query strings', () => { + expect(buildPagedQuery(2, 50)).toBe('page=2&page_size=50'); + expect(buildPagedQuery(undefined, undefined)).toBe('page=1&page_size=20'); + }); + + it('defaults missing operation stats to zero', () => { + expect(examOperationStats(null)).toEqual({ + snapshotQuestionCount: 0, + snapshotTotalScore: 0, + candidateCount: 0, + submittedCount: 0, + resultCount: 0, + auditEventCount: 0, + }); + }); }); diff --git a/apps/admin/src/pages/Examination/ExamDetail/model.ts b/apps/admin/src/pages/Examination/ExamDetail/model.ts index f7a53de..71be308 100644 --- a/apps/admin/src/pages/Examination/ExamDetail/model.ts +++ b/apps/admin/src/pages/Examination/ExamDetail/model.ts @@ -1,4 +1,5 @@ import type { + AdminExam, AdminExamSession, ExamSessionStatus, ExamStatus, @@ -24,3 +25,36 @@ export const examStatusTone = ( export const canRemoveCandidate = (session: Pick) => session.status === 'NOT_STARTED'; + +export const examDetailTabKeys = [ + 'overview', + 'candidates', + 'results', + 'events', +] as const; + +export type ExamDetailTabKey = (typeof examDetailTabKeys)[number]; + +export const normalizeExamDetailTab = ( + value: string | null | undefined, +): ExamDetailTabKey => { + if (examDetailTabKeys.includes(value as ExamDetailTabKey)) { + return value as ExamDetailTabKey; + } + return 'overview'; +}; + +export const buildPagedQuery = (page?: number, pageSize?: number) => + new URLSearchParams({ + page: String(page || 1), + page_size: String(pageSize || 20), + }).toString(); + +export const examOperationStats = (exam: AdminExam | null) => ({ + snapshotQuestionCount: exam?.snapshot_question_count || 0, + snapshotTotalScore: exam?.snapshot_total_score || 0, + candidateCount: exam?.candidate_count || 0, + submittedCount: exam?.submitted_count || 0, + resultCount: exam?.result_count || 0, + auditEventCount: exam?.audit_event_count || 0, +}); diff --git a/docs/superpowers/plans/2026-05-17-examora-module-batches.md b/docs/superpowers/plans/2026-05-17-examora-module-batches.md index 8cd5b47..4554cc0 100644 --- a/docs/superpowers/plans/2026-05-17-examora-module-batches.md +++ b/docs/superpowers/plans/2026-05-17-examora-module-batches.md @@ -226,7 +226,7 @@ pnpm --dir apps/admin build - Modify: `internal/api/exam.go` - Modify: `packages/types/src/index.ts` -- [ ] Add snapshot metadata to admin exam detail responses. +- [x] Add snapshot metadata to admin exam detail responses. Fields: @@ -237,7 +237,7 @@ snapshot_question_count snapshot_total_score ``` -- [ ] Add tests for exam detail metadata. +- [x] Add tests for exam detail metadata. Run: @@ -245,7 +245,7 @@ Run: go test ./internal/api -run Exam -v ``` -- [ ] Update admin exam detail overview. +- [x] Update admin exam detail overview. Display: @@ -260,7 +260,7 @@ Result count Audit event count ``` -- [ ] Replace fixed 100-row loads with paginated requests for sessions, results, and events. +- [x] Replace fixed 100-row loads with paginated requests for sessions, results, and events. Expected behavior: @@ -269,7 +269,7 @@ Each tab requests page and page_size from the table pagination state. Reload preserves selected exam and active tab. ``` -- [ ] Verify Batch 3. +- [x] Verify Batch 3. Run: diff --git a/internal/api/exam.go b/internal/api/exam.go index 2c8f821..aeb9978 100644 --- a/internal/api/exam.go +++ b/internal/api/exam.go @@ -121,12 +121,12 @@ func (s *Server) getExam(c *gin.Context) { if !ok { return } - exam, err := s.exam.GetExam(c.Request.Context(), id) + exam, err := s.exam.GetExamDetail(c.Request.Context(), id) if err != nil { writeError(c, err) return } - response.Success(c, toExamResponse(*exam)) + response.Success(c, toExamDetailResponse(*exam)) } func (s *Server) createExam(c *gin.Context) { @@ -189,12 +189,13 @@ func (s *Server) listExamSessions(c *gin.Context) { if !ok { return } - items, err := s.exam.ListExamSessions(c.Request.Context(), id) + pageNum, pageSize := pageQuery(c) + items, total, err := s.exam.ListExamSessionsPage(c.Request.Context(), id, pageNum, pageSize) if err != nil { writeError(c, err) return } - response.Success(c, map[string]any{"items": sessionsToResponses(items)}) + response.PageSuccessWith(c, items, total, pageNum, pageSize, toExamSessionResponse) } func (s *Server) assignExamCandidates(c *gin.Context) { diff --git a/internal/api/exam_test.go b/internal/api/exam_test.go index 1a6ebea..3c20f28 100644 --- a/internal/api/exam_test.go +++ b/internal/api/exam_test.go @@ -7,7 +7,9 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strconv" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -29,6 +31,13 @@ func (apiRecordingJudgeDispatcher) CreateAndEnqueue(context.Context, uint64, uin func newExamAPIRouter(t *testing.T) (*gin.Engine, *exam.Service) { t.Helper() + router, service, _ := newExamAPIRouterWithLibrary(t) + return router, service +} + +func newExamAPIRouterWithLibrary(t *testing.T) (*gin.Engine, *exam.Service, *library.Service) { + t.Helper() + gin.SetMode(gin.TestMode) db, err := database.Open(filepath.Join(t.TempDir(), "examora-api-exam-test.db")) @@ -36,7 +45,7 @@ func newExamAPIRouter(t *testing.T) (*gin.Engine, *exam.Service) { require.NoError(t, database.AutoMigrate(db)) libraryStore := librarystore.New(db) - _, err = library.ProvideService(libraryStore, transaction.NewManager(db)) + libraryService, err := library.ProvideService(libraryStore, transaction.NewManager(db)) require.NoError(t, err) examStore := examstore.New(db) @@ -47,7 +56,45 @@ func newExamAPIRouter(t *testing.T) (*gin.Engine, *exam.Service) { router := gin.New() admin := router.Group("/api/v1") server.registerExamAdminRoutes(admin) - return router, service + return router, service, libraryService +} + +func createAPIExamWithPublishedPaper(t *testing.T, ctx context.Context, exams *exam.Service, libraryService *library.Service) *exam.Exam { + t.Helper() + + question, err := libraryService.CreateQuestion(ctx, library.SaveQuestionCommand{ + Type: library.QuestionTypeTrueFalse, + Title: "Go is compiled", + Content: map[string]any{"text": "Go is compiled."}, + Answer: map[string]any{"correct": true}, + Status: library.QuestionStatusPublished, + }) + require.NoError(t, err) + paper, err := libraryService.CreatePaper(ctx, library.SavePaperCommand{ + Title: "Batch3 paper", + Status: library.PaperStatusDraft, + }) + require.NoError(t, err) + _, err = libraryService.AddPaperQuestion(ctx, paper.ID, library.AddPaperQuestionCommand{ + QuestionID: question.ID, + Score: 10, + SortOrder: 1, + }) + require.NoError(t, err) + _, err = libraryService.UpdatePaper(ctx, paper.ID, library.SavePaperCommand{ + Title: paper.Title, + Status: library.PaperStatusPublished, + }) + require.NoError(t, err) + created, err := exams.CreateExam(ctx, exam.SaveExamCommand{ + Title: "Batch3 exam", + Description: "operations", + PaperID: &paper.ID, + Status: exam.StatusDraft, + DurationMinutes: 60, + }) + require.NoError(t, err) + return created } func TestBatchCloseExamsEndpointReturnsPartialFailures(t *testing.T) { @@ -86,3 +133,90 @@ func TestBatchCloseExamsEndpointReturnsPartialFailures(t *testing.T) { require.NoError(t, err) require.Equal(t, exam.StatusClosed, closed.Status) } + +func TestGetExamEndpointReturnsOperationsMetadata(t *testing.T) { + router, service, libraryService := newExamAPIRouterWithLibrary(t) + ctx := t.Context() + + created := createAPIExamWithPublishedPaper(t, ctx, service, libraryService) + start := time.Now().Add(-time.Hour) + end := time.Now().Add(time.Hour) + snapshot, err := service.PublishExamWithSnapshot(ctx, created.ID, start, end, 60) + require.NoError(t, err) + result, err := service.AssignCandidates(ctx, created.ID, []uint64{101, 102}) + require.NoError(t, err) + require.Equal(t, 2, result.SuccessCount) + _, err = service.StartExamSession(ctx, created.ID, 101, "127.0.0.1", "device-a") + require.NoError(t, err) + require.NoError(t, service.SubmitExam(ctx, created.ID, 101)) + deviceID := "device-a" + _, err = service.RecordClientEvent(ctx, 101, exam.RecordEventCommand{ + ExamID: created.ID, + DeviceID: &deviceID, + EventType: "focus_lost", + Payload: map[string]any{"count": 1}, + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/exams/"+strconv.FormatUint(created.ID, 10), nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + var body struct { + Data struct { + ID uint64 `json:"id"` + ExamSnapshotID *uint64 `json:"exam_snapshot_id"` + PublishedAt *string `json:"published_at"` + SnapshotQuestionCount int `json:"snapshot_question_count"` + SnapshotTotalScore float64 `json:"snapshot_total_score"` + CandidateCount int64 `json:"candidate_count"` + SubmittedCount int64 `json:"submitted_count"` + ResultCount int64 `json:"result_count"` + AuditEventCount int64 `json:"audit_event_count"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, created.ID, body.Data.ID) + require.NotNil(t, body.Data.ExamSnapshotID) + require.Equal(t, snapshot.ID, *body.Data.ExamSnapshotID) + require.NotNil(t, body.Data.PublishedAt) + require.Equal(t, 1, body.Data.SnapshotQuestionCount) + require.Equal(t, 10.0, body.Data.SnapshotTotalScore) + require.EqualValues(t, 2, body.Data.CandidateCount) + require.EqualValues(t, 1, body.Data.SubmittedCount) + require.EqualValues(t, 1, body.Data.ResultCount) + require.EqualValues(t, 1, body.Data.AuditEventCount) +} + +func TestListExamSessionsEndpointReturnsPaginatedResponse(t *testing.T) { + router, service, libraryService := newExamAPIRouterWithLibrary(t) + ctx := t.Context() + + created := createAPIExamWithPublishedPaper(t, ctx, service, libraryService) + _, err := service.PublishExamWithSnapshot(ctx, created.ID, time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 60) + require.NoError(t, err) + result, err := service.AssignCandidates(ctx, created.ID, []uint64{101, 102, 103}) + require.NoError(t, err) + require.Equal(t, 3, result.SuccessCount) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/exams/"+strconv.FormatUint(created.ID, 10)+"/sessions?page=2&page_size=1", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + var body struct { + Data struct { + Items []examSessionResponse `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Len(t, body.Data.Items, 1) + require.EqualValues(t, 3, body.Data.Total) + require.Equal(t, 2, body.Data.Page) + require.Equal(t, 1, body.Data.PageSize) + require.EqualValues(t, 102, body.Data.Items[0].UserID) +} diff --git a/internal/api/types.go b/internal/api/types.go index fa2df18..719120b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -235,6 +235,18 @@ type examResponse struct { UpdatedAt time.Time `json:"updated_at"` } +type examDetailResponse struct { + examResponse + ExamSnapshotID *uint64 `json:"exam_snapshot_id"` + PublishedAt *time.Time `json:"published_at"` + SnapshotQuestionCount int `json:"snapshot_question_count"` + SnapshotTotalScore float64 `json:"snapshot_total_score"` + CandidateCount int64 `json:"candidate_count"` + SubmittedCount int64 `json:"submitted_count"` + ResultCount int64 `json:"result_count"` + AuditEventCount int64 `json:"audit_event_count"` +} + type examSnapshotResponse struct { ID uint64 `json:"id"` ExamID uint64 `json:"exam_id"` @@ -621,6 +633,20 @@ func toExamResponse(e exam.Exam) examResponse { return examResponse{ID: e.ID, Title: e.Title, Description: e.Description, PaperID: e.PaperID, Status: e.Status, StartTime: e.StartTime, EndTime: e.EndTime, DurationMinutes: e.DurationMinutes, CreatedBy: e.CreatedBy, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt} } +func toExamDetailResponse(e exam.ExamDetail) examDetailResponse { + return examDetailResponse{ + examResponse: toExamResponse(e.Exam), + ExamSnapshotID: e.ExamSnapshotID, + PublishedAt: e.PublishedAt, + SnapshotQuestionCount: e.SnapshotQuestionCount, + SnapshotTotalScore: e.SnapshotTotalScore, + CandidateCount: e.CandidateCount, + SubmittedCount: e.SubmittedCount, + ResultCount: e.ResultCount, + AuditEventCount: e.AuditEventCount, + } +} + func toSubmissionResponse(s exam.Submission) submissionResponse { return submissionResponse{ID: s.ID, ExamID: s.ExamID, UserID: s.UserID, QuestionID: s.QuestionID, Answer: s.Answer, Code: s.Code, Language: s.Language, Status: s.Status, Score: s.Score, Result: s.Result, SubmittedAt: s.SubmittedAt, JudgedAt: s.JudgedAt} } diff --git a/internal/exam/exam.go b/internal/exam/exam.go index 2c3a706..8c2863d 100644 --- a/internal/exam/exam.go +++ b/internal/exam/exam.go @@ -34,6 +34,18 @@ type Exam struct { UpdatedAt time.Time `json:"updated_at"` } +type ExamDetail struct { + Exam + ExamSnapshotID *uint64 + PublishedAt *time.Time + SnapshotQuestionCount int + SnapshotTotalScore float64 + CandidateCount int64 + SubmittedCount int64 + ResultCount int64 + AuditEventCount int64 +} + func (e *Exam) Publish() error { if e.Status != StatusDraft { return ErrInvalidExamStatusTransition @@ -71,6 +83,52 @@ func (s *Service) GetExam(ctx context.Context, id uint64) (*Exam, error) { return s.store.GetExam(ctx, id) } +func (s *Service) GetExamDetail(ctx context.Context, id uint64) (*ExamDetail, error) { + e, err := s.store.GetExam(ctx, id) + if err != nil { + return nil, err + } + detail := &ExamDetail{Exam: *e} + snapshot, err := s.store.GetExamSnapshotByExamID(ctx, id) + if err != nil { + if errors.Is(err, ErrSnapshotNotFound) { + return detail, nil + } + return nil, err + } + detail.ExamSnapshotID = &snapshot.ID + detail.PublishedAt = &snapshot.PublishedAt + sections, err := s.store.ListPaperSectionSnapshots(ctx, snapshot.ID) + if err != nil { + return nil, err + } + for _, section := range sections { + detail.SnapshotQuestionCount += section.QuestionCount + detail.SnapshotTotalScore += section.TotalScore + } + _, candidateCount, err := s.store.ListExamSessionsBySnapshotPage(ctx, snapshot.ID, 1, 1) + if err != nil { + return nil, err + } + detail.CandidateCount = candidateCount + submittedCount, err := s.store.CountSubmittedExamSessionsBySnapshot(ctx, snapshot.ID) + if err != nil { + return nil, err + } + detail.SubmittedCount = submittedCount + _, resultCount, err := s.store.ListExamResults(ctx, id, 1, 1) + if err != nil { + return nil, err + } + detail.ResultCount = resultCount + _, eventCount, err := s.store.ListClientEvents(ctx, id, 1, 1) + if err != nil { + return nil, err + } + detail.AuditEventCount = eventCount + return detail, nil +} + func (s *Service) GetExamSnapshot(ctx context.Context, id uint64) (*ExamSnapshot, error) { return s.store.GetExamSnapshot(ctx, id) } @@ -159,6 +217,17 @@ func (s *Service) ListExamSessions(ctx context.Context, examID uint64) ([]ExamSe return s.store.ListExamSessionsBySnapshot(ctx, snapshot.ID) } +func (s *Service) ListExamSessionsPage(ctx context.Context, examID uint64, pageNum, pageSize int) ([]ExamSession, int64, error) { + snapshot, err := s.store.GetExamSnapshotByExamID(ctx, examID) + if err != nil { + if errors.Is(err, ErrSnapshotNotFound) { + return []ExamSession{}, 0, nil + } + return nil, 0, err + } + return s.store.ListExamSessionsBySnapshotPage(ctx, snapshot.ID, pageNum, pageSize) +} + func (s *Service) AssignCandidates(ctx context.Context, examID uint64, userIDs []uint64) (BatchResult, error) { return s.AssignExamTargets(ctx, examID, AssignExamTargetsCommand{UserIDs: userIDs}) } diff --git a/internal/exam/store.go b/internal/exam/store.go index 0ebea36..dfaacaa 100644 --- a/internal/exam/store.go +++ b/internal/exam/store.go @@ -22,6 +22,8 @@ type ExamSessionStore interface { CreateExamSession(ctx context.Context, session *ExamSession) error GetExamSession(ctx context.Context, examSnapshotID, userID uint64) (*ExamSession, error) ListExamSessionsBySnapshot(ctx context.Context, examSnapshotID uint64) ([]ExamSession, error) + ListExamSessionsBySnapshotPage(ctx context.Context, examSnapshotID uint64, pageNum, pageSize int) ([]ExamSession, int64, error) + CountSubmittedExamSessionsBySnapshot(ctx context.Context, examSnapshotID uint64) (int64, error) ListExamSessionsByUser(ctx context.Context, userID uint64) ([]ExamSession, error) UpdateExamSession(ctx context.Context, session *ExamSession) error DeleteExamSession(ctx context.Context, id uint64) error diff --git a/internal/exam/store/session.go b/internal/exam/store/session.go index 6c4e0c6..7529d5d 100644 --- a/internal/exam/store/session.go +++ b/internal/exam/store/session.go @@ -36,6 +36,32 @@ func (s *Store) ListExamSessionsBySnapshot(ctx context.Context, examSnapshotID u return sessions, nil } +func (s *Store) ListExamSessionsBySnapshotPage(ctx context.Context, examSnapshotID uint64, pageNum, pageSize int) ([]exam.ExamSession, int64, error) { + db := s.db(ctx).Model(&database.ExamSessionModel{}).Where("exam_snapshot_id = ?", examSnapshotID) + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []database.ExamSessionModel + if err := db.Order("id ASC").Offset((pageNum - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil { + return nil, 0, err + } + sessions := make([]exam.ExamSession, 0, len(rows)) + for _, row := range rows { + sessions = append(sessions, *toExamSession(&row)) + } + return sessions, total, nil +} + +func (s *Store) CountSubmittedExamSessionsBySnapshot(ctx context.Context, examSnapshotID uint64) (int64, error) { + var total int64 + err := s.db(ctx). + Model(&database.ExamSessionModel{}). + Where("exam_snapshot_id = ? AND status = ?", examSnapshotID, exam.SessionStatusSubmitted). + Count(&total).Error + return total, err +} + func (s *Store) ListExamSessionsByUser(ctx context.Context, userID uint64) ([]exam.ExamSession, error) { var rows []database.ExamSessionModel if err := s.db(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e0fa971..272e9d0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -369,6 +369,14 @@ export interface AdminExam { start_time: string | null; end_time: string | null; duration_minutes: number; + exam_snapshot_id?: number | null; + published_at?: string | null; + snapshot_question_count?: number; + snapshot_total_score?: number; + candidate_count?: number; + submitted_count?: number; + result_count?: number; + audit_event_count?: number; created_by: number; created_at: string; updated_at: string; @@ -454,9 +462,7 @@ export interface AdminExamSession { remaining_seconds?: number; } -export interface AdminExamSessionListResponse { - items: AdminExamSession[]; -} +export type AdminExamSessionListResponse = PageResponse; export interface BatchFailure { id: number; From 0117afc9fb00bbc13994d8f34bae69ac92b454f9 Mon Sep 17 00:00:00 2001 From: wecoding Date: Sun, 17 May 2026 14:21:57 +0800 Subject: [PATCH 2/3] fix(admin): remove unused exam sessions helper --- internal/api/types.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/api/types.go b/internal/api/types.go index 719120b..4c498ef 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -774,14 +774,6 @@ func toExamSessionResponse(session exam.ExamSession) examSessionResponse { } } -func sessionsToResponses(sessions []exam.ExamSession) []examSessionResponse { - items := make([]examSessionResponse, 0, len(sessions)) - for _, session := range sessions { - items = append(items, toExamSessionResponse(session)) - } - return items -} - func toUserGroupResponse(group exam.UserGroup) userGroupResponse { return userGroupResponse{ ID: group.ID, From e0d89da1462d4cf4c63f3811816df51c1b645e4c Mon Sep 17 00:00:00 2001 From: wecoding Date: Sun, 17 May 2026 14:30:26 +0800 Subject: [PATCH 3/3] fix(admin): filter assigned exam candidates globally --- .../pages/Examination/ExamDetail/index.tsx | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/pages/Examination/ExamDetail/index.tsx b/apps/admin/src/pages/Examination/ExamDetail/index.tsx index eb71a2c..d628c21 100644 --- a/apps/admin/src/pages/Examination/ExamDetail/index.tsx +++ b/apps/admin/src/pages/Examination/ExamDetail/index.tsx @@ -84,6 +84,9 @@ const ExamDetailContent: React.FC = () => { const [exam, setExam] = useState(null); const [examLoading, setExamLoading] = useState(false); const [sessions, setSessions] = useState([]); + const [assignedCandidateUserIDs, setAssignedCandidateUserIDs] = useState< + Set + >(new Set()); const [assignments, setAssignments] = useState([]); const sessionsActionRef = React.useRef(undefined); const resultsActionRef = React.useRef(undefined); @@ -170,6 +173,39 @@ const ExamDetailContent: React.FC = () => { } }, [examID]); + const loadAssignedCandidateUserIDs = React.useCallback(async () => { + if (!examID) return; + const pageSize = 100; + const collectUserIDs = ( + current: Set, + page: AdminExamSessionListResponse, + ) => { + page.items?.forEach((session) => { + current.add(session.user_id); + }); + }; + + try { + const firstPage = await fetchEnvelope( + `${API_PATHS.admin.examSessions(examID)}?${buildPagedQuery(1, pageSize)}`, + ); + const nextUserIDs = new Set(); + collectUserIDs(nextUserIDs, firstPage); + + const total = firstPage.total || 0; + const pageCount = Math.ceil(total / pageSize); + for (let page = 2; page <= pageCount; page += 1) { + const data = await fetchEnvelope( + `${API_PATHS.admin.examSessions(examID)}?${buildPagedQuery(page, pageSize)}`, + ); + collectUserIDs(nextUserIDs, data); + } + setAssignedCandidateUserIDs(nextUserIDs); + } catch (_error) { + setAssignedCandidateUserIDs(new Set()); + } + }, [examID]); + const refreshAll = React.useCallback(() => { loadExam(); loadAssignments(); @@ -195,8 +231,14 @@ const ExamDetailContent: React.FC = () => { }, [users]); const assignedUserIDs = useMemo( - () => new Set(sessions.map((session) => session.user_id)), - [sessions], + () => { + const ids = new Set(assignedCandidateUserIDs); + sessions.forEach((session) => { + ids.add(session.user_id); + }); + return ids; + }, + [assignedCandidateUserIDs, sessions], ); const assignmentSourceByUserID = useMemo(() => { @@ -234,7 +276,12 @@ const ExamDetailContent: React.FC = () => { setGroupCoverageCount(0); setAssignMode('users'); setAssignOpen(true); - await Promise.all([loadUsers(), loadUserGroups()]); + await Promise.all([ + loadUsers(), + loadUserGroups(), + loadAssignments(), + loadAssignedCandidateUserIDs(), + ]); }; const assignCandidates = async () => { @@ -271,6 +318,7 @@ const ExamDetailContent: React.FC = () => { loadExam(); sessionsActionRef.current?.reload(); loadAssignments(); + loadAssignedCandidateUserIDs(); } catch (error) { message.error( requestErrorMessage(error) || @@ -325,6 +373,7 @@ const ExamDetailContent: React.FC = () => { ); loadExam(); sessionsActionRef.current?.reload(); + loadAssignedCandidateUserIDs(); }, }); };