add more info of AdminVocabularyAudit
This commit is contained in:
@@ -58,6 +58,10 @@ export interface IVocabularyTestRecord extends Document {
|
|||||||
}>;
|
}>;
|
||||||
invalidated?: boolean;
|
invalidated?: boolean;
|
||||||
riskFlags?: string[];
|
riskFlags?: string[];
|
||||||
|
reviewDecision?: 'approved';
|
||||||
|
reviewedBy?: mongoose.Types.ObjectId;
|
||||||
|
reviewedAt?: Date;
|
||||||
|
reviewNote?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +96,10 @@ export interface IVocabularyTestAttempt extends Document {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
riskFlags?: string[];
|
riskFlags?: string[];
|
||||||
|
reviewDecision?: 'approved';
|
||||||
|
reviewedBy?: mongoose.Types.ObjectId;
|
||||||
|
reviewedAt?: Date;
|
||||||
|
reviewNote?: string;
|
||||||
issuedAt: Date;
|
issuedAt: Date;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
submittedAt?: Date;
|
submittedAt?: Date;
|
||||||
@@ -178,6 +186,10 @@ const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
|||||||
}],
|
}],
|
||||||
invalidated: { type: Boolean, default: false },
|
invalidated: { type: Boolean, default: false },
|
||||||
riskFlags: [{ type: String }],
|
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 }
|
createdAt: { type: Date, default: Date.now }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,6 +229,10 @@ const VocabularyTestAttemptSchema = new Schema<IVocabularyTestAttempt>({
|
|||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
riskFlags: [{ type: String }],
|
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 },
|
issuedAt: { type: Date, required: true },
|
||||||
expiresAt: { type: Date, required: true },
|
expiresAt: { type: Date, required: true },
|
||||||
submittedAt: Date,
|
submittedAt: Date,
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ const INVALIDATING_VOCABULARY_RISK_FLAGS = [
|
|||||||
'repeated_whole_second_intervals'
|
'repeated_whole_second_intervals'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||||
|
|
||||||
const parseAdminDateRange = (source: Record<string, any>) => {
|
const parseAdminDateRange = (source: Record<string, any>) => {
|
||||||
const start = new Date(String(source.start || source.startTime || ''));
|
const start = new Date(String(source.start || source.startTime || ''));
|
||||||
const end = new Date(String(source.end || source.endTime || ''));
|
const end = new Date(String(source.end || source.endTime || ''));
|
||||||
@@ -152,11 +154,14 @@ const rebuildVocabularyWordRecordsForUser = async (
|
|||||||
.map((record: any) => record.attempt?.toString?.() || '')
|
.map((record: any) => record.attempt?.toString?.() || '')
|
||||||
.filter(id => mongoose.isValidObjectId(id));
|
.filter(id => mongoose.isValidObjectId(id));
|
||||||
const attempts = attemptIds.length > 0
|
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(
|
const invalidatedAttemptIds = new Set(
|
||||||
attempts
|
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())
|
.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) => {
|
router.get('/users', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await User.find()
|
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 { start, end } = range as { start: Date; end: Date };
|
||||||
const items = await VocabularyTestRecord.aggregate([
|
const validPassItems = await VocabularyTestRecord.aggregate([
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
invalidated: { $ne: true },
|
invalidated: { $ne: true },
|
||||||
@@ -546,11 +594,118 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
|||||||
{ $sort: { passedWords: -1, lastPassedAt: -1 } }
|
{ $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<string, any>();
|
||||||
|
|
||||||
|
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({
|
res.json({
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
totalStudents: items.length,
|
totalStudents: items.length,
|
||||||
totalPassedWords: items.reduce((sum: number, item: any) => sum + (item.passedWords || 0), 0),
|
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
|
items
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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<string, any>);
|
||||||
|
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) => {
|
router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
135
src/api/admin.ts
135
src/api/admin.ts
@@ -102,10 +102,16 @@ export interface VocabularyPassSummaryItem {
|
|||||||
passedWords: number;
|
passedWords: number;
|
||||||
uniqueWords: number;
|
uniqueWords: number;
|
||||||
testRecords: number;
|
testRecords: number;
|
||||||
|
totalRecords?: number;
|
||||||
|
validRecords?: number;
|
||||||
|
invalidatedRecords?: number;
|
||||||
wordSets: number;
|
wordSets: number;
|
||||||
testTypes: string[];
|
testTypes: string[];
|
||||||
|
riskFlags?: string[];
|
||||||
firstPassedAt: string;
|
firstPassedAt: string;
|
||||||
lastPassedAt: string;
|
lastPassedAt: string;
|
||||||
|
firstRecordAt?: string;
|
||||||
|
lastRecordAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VocabularyPassSummaryResponse {
|
export interface VocabularyPassSummaryResponse {
|
||||||
@@ -113,6 +119,8 @@ export interface VocabularyPassSummaryResponse {
|
|||||||
end: string;
|
end: string;
|
||||||
totalStudents: number;
|
totalStudents: number;
|
||||||
totalPassedWords: number;
|
totalPassedWords: number;
|
||||||
|
totalRecords?: number;
|
||||||
|
invalidatedRecords?: number;
|
||||||
items: VocabularyPassSummaryItem[];
|
items: VocabularyPassSummaryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +135,124 @@ export interface ClearVocabularyPassSummaryResponse {
|
|||||||
rebuiltWords: number;
|
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 = {
|
export const adminApi = {
|
||||||
getUsers: async (): Promise<User[]> => {
|
getUsers: async (): Promise<User[]> => {
|
||||||
return api.get<User[]>('/admin/users');
|
return api.get<User[]>('/admin/users');
|
||||||
@@ -217,4 +343,13 @@ export const adminApi = {
|
|||||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||||
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getVocabularyPassSummaryDetails: async (params: { userId: string; start: string; end: string }): Promise<VocabularyPassSummaryDetailsResponse> => {
|
||||||
|
const { userId, ...query } = params;
|
||||||
|
return api.get<VocabularyPassSummaryDetailsResponse>(`/admin/vocabulary/test-pass-summary/${userId}/details`, { params: query });
|
||||||
|
},
|
||||||
|
|
||||||
|
approveVocabularyTestRecord: async (recordId: string, data: { note?: string } = {}): Promise<ApproveVocabularyTestRecordResponse> => {
|
||||||
|
return api.post<ApproveVocabularyTestRecordResponse>(`/admin/vocabulary/test-records/${recordId}/approve`, data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
import { CheckCircleOutlined, DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { adminApi, VocabularyPassSummaryItem, VocabularyPassSummaryResponse } from '../api/admin';
|
import {
|
||||||
|
adminApi,
|
||||||
|
VocabularyAuditAnswerDetail,
|
||||||
|
VocabularyAuditRecordDetail,
|
||||||
|
VocabularyPassSummaryDetailsResponse,
|
||||||
|
VocabularyPassSummaryItem,
|
||||||
|
VocabularyPassSummaryResponse
|
||||||
|
} from '../api/admin';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -24,11 +31,76 @@ const formatDateTime = (value?: string) => {
|
|||||||
return new Date(value).toLocaleString('zh-CN');
|
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 (
|
||||||
|
<Typography.Text copyable ellipsis style={{ display: 'inline-block', maxWidth: 180 }}>
|
||||||
|
{value}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRiskTags = (flags?: string[]) => {
|
||||||
|
const uniqueFlags = Array.from(new Set((flags || []).filter(Boolean)));
|
||||||
|
if (uniqueFlags.length === 0) return <Typography.Text type="secondary">-</Typography.Text>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{uniqueFlags.map(flag => (
|
||||||
|
<Tag key={flag} color="orange">{flag}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderJsonBlock = (value: any) => (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: 12,
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(value ?? null, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 AdminVocabularyScoreAudit: React.FC = () => {
|
||||||
const [range, setRange] = useState<[string, string] | null>(null);
|
const [range, setRange] = useState<[string, string] | null>(null);
|
||||||
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
|
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedStudent, setSelectedStudent] = useState<VocabularyPassSummaryItem | null>(null);
|
||||||
|
const [details, setDetails] = useState<VocabularyPassSummaryDetailsResponse | null>(null);
|
||||||
|
const [approvingRecordId, setApprovingRecordId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchSummary = async () => {
|
const fetchSummary = async () => {
|
||||||
if (!range) {
|
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) => {
|
const clearStudentScores = async (row: VocabularyPassSummaryItem) => {
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
|
|
||||||
@@ -63,6 +183,9 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
});
|
});
|
||||||
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
||||||
await fetchSummary();
|
await fetchSummary();
|
||||||
|
if (selectedStudent?.userId === row.userId) {
|
||||||
|
await fetchStudentDetails(row);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('清除词汇测试成绩失败:', error);
|
console.error('清除词汇测试成绩失败:', error);
|
||||||
message.error('清除词汇测试成绩失败');
|
message.error('清除词汇测试成绩失败');
|
||||||
@@ -71,6 +194,247 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const answerColumns: ColumnsType<VocabularyAuditAnswerDetail> = [
|
||||||
|
{
|
||||||
|
title: '#',
|
||||||
|
dataIndex: 'index',
|
||||||
|
key: 'index',
|
||||||
|
width: 56
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单词',
|
||||||
|
key: 'word',
|
||||||
|
width: 180,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{row.word?.word || row.wordId || '-'}</div>
|
||||||
|
<Typography.Text type="secondary">{row.word?.translation || '-'}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交答案',
|
||||||
|
dataIndex: 'userAnswer',
|
||||||
|
key: 'userAnswer',
|
||||||
|
width: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '正确答案',
|
||||||
|
dataIndex: 'correctAnswer',
|
||||||
|
key: 'correctAnswer',
|
||||||
|
width: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结果',
|
||||||
|
dataIndex: 'isCorrect',
|
||||||
|
key: 'isCorrect',
|
||||||
|
width: 96,
|
||||||
|
render: (isCorrect: boolean) => (
|
||||||
|
<Tag color={isCorrect ? 'green' : 'red'}>{isCorrect ? '正确' : '错误'}</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 <Typography.Text type="secondary">-</Typography.Text>;
|
||||||
|
return (
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
<Tag>key {summary.keyCount}</Tag>
|
||||||
|
<Tag>input {summary.inputCount}</Tag>
|
||||||
|
<Tag>paste {summary.pasteCount}</Tag>
|
||||||
|
<Tag>pointer {summary.pointerCount}</Tag>
|
||||||
|
<Tag>focus {summary.focusCount}</Tag>
|
||||||
|
<Tag>blur {summary.blurCount}</Tag>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
|
<Descriptions.Item label="记录ID">{renderCopyableId(record.recordId)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Attempt ID">{renderCopyableId(record.attemptId)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="单词集">{record.wordSet?.name || record.wordSet?._id || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开始时间">{formatDateTime(record.stats?.startTime)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结束时间">{formatDateTime(record.stats?.endTime)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时长">{formatDuration(record.stats?.duration)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="成绩">{`${record.stats?.correctWords || 0}/${record.stats?.totalWords || 0}`}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="正确率">{`${Math.round((record.stats?.accuracy || 0) * 10) / 10}%`}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="人工审核">
|
||||||
|
{record.reviewDecision === 'approved' ? (
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="green">已放行</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{record.reviewedBy?.fullname || record.reviewedBy?.username || ''}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">{formatDateTime(record.reviewedAt)}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">-</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="记录 RiskFlags" span={3}>{renderRiskTags(record.riskFlags)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Attempt RiskFlags" span={3}>{renderRiskTags(record.attempt?.riskFlags)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
columns={answerColumns}
|
||||||
|
dataSource={getAnswerRows(record)}
|
||||||
|
rowKey={row => `${record.recordId}-${row.index}-${row.questionToken || row.wordId}`}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1600 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'record-json',
|
||||||
|
label: '成绩记录 JSON',
|
||||||
|
children: renderJsonBlock(recordJson)
|
||||||
|
},
|
||||||
|
...(record.attempt ? [{
|
||||||
|
key: 'attempt-json',
|
||||||
|
label: 'Attempt JSON',
|
||||||
|
children: renderJsonBlock(record.attempt)
|
||||||
|
}] : [])
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailColumns: ColumnsType<VocabularyAuditRecordDetail> = [
|
||||||
|
{
|
||||||
|
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 <Tag color="red">风控作废</Tag>;
|
||||||
|
if (record.reviewDecision === 'approved') return <Tag color="green">人工有效</Tag>;
|
||||||
|
return <Tag color="blue">有效</Tag>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '题型',
|
||||||
|
dataIndex: 'testType',
|
||||||
|
key: 'testType',
|
||||||
|
width: 150,
|
||||||
|
render: (type: string) => <Tag color="blue">{TEST_TYPE_LABELS[type] || type}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => <Tag color="green">{record.stats?.correctWords || 0}/{record.stats?.totalWords || 0}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认将该风控记录转为有效成绩?"
|
||||||
|
description="会按原始 attempt 答案恢复正确率,并重算这些单词的掌握状态。"
|
||||||
|
okText="确认放行"
|
||||||
|
cancelText="取消"
|
||||||
|
onConfirm={() => approveRecord(record)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
loading={approvingRecordId === record.recordId}
|
||||||
|
>
|
||||||
|
转有效
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">-</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const columns: ColumnsType<VocabularyPassSummaryItem> = [
|
const columns: ColumnsType<VocabularyPassSummaryItem> = [
|
||||||
{
|
{
|
||||||
title: '学生',
|
title: '学生',
|
||||||
@@ -83,7 +447,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '通过单词数',
|
title: '有效通过',
|
||||||
dataIndex: 'passedWords',
|
dataIndex: 'passedWords',
|
||||||
key: 'passedWords',
|
key: 'passedWords',
|
||||||
sorter: (a, b) => a.passedWords - b.passedWords,
|
sorter: (a, b) => a.passedWords - b.passedWords,
|
||||||
@@ -91,16 +455,34 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
render: (value: number) => <Tag color="green">{value}</Tag>
|
render: (value: number) => <Tag color="green">{value}</Tag>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '去重单词数',
|
title: '去重单词',
|
||||||
dataIndex: 'uniqueWords',
|
dataIndex: 'uniqueWords',
|
||||||
key: 'uniqueWords',
|
key: 'uniqueWords',
|
||||||
sorter: (a, b) => a.uniqueWords - b.uniqueWords
|
sorter: (a, b) => a.uniqueWords - b.uniqueWords
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '测试记录',
|
title: '测试记录',
|
||||||
dataIndex: 'testRecords',
|
key: 'records',
|
||||||
key: 'testRecords',
|
sorter: (a, b) => (a.totalRecords || a.testRecords || 0) - (b.totalRecords || b.testRecords || 0),
|
||||||
sorter: (a, b) => a.testRecords - b.testRecords
|
render: (_, row) => (
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
<Tag color="blue">有效 {row.validRecords ?? row.testRecords ?? 0}</Tag>
|
||||||
|
<Tag>全部 {row.totalRecords || row.testRecords || 0}</Tag>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '风控',
|
||||||
|
key: 'risk',
|
||||||
|
sorter: (a, b) => (a.invalidatedRecords || 0) - (b.invalidatedRecords || 0),
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<Tag color={(row.invalidatedRecords || 0) > 0 ? 'red' : 'green'}>
|
||||||
|
作废 {row.invalidatedRecords || 0}
|
||||||
|
</Tag>
|
||||||
|
{renderRiskTags(row.riskFlags)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '题型',
|
title: '题型',
|
||||||
@@ -115,21 +497,23 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '首次通过',
|
title: '首次记录',
|
||||||
dataIndex: 'firstPassedAt',
|
key: 'firstRecordAt',
|
||||||
key: 'firstPassedAt',
|
render: (_, row) => formatDateTime(row.firstRecordAt || row.firstPassedAt)
|
||||||
render: formatDateTime
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '最后通过',
|
title: '最后记录',
|
||||||
dataIndex: 'lastPassedAt',
|
key: 'lastRecordAt',
|
||||||
key: 'lastPassedAt',
|
render: (_, row) => formatDateTime(row.lastRecordAt || row.lastPassedAt)
|
||||||
render: formatDateTime
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button icon={<EyeOutlined />} onClick={() => openStudentDetails(row)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确认清除该学生在所选时间段内的词汇测试成绩?"
|
title="确认清除该学生在所选时间段内的词汇测试成绩?"
|
||||||
description="会删除对应测试记录,并重算这些单词的掌握状态。"
|
description="会删除对应测试记录,并重算这些单词的掌握状态。"
|
||||||
@@ -146,6 +530,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
清除成绩
|
清除成绩
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -174,6 +559,8 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Statistic title="学生数" value={summary.totalStudents} />
|
<Statistic title="学生数" value={summary.totalStudents} />
|
||||||
<Statistic title="通过单词总数" value={summary.totalPassedWords} />
|
<Statistic title="通过单词总数" value={summary.totalPassedWords} />
|
||||||
|
<Statistic title="测试记录" value={summary.totalRecords || 0} />
|
||||||
|
<Statistic title="风控作废" value={summary.invalidatedRecords || 0} />
|
||||||
<Statistic title="开始时间" value={formatDateTime(summary.start)} />
|
<Statistic title="开始时间" value={formatDateTime(summary.start)} />
|
||||||
<Statistic title="结束时间" value={formatDateTime(summary.end)} />
|
<Statistic title="结束时间" value={formatDateTime(summary.end)} />
|
||||||
</Space>
|
</Space>
|
||||||
@@ -184,6 +571,7 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
dataSource={summary?.items || []}
|
dataSource={summary?.items || []}
|
||||||
rowKey="userId"
|
rowKey="userId"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
scroll={{ x: 1280 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 10,
|
defaultPageSize: 10,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
@@ -192,6 +580,50 @@ const AdminVocabularyScoreAudit: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`词汇提交明细 - ${details?.user.fullname || details?.user.username || selectedStudent?.fullname || selectedStudent?.username || ''}`}
|
||||||
|
open={detailOpen}
|
||||||
|
width={1280}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
onCancel={() => {
|
||||||
|
setDetailOpen(false);
|
||||||
|
setDetails(null);
|
||||||
|
setSelectedStudent(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%', maxHeight: '72vh', overflow: 'auto' }}>
|
||||||
|
{details && (
|
||||||
|
<Space wrap>
|
||||||
|
<Statistic title="测试记录" value={details.totalRecords} />
|
||||||
|
<Statistic title="有效通过单词" value={details.totalPassedWords} />
|
||||||
|
<Statistic title="风控作废" value={details.invalidatedRecords} />
|
||||||
|
<Statistic title="人工放行" value={details.approvedRecords} />
|
||||||
|
<Statistic title="开始时间" value={formatDateTime(details.start)} />
|
||||||
|
<Statistic title="结束时间" value={formatDateTime(details.end)} />
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
columns={detailColumns}
|
||||||
|
dataSource={details?.records || []}
|
||||||
|
rowKey="recordId"
|
||||||
|
loading={detailLoading}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: renderRecordExpanded,
|
||||||
|
rowExpandable: record => Boolean(record.attempt || record.results.length > 0)
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: total => `共 ${total} 条记录`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user