From 37e4837e3feff805af033203b1151e93cdfccf1c Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Wed, 27 May 2026 08:29:50 +0800 Subject: [PATCH] add more info of AdminVocabularyAudit --- server/models/Vocabulary.ts | 16 + server/routes/admin.ts | 395 ++++++++++++++- src/api/admin.ts | 135 +++++ src/components/AdminVocabularyScoreAudit.tsx | 494 +++++++++++++++++-- 4 files changed, 1006 insertions(+), 34 deletions(-) diff --git a/server/models/Vocabulary.ts b/server/models/Vocabulary.ts index 0ecea93..acfd95b 100644 --- a/server/models/Vocabulary.ts +++ b/server/models/Vocabulary.ts @@ -58,6 +58,10 @@ export interface IVocabularyTestRecord extends Document { }>; invalidated?: boolean; riskFlags?: string[]; + reviewDecision?: 'approved'; + reviewedBy?: mongoose.Types.ObjectId; + reviewedAt?: Date; + reviewNote?: string; createdAt: Date; } @@ -92,6 +96,10 @@ export interface IVocabularyTestAttempt extends Document { }; }>; riskFlags?: string[]; + reviewDecision?: 'approved'; + reviewedBy?: mongoose.Types.ObjectId; + reviewedAt?: Date; + reviewNote?: string; issuedAt: Date; expiresAt: Date; submittedAt?: Date; @@ -178,6 +186,10 @@ const VocabularyTestRecordSchema = new Schema({ }], invalidated: { type: Boolean, default: false }, riskFlags: [{ type: String }], + reviewDecision: { type: String, enum: ['approved'] }, + reviewedBy: { type: Schema.Types.ObjectId, ref: 'User' }, + reviewedAt: Date, + reviewNote: { type: String, trim: true, maxlength: 500 }, createdAt: { type: Date, default: Date.now } }); @@ -217,6 +229,10 @@ const VocabularyTestAttemptSchema = new Schema({ } }], riskFlags: [{ type: String }], + reviewDecision: { type: String, enum: ['approved'] }, + reviewedBy: { type: Schema.Types.ObjectId, ref: 'User' }, + reviewedAt: Date, + reviewNote: { type: String, trim: true, maxlength: 500 }, issuedAt: { type: Date, required: true }, expiresAt: { type: Date, required: true }, submittedAt: Date, diff --git a/server/routes/admin.ts b/server/routes/admin.ts index d27b4eb..307450f 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -69,6 +69,8 @@ const INVALIDATING_VOCABULARY_RISK_FLAGS = [ 'repeated_whole_second_intervals' ]; +const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || ''; + const parseAdminDateRange = (source: Record) => { const start = new Date(String(source.start || source.startTime || '')); const end = new Date(String(source.end || source.endTime || '')); @@ -152,11 +154,14 @@ const rebuildVocabularyWordRecordsForUser = async ( .map((record: any) => record.attempt?.toString?.() || '') .filter(id => mongoose.isValidObjectId(id)); const attempts = attemptIds.length > 0 - ? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }).select('riskFlags').lean() + ? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }).select('riskFlags reviewDecision').lean() : []; const invalidatedAttemptIds = new Set( attempts - .filter((attempt: any) => (attempt.riskFlags || []).some((flag: string) => INVALIDATING_VOCABULARY_RISK_FLAGS.includes(flag))) + .filter((attempt: any) => + attempt.reviewDecision !== 'approved' && + (attempt.riskFlags || []).some((flag: string) => INVALIDATING_VOCABULARY_RISK_FLAGS.includes(flag)) + ) .map((attempt: any) => attempt._id.toString()) ); @@ -211,6 +216,49 @@ const rebuildVocabularyWordRecordsForUser = async ( }; }; +const serializeUserRef = (value: any) => { + const id = getObjectIdString(value); + if (!id) return null; + + return { + _id: id, + username: value?.username || '', + fullname: value?.fullname || '', + email: value?.email || '' + }; +}; + +const serializeWordRef = (value: any) => { + const id = getObjectIdString(value); + if (!id) return null; + + return { + _id: id, + word: value?.word || '', + translation: value?.translation || '', + pronunciation: value?.pronunciation || '', + example: value?.example || '' + }; +}; + +const serializeWordSetRef = (value: any) => { + const id = getObjectIdString(value); + if (!id) return null; + + return { + _id: id, + name: value?.name || '', + description: value?.description || '' + }; +}; + +const flattenRiskFlags = (items: any[]): string[] => Array.from(new Set( + (items || []) + .flatMap(item => Array.isArray(item) ? item : [item]) + .filter(Boolean) + .map(item => String(item)) +)); + router.get('/users', adminAuth, async (req, res) => { try { const users = await User.find() @@ -496,7 +544,7 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => { } const { start, end } = range as { start: Date; end: Date }; - const items = await VocabularyTestRecord.aggregate([ + const validPassItems = await VocabularyTestRecord.aggregate([ { $match: { invalidated: { $ne: true }, @@ -546,11 +594,118 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => { { $sort: { passedWords: -1, lastPassedAt: -1 } } ]); + const recordItems = await VocabularyTestRecord.aggregate([ + { + $match: { + 'stats.endTime': { $gte: start, $lte: end } + } + }, + { + $group: { + _id: '$user', + totalRecords: { $sum: 1 }, + validRecords: { + $sum: { + $cond: [{ $eq: ['$invalidated', true] }, 0, 1] + } + }, + invalidatedRecords: { + $sum: { + $cond: [{ $eq: ['$invalidated', true] }, 1, 0] + } + }, + riskFlagSets: { $addToSet: '$riskFlags' }, + wordSetIds: { $addToSet: '$wordSet' }, + testTypes: { $addToSet: '$testType' }, + firstRecordAt: { $min: '$stats.endTime' }, + lastRecordAt: { $max: '$stats.endTime' } + } + }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'user' + } + }, + { $unwind: { path: '$user', preserveNullAndEmptyArrays: true } }, + { + $project: { + _id: 0, + userId: { $toString: '$_id' }, + username: '$user.username', + fullname: '$user.fullname', + email: '$user.email', + totalRecords: 1, + validRecords: 1, + invalidatedRecords: 1, + riskFlagSets: 1, + wordSets: { $size: '$wordSetIds' }, + testTypes: 1, + firstRecordAt: 1, + lastRecordAt: 1 + } + } + ]); + + const passMap = new Map(validPassItems.map((item: any) => [item.userId, item])); + const itemMap = new Map(); + + recordItems.forEach((recordItem: any) => { + const passItem = passMap.get(recordItem.userId) || {}; + itemMap.set(recordItem.userId, { + userId: recordItem.userId, + username: recordItem.username || passItem.username || '', + fullname: recordItem.fullname || passItem.fullname || '', + email: recordItem.email || passItem.email || '', + passedWords: passItem.passedWords || 0, + uniqueWords: passItem.uniqueWords || 0, + testRecords: passItem.testRecords || 0, + totalRecords: recordItem.totalRecords || 0, + validRecords: recordItem.validRecords || 0, + invalidatedRecords: recordItem.invalidatedRecords || 0, + wordSets: recordItem.wordSets || passItem.wordSets || 0, + testTypes: Array.from(new Set([...(recordItem.testTypes || []), ...(passItem.testTypes || [])])), + riskFlags: flattenRiskFlags(recordItem.riskFlagSets || []), + firstPassedAt: passItem.firstPassedAt, + lastPassedAt: passItem.lastPassedAt, + firstRecordAt: recordItem.firstRecordAt, + lastRecordAt: recordItem.lastRecordAt + }); + }); + + validPassItems.forEach((passItem: any) => { + if (itemMap.has(passItem.userId)) return; + itemMap.set(passItem.userId, { + ...passItem, + totalRecords: passItem.testRecords || 0, + validRecords: passItem.testRecords || 0, + invalidatedRecords: 0, + riskFlags: [], + firstRecordAt: passItem.firstPassedAt, + lastRecordAt: passItem.lastPassedAt + }); + }); + + const items = Array.from(itemMap.values()).sort((left, right) => { + const passedDelta = (right.passedWords || 0) - (left.passedWords || 0); + if (passedDelta !== 0) return passedDelta; + + const riskDelta = (right.invalidatedRecords || 0) - (left.invalidatedRecords || 0); + if (riskDelta !== 0) return riskDelta; + + return new Date(right.lastRecordAt || right.lastPassedAt || 0).getTime() - + new Date(left.lastRecordAt || left.lastPassedAt || 0).getTime(); + }); + res.json({ start, end, totalStudents: items.length, totalPassedWords: items.reduce((sum: number, item: any) => sum + (item.passedWords || 0), 0), + totalRecords: items.reduce((sum: number, item: any) => sum + (item.totalRecords || 0), 0), + invalidatedRecords: items.reduce((sum: number, item: any) => sum + (item.invalidatedRecords || 0), 0), items }); } catch (error) { @@ -559,6 +714,240 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => { } }); +// 查看某个学生在时间段内的词汇测试明细(包含风控 attempt 原始提交) +router.get('/vocabulary/test-pass-summary/:userId/details', adminAuth, async (req, res) => { + try { + const range = parseAdminDateRange(req.query as Record); + if (range.error) { + return res.status(400).json({ message: range.error }); + } + + const { start, end } = range as { start: Date; end: Date }; + const { userId } = req.params; + if (!mongoose.isValidObjectId(userId)) { + return res.status(400).json({ message: '学生ID无效' }); + } + + const user = await User.findById(userId).select('_id username fullname email').lean(); + if (!user) { + return res.status(404).json({ message: '学生不存在' }); + } + + const records = await VocabularyTestRecord.find({ + user: user._id, + 'stats.endTime': { $gte: start, $lte: end } + }) + .populate('wordSet', 'name description') + .populate('results.word', 'word translation pronunciation example') + .populate('reviewedBy', 'username fullname email') + .sort({ 'stats.endTime': -1, createdAt: -1 }) + .lean(); + + const attemptIds = records + .map((record: any) => getObjectIdString(record.attempt)) + .filter(id => mongoose.isValidObjectId(id)); + const attempts = attemptIds.length > 0 + ? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }) + .populate('answers.word', 'word translation pronunciation example') + .populate('reviewedBy', 'username fullname email') + .lean() + : []; + const attemptMap = new Map(attempts.map((attempt: any) => [attempt._id.toString(), attempt])); + + const recordDetails = records.map((record: any) => { + const attemptId = getObjectIdString(record.attempt); + const attempt = attemptMap.get(attemptId); + + return { + recordId: record._id.toString(), + attemptId, + wordSet: serializeWordSetRef(record.wordSet), + testType: record.testType, + stats: record.stats, + invalidated: Boolean(record.invalidated), + riskFlags: record.riskFlags || [], + reviewDecision: record.reviewDecision || attempt?.reviewDecision || '', + reviewedAt: record.reviewedAt || attempt?.reviewedAt || null, + reviewedBy: serializeUserRef(record.reviewedBy || attempt?.reviewedBy), + reviewNote: record.reviewNote || attempt?.reviewNote || '', + createdAt: record.createdAt, + results: (record.results || []).map((result: any, index: number) => ({ + index: index + 1, + wordId: getObjectIdString(result.word), + word: serializeWordRef(result.word), + userAnswer: result.userAnswer || '', + correctAnswer: result.correctAnswer || '', + isCorrect: Boolean(result.isCorrect) + })), + attempt: attempt ? { + attemptId, + status: attempt.status, + testType: attempt.testType, + wordSetId: getObjectIdString(attempt.wordSet), + issuedAt: attempt.issuedAt, + expiresAt: attempt.expiresAt, + submittedAt: attempt.submittedAt, + riskFlags: attempt.riskFlags || [], + reviewDecision: attempt.reviewDecision || '', + reviewedAt: attempt.reviewedAt || null, + reviewedBy: serializeUserRef(attempt.reviewedBy), + reviewNote: attempt.reviewNote || '', + questionWordIds: (attempt.questionWordIds || []).map((wordId: any) => getObjectIdString(wordId)), + questionTokens: attempt.questionTokens || [], + optionTokens: attempt.optionTokens || [], + optionTexts: attempt.optionTexts || [], + answeredQuestionTokens: attempt.answeredQuestionTokens || [], + answerDurations: attempt.answerDurations || [], + answers: (attempt.answers || []).map((answer: any, index: number) => ({ + index: index + 1, + questionToken: answer.questionToken || '', + wordId: getObjectIdString(answer.word), + word: serializeWordRef(answer.word), + userAnswer: answer.userAnswer || '', + correctAnswer: answer.correctAnswer || '', + isCorrect: Boolean(answer.isCorrect), + submittedAt: answer.submittedAt, + duration: answer.duration, + riskFlags: answer.riskFlags || [], + interactionSummary: answer.interactionSummary || null + })) + } : null + }; + }); + + res.json({ + start, + end, + user: { + userId: user._id.toString(), + username: user.username, + fullname: user.fullname, + email: user.email + }, + totalRecords: recordDetails.length, + totalPassedWords: recordDetails + .filter((record: any) => !record.invalidated) + .reduce((sum: number, record: any) => sum + Number(record.stats?.correctWords || 0), 0), + invalidatedRecords: recordDetails.filter((record: any) => record.invalidated).length, + approvedRecords: recordDetails.filter((record: any) => record.reviewDecision === 'approved').length, + records: recordDetails + }); + } catch (error) { + console.error('获取词汇测试明细失败:', error); + res.status(500).json({ message: '获取词汇测试明细失败' }); + } +}); + +// 人工将风控作废的词汇测试记录转为有效成绩 +router.post('/vocabulary/test-records/:recordId/approve', adminAuth, async (req, res) => { + try { + const { recordId } = req.params; + if (!mongoose.isValidObjectId(recordId)) { + return res.status(400).json({ message: '测试记录ID无效' }); + } + + const record: any = await VocabularyTestRecord.findById(recordId); + if (!record) { + return res.status(404).json({ message: '测试记录不存在' }); + } + + if (!record.invalidated) { + return res.json({ + message: '该词汇测试记录已经是有效成绩', + recordId, + alreadyEffective: true + }); + } + + const attemptId = getObjectIdString(record.attempt); + if (!mongoose.isValidObjectId(attemptId)) { + return res.status(400).json({ message: '该测试记录没有可用于恢复的 attempt 数据' }); + } + + const attempt: any = await VocabularyTestAttempt.findById(attemptId); + if (!attempt) { + return res.status(404).json({ message: '原始提交 attempt 不存在,无法转为有效成绩' }); + } + + const submittedAnswers = Array.isArray(attempt.answers) ? attempt.answers : []; + const restoredResults = submittedAnswers + .map((answer: any) => { + const wordId = getObjectIdString(answer.word); + if (!mongoose.isValidObjectId(wordId)) return null; + + return { + word: new mongoose.Types.ObjectId(wordId), + userAnswer: String(answer.userAnswer || ''), + correctAnswer: String(answer.correctAnswer || ''), + isCorrect: Boolean(answer.isCorrect) + }; + }) + .filter(Boolean) as Array<{ + word: mongoose.Types.ObjectId; + userAnswer: string; + correctAnswer: string; + isCorrect: boolean; + }>; + + if (restoredResults.length === 0) { + return res.status(400).json({ message: '该 attempt 没有可恢复的答题数据' }); + } + + const totalWords = restoredResults.length; + const correctWords = restoredResults.filter(result => result.isCorrect).length; + const reviewedAt = new Date(); + const reviewNote = String(req.body?.note || '').trim().slice(0, 500); + const userId = getObjectIdString(record.user); + const affectedWordIds = restoredResults.map(result => result.word.toString()); + + await VocabularyTestRecord.updateOne( + { _id: record._id }, + { + $set: { + invalidated: false, + results: restoredResults, + 'stats.totalWords': totalWords, + 'stats.correctWords': correctWords, + 'stats.accuracy': totalWords > 0 ? (correctWords / totalWords) * 100 : 0, + reviewDecision: 'approved', + reviewedBy: req.user._id, + reviewedAt, + reviewNote + } + } + ); + + await VocabularyTestAttempt.updateOne( + { _id: attempt._id }, + { + $set: { + reviewDecision: 'approved', + reviewedBy: req.user._id, + reviewedAt, + reviewNote + } + } + ); + + const rebuildResult = await rebuildVocabularyWordRecordsForUser(userId, affectedWordIds); + + res.json({ + message: '已将风控记录转为有效成绩', + recordId, + attemptId, + totalWords, + correctWords, + accuracy: totalWords > 0 ? (correctWords / totalWords) * 100 : 0, + riskFlags: Array.from(new Set([...(record.riskFlags || []), ...(attempt.riskFlags || [])])), + affectedWords: rebuildResult.affectedWords, + rebuiltWords: rebuildResult.rebuiltWords + }); + } catch (error) { + console.error('人工放行词汇测试记录失败:', error); + res.status(500).json({ message: '人工放行词汇测试记录失败' }); + } +}); + // 清除某个学生在指定时间段内的词汇测试成绩,并重算掌握状态 router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) => { try { diff --git a/src/api/admin.ts b/src/api/admin.ts index 4678902..3baaeae 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -102,10 +102,16 @@ export interface VocabularyPassSummaryItem { passedWords: number; uniqueWords: number; testRecords: number; + totalRecords?: number; + validRecords?: number; + invalidatedRecords?: number; wordSets: number; testTypes: string[]; + riskFlags?: string[]; firstPassedAt: string; lastPassedAt: string; + firstRecordAt?: string; + lastRecordAt?: string; } export interface VocabularyPassSummaryResponse { @@ -113,6 +119,8 @@ export interface VocabularyPassSummaryResponse { end: string; totalStudents: number; totalPassedWords: number; + totalRecords?: number; + invalidatedRecords?: number; items: VocabularyPassSummaryItem[]; } @@ -127,6 +135,124 @@ export interface ClearVocabularyPassSummaryResponse { rebuiltWords: number; } +export interface VocabularyAuditWordBrief { + _id: string; + word?: string; + translation?: string; + pronunciation?: string; + example?: string; +} + +export interface VocabularyAuditUserBrief { + _id?: string; + userId?: string; + username?: string; + fullname?: string; + email?: string; +} + +export interface VocabularyAuditInteractionSummary { + keyCount: number; + inputCount: number; + pasteCount: number; + focusCount: number; + blurCount: number; + pointerCount: number; + firstEventOffset: number; + lastEventOffset: number; +} + +export interface VocabularyAuditResultDetail { + index: number; + wordId: string; + word: VocabularyAuditWordBrief | null; + userAnswer: string; + correctAnswer: string; + isCorrect: boolean; +} + +export interface VocabularyAuditAnswerDetail extends VocabularyAuditResultDetail { + questionToken: string; + submittedAt?: string; + duration?: number; + riskFlags: string[]; + interactionSummary?: VocabularyAuditInteractionSummary | null; +} + +export interface VocabularyAuditAttemptDetail { + attemptId: string; + status: string; + testType: string; + wordSetId: string; + issuedAt?: string; + expiresAt?: string; + submittedAt?: string; + riskFlags: string[]; + reviewDecision?: string; + reviewedAt?: string; + reviewedBy?: VocabularyAuditUserBrief | null; + reviewNote?: string; + questionWordIds: string[]; + questionTokens: string[]; + optionTokens: string[][]; + optionTexts: string[][]; + answeredQuestionTokens: string[]; + answerDurations: number[]; + answers: VocabularyAuditAnswerDetail[]; +} + +export interface VocabularyAuditRecordDetail { + recordId: string; + attemptId: string; + wordSet: { + _id: string; + name?: string; + description?: string; + } | null; + testType: string; + stats: { + totalWords: number; + correctWords: number; + accuracy: number; + startTime: string; + endTime: string; + duration: number; + }; + invalidated: boolean; + riskFlags: string[]; + reviewDecision?: string; + reviewedAt?: string; + reviewedBy?: VocabularyAuditUserBrief | null; + reviewNote?: string; + createdAt: string; + results: VocabularyAuditResultDetail[]; + attempt: VocabularyAuditAttemptDetail | null; +} + +export interface VocabularyPassSummaryDetailsResponse { + start: string; + end: string; + user: VocabularyAuditUserBrief; + totalRecords: number; + totalPassedWords: number; + invalidatedRecords: number; + approvedRecords: number; + records: VocabularyAuditRecordDetail[]; +} + +export interface ApproveVocabularyTestRecordResponse { + message: string; + recordId: string; + attemptId?: string; + totalWords?: number; + correctWords?: number; + accuracy?: number; + riskFlags?: string[]; + affectedWords?: number; + rebuiltWords?: number; + alreadyEffective?: boolean; +} + export const adminApi = { getUsers: async (): Promise => { return api.get('/admin/users'); @@ -217,4 +343,13 @@ export const adminApi = { clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise => { return api.post('/admin/vocabulary/test-pass-summary/clear', data); }, + + getVocabularyPassSummaryDetails: async (params: { userId: string; start: string; end: string }): Promise => { + const { userId, ...query } = params; + return api.get(`/admin/vocabulary/test-pass-summary/${userId}/details`, { params: query }); + }, + + approveVocabularyTestRecord: async (recordId: string, data: { note?: string } = {}): Promise => { + return api.post(`/admin/vocabulary/test-records/${recordId}/approve`, data); + }, }; diff --git a/src/components/AdminVocabularyScoreAudit.tsx b/src/components/AdminVocabularyScoreAudit.tsx index 5bb83f2..1401e3c 100644 --- a/src/components/AdminVocabularyScoreAudit.tsx +++ b/src/components/AdminVocabularyScoreAudit.tsx @@ -1,8 +1,15 @@ import React, { useState } from 'react'; -import { Button, Card, DatePicker, message, Popconfirm, Space, Statistic, Table, Tag, Typography } from 'antd'; +import { Button, Card, Collapse, DatePicker, Descriptions, message, Modal, Popconfirm, Space, Statistic, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { DeleteOutlined, SearchOutlined } from '@ant-design/icons'; -import { adminApi, VocabularyPassSummaryItem, VocabularyPassSummaryResponse } from '../api/admin'; +import { CheckCircleOutlined, DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons'; +import { + adminApi, + VocabularyAuditAnswerDetail, + VocabularyAuditRecordDetail, + VocabularyPassSummaryDetailsResponse, + VocabularyPassSummaryItem, + VocabularyPassSummaryResponse +} from '../api/admin'; const { RangePicker } = DatePicker; @@ -24,11 +31,76 @@ const formatDateTime = (value?: string) => { return new Date(value).toLocaleString('zh-CN'); }; +const formatDuration = (value?: number) => { + if (typeof value !== 'number' || Number.isNaN(value)) return '-'; + return `${Math.round(value * 10) / 10} 秒`; +}; + +const renderCopyableId = (value?: string) => { + if (!value) return '-'; + return ( + + {value} + + ); +}; + +const renderRiskTags = (flags?: string[]) => { + const uniqueFlags = Array.from(new Set((flags || []).filter(Boolean))); + if (uniqueFlags.length === 0) return -; + + return ( + + {uniqueFlags.map(flag => ( + {flag} + ))} + + ); +}; + +const renderJsonBlock = (value: any) => ( +
+    {JSON.stringify(value ?? null, null, 2)}
+  
+); + +const getRecordRiskFlags = (record: VocabularyAuditRecordDetail) => Array.from(new Set([ + ...(record.riskFlags || []), + ...(record.attempt?.riskFlags || []) +])); + +const getAnswerRows = (record: VocabularyAuditRecordDetail): VocabularyAuditAnswerDetail[] => { + if (record.attempt?.answers?.length) return record.attempt.answers; + + return record.results.map(result => ({ + ...result, + questionToken: '', + riskFlags: [], + interactionSummary: null + })); +}; + const AdminVocabularyScoreAudit: React.FC = () => { const [range, setRange] = useState<[string, string] | null>(null); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [clearingUserId, setClearingUserId] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [selectedStudent, setSelectedStudent] = useState(null); + const [details, setDetails] = useState(null); + const [approvingRecordId, setApprovingRecordId] = useState(null); const fetchSummary = async () => { if (!range) { @@ -51,6 +123,54 @@ const AdminVocabularyScoreAudit: React.FC = () => { } }; + const fetchStudentDetails = async (row: VocabularyPassSummaryItem) => { + if (!range) return; + + try { + setDetailLoading(true); + const response = await adminApi.getVocabularyPassSummaryDetails({ + userId: row.userId, + start: range[0], + end: range[1] + }); + setDetails(response); + } catch (error) { + console.error('获取词汇测试明细失败:', error); + message.error('获取词汇测试明细失败'); + } finally { + setDetailLoading(false); + } + }; + + const openStudentDetails = async (row: VocabularyPassSummaryItem) => { + setSelectedStudent(row); + setDetails(null); + setDetailOpen(true); + await fetchStudentDetails(row); + }; + + const approveRecord = async (record: VocabularyAuditRecordDetail) => { + try { + setApprovingRecordId(record.recordId); + const response = await adminApi.approveVocabularyTestRecord(record.recordId); + if (response.alreadyEffective) { + message.info(response.message); + } else { + message.success(`已恢复为有效成绩:${response.correctWords || 0}/${response.totalWords || 0}`); + } + + if (selectedStudent) { + await fetchStudentDetails(selectedStudent); + } + await fetchSummary(); + } catch (error) { + console.error('人工放行词汇测试记录失败:', error); + message.error('人工放行词汇测试记录失败'); + } finally { + setApprovingRecordId(null); + } + }; + const clearStudentScores = async (row: VocabularyPassSummaryItem) => { if (!range) return; @@ -63,6 +183,9 @@ const AdminVocabularyScoreAudit: React.FC = () => { }); message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`); await fetchSummary(); + if (selectedStudent?.userId === row.userId) { + await fetchStudentDetails(row); + } } catch (error) { console.error('清除词汇测试成绩失败:', error); message.error('清除词汇测试成绩失败'); @@ -71,6 +194,247 @@ const AdminVocabularyScoreAudit: React.FC = () => { } }; + const answerColumns: ColumnsType = [ + { + title: '#', + dataIndex: 'index', + key: 'index', + width: 56 + }, + { + title: '单词', + key: 'word', + width: 180, + render: (_, row) => ( +
+
{row.word?.word || row.wordId || '-'}
+ {row.word?.translation || '-'} +
+ ) + }, + { + title: '提交答案', + dataIndex: 'userAnswer', + key: 'userAnswer', + width: 180 + }, + { + title: '正确答案', + dataIndex: 'correctAnswer', + key: 'correctAnswer', + width: 180 + }, + { + title: '结果', + dataIndex: 'isCorrect', + key: 'isCorrect', + width: 96, + render: (isCorrect: boolean) => ( + {isCorrect ? '正确' : '错误'} + ) + }, + { + title: '用时', + dataIndex: 'duration', + key: 'duration', + width: 96, + render: formatDuration + }, + { + title: '提交时间', + dataIndex: 'submittedAt', + key: 'submittedAt', + width: 180, + render: formatDateTime + }, + { + title: '题目 Token', + dataIndex: 'questionToken', + key: 'questionToken', + width: 200, + render: renderCopyableId + }, + { + title: 'RiskFlags', + dataIndex: 'riskFlags', + key: 'riskFlags', + width: 220, + render: renderRiskTags + }, + { + title: '交互摘要', + dataIndex: 'interactionSummary', + key: 'interactionSummary', + width: 260, + render: summary => { + if (!summary) return -; + return ( + + key {summary.keyCount} + input {summary.inputCount} + paste {summary.pasteCount} + pointer {summary.pointerCount} + focus {summary.focusCount} + blur {summary.blurCount} + + ); + } + } + ]; + + const renderRecordExpanded = (record: VocabularyAuditRecordDetail) => { + const recordJson = { + recordId: record.recordId, + attemptId: record.attemptId, + wordSet: record.wordSet, + testType: record.testType, + stats: record.stats, + invalidated: record.invalidated, + riskFlags: record.riskFlags, + reviewDecision: record.reviewDecision, + reviewedAt: record.reviewedAt, + reviewedBy: record.reviewedBy, + reviewNote: record.reviewNote, + createdAt: record.createdAt, + results: record.results + }; + + return ( + + + {renderCopyableId(record.recordId)} + {renderCopyableId(record.attemptId)} + {record.wordSet?.name || record.wordSet?._id || '-'} + {formatDateTime(record.stats?.startTime)} + {formatDateTime(record.stats?.endTime)} + {formatDuration(record.stats?.duration)} + {`${record.stats?.correctWords || 0}/${record.stats?.totalWords || 0}`} + {`${Math.round((record.stats?.accuracy || 0) * 10) / 10}%`} + + {record.reviewDecision === 'approved' ? ( + + 已放行 + + {record.reviewedBy?.fullname || record.reviewedBy?.username || ''} + + {formatDateTime(record.reviewedAt)} + + ) : ( + - + )} + + {renderRiskTags(record.riskFlags)} + {renderRiskTags(record.attempt?.riskFlags)} + + + `${record.recordId}-${row.index}-${row.questionToken || row.wordId}`} + pagination={false} + scroll={{ x: 1600 }} + /> + + + + ); + }; + + const detailColumns: ColumnsType = [ + { + title: '时间', + dataIndex: ['stats', 'endTime'], + key: 'endTime', + width: 180, + render: formatDateTime, + sorter: (a, b) => new Date(a.stats?.endTime || 0).getTime() - new Date(b.stats?.endTime || 0).getTime(), + defaultSortOrder: 'descend' + }, + { + title: '状态', + key: 'status', + width: 120, + render: (_, record) => { + if (record.invalidated) return 风控作废; + if (record.reviewDecision === 'approved') return 人工有效; + return 有效; + } + }, + { + title: '题型', + dataIndex: 'testType', + key: 'testType', + width: 150, + render: (type: string) => {TEST_TYPE_LABELS[type] || type} + }, + { + title: '单词集', + key: 'wordSet', + width: 160, + render: (_, record) => record.wordSet?.name || record.wordSet?._id || '-' + }, + { + title: '成绩', + key: 'score', + width: 110, + sorter: (a, b) => (a.stats?.correctWords || 0) - (b.stats?.correctWords || 0), + render: (_, record) => {record.stats?.correctWords || 0}/{record.stats?.totalWords || 0} + }, + { + title: 'Attempt ID', + dataIndex: 'attemptId', + key: 'attemptId', + width: 200, + render: renderCopyableId + }, + { + title: 'RiskFlags', + key: 'riskFlags', + width: 260, + render: (_, record) => renderRiskTags(getRecordRiskFlags(record)) + }, + { + title: '操作', + key: 'actions', + fixed: 'right', + width: 150, + render: (_, record) => record.invalidated ? ( + approveRecord(record)} + > + + + ) : ( + - + ) + } + ]; + const columns: ColumnsType = [ { title: '学生', @@ -83,7 +447,7 @@ const AdminVocabularyScoreAudit: React.FC = () => { ) }, { - title: '通过单词数', + title: '有效通过', dataIndex: 'passedWords', key: 'passedWords', sorter: (a, b) => a.passedWords - b.passedWords, @@ -91,16 +455,34 @@ const AdminVocabularyScoreAudit: React.FC = () => { render: (value: number) => {value} }, { - title: '去重单词数', + title: '去重单词', dataIndex: 'uniqueWords', key: 'uniqueWords', sorter: (a, b) => a.uniqueWords - b.uniqueWords }, { title: '测试记录', - dataIndex: 'testRecords', - key: 'testRecords', - sorter: (a, b) => a.testRecords - b.testRecords + key: 'records', + sorter: (a, b) => (a.totalRecords || a.testRecords || 0) - (b.totalRecords || b.testRecords || 0), + render: (_, row) => ( + + 有效 {row.validRecords ?? row.testRecords ?? 0} + 全部 {row.totalRecords || row.testRecords || 0} + + ) + }, + { + title: '风控', + key: 'risk', + sorter: (a, b) => (a.invalidatedRecords || 0) - (b.invalidatedRecords || 0), + render: (_, row) => ( + + 0 ? 'red' : 'green'}> + 作废 {row.invalidatedRecords || 0} + + {renderRiskTags(row.riskFlags)} + + ) }, { title: '题型', @@ -115,37 +497,40 @@ const AdminVocabularyScoreAudit: React.FC = () => { ) }, { - title: '首次通过', - dataIndex: 'firstPassedAt', - key: 'firstPassedAt', - render: formatDateTime + title: '首次记录', + key: 'firstRecordAt', + render: (_, row) => formatDateTime(row.firstRecordAt || row.firstPassedAt) }, { - title: '最后通过', - dataIndex: 'lastPassedAt', - key: 'lastPassedAt', - render: formatDateTime + title: '最后记录', + key: 'lastRecordAt', + render: (_, row) => formatDateTime(row.lastRecordAt || row.lastPassedAt) }, { title: '操作', key: 'actions', render: (_, row) => ( - clearStudentScores(row)} - > - - + clearStudentScores(row)} + > + + + ) } ]; @@ -174,6 +559,8 @@ const AdminVocabularyScoreAudit: React.FC = () => { + + @@ -184,6 +571,7 @@ const AdminVocabularyScoreAudit: React.FC = () => { dataSource={summary?.items || []} rowKey="userId" loading={loading} + scroll={{ x: 1280 }} pagination={{ defaultPageSize: 10, showSizeChanger: true, @@ -192,6 +580,50 @@ const AdminVocabularyScoreAudit: React.FC = () => { }} /> + + { + setDetailOpen(false); + setDetails(null); + setSelectedStudent(null); + }} + > + + {details && ( + + + + + + + + + )} + +
Boolean(record.attempt || record.results.length > 0) + }} + scroll={{ x: 1300 }} + pagination={{ + defaultPageSize: 5, + showSizeChanger: true, + showTotal: total => `共 ${total} 条记录` + }} + /> + + ); };