Compare commits
9 Commits
4088fbc9e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2841413927 | |||
| c6b2bb7800 | |||
| f9311292cc | |||
| b19c811048 | |||
| 76c2f17229 | |||
| 8fd1606424 | |||
| 37e4837e3f | |||
| 0ec12aa0ca | |||
| 1de143740a |
@@ -1,6 +1,7 @@
|
||||
# Server Configuration
|
||||
SERVER_PORT=5001
|
||||
REACT_APP_API_BASE_URL=http://localhost:5001
|
||||
BODY_LIMIT=5mb
|
||||
# Client Configuration
|
||||
CLIENT_PORT=3001
|
||||
REACT_APP_CLIENT_URL=http://localhost:3001
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ build/
|
||||
# Other
|
||||
coverage/
|
||||
.codebuddy
|
||||
.codex-docwork/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 服务器配置
|
||||
PORT=5001
|
||||
NODE_ENV=development
|
||||
BODY_LIMIT=5mb
|
||||
|
||||
# 数据库配置
|
||||
MONGODB_URI=mongodb://localhost:27017/typeskill
|
||||
@@ -8,5 +9,8 @@ MONGODB_URI=mongodb://localhost:27017/typeskill
|
||||
# JWT配置
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
|
||||
# 词汇防作弊配置
|
||||
VOCABULARY_FULL_CORRECT_WORD_THRESHOLD=50
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
@@ -56,6 +56,12 @@ export interface IVocabularyTestRecord extends Document {
|
||||
correctAnswer: string;
|
||||
isCorrect: boolean;
|
||||
}>;
|
||||
invalidated?: boolean;
|
||||
riskFlags?: string[];
|
||||
reviewDecision?: 'approved';
|
||||
reviewedBy?: mongoose.Types.ObjectId;
|
||||
reviewedAt?: Date;
|
||||
reviewNote?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -90,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;
|
||||
@@ -174,6 +184,12 @@ const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
||||
correctAnswer: { type: String, required: true },
|
||||
isCorrect: { type: Boolean, required: true }
|
||||
}],
|
||||
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 }
|
||||
});
|
||||
|
||||
@@ -213,6 +229,10 @@ const VocabularyTestAttemptSchema = new Schema<IVocabularyTestAttempt>({
|
||||
}
|
||||
}],
|
||||
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,
|
||||
|
||||
@@ -9,8 +9,13 @@ import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import csv from 'csv-parser';
|
||||
import { Word, WordSet } from '../models/Vocabulary';
|
||||
import { Word, WordRecord, WordSet, VocabularyTestAttempt, VocabularyTestRecord } from '../models/Vocabulary';
|
||||
import mongoose from 'mongoose';
|
||||
import { INVALIDATING_VOCABULARY_RISK_FLAGS } from '../utils/vocabularyRisk';
|
||||
import {
|
||||
applyVocabularySummaryItemFilters,
|
||||
buildVocabularySummaryFilters
|
||||
} from '../utils/vocabularyAuditFilters';
|
||||
|
||||
const router = express.Router();
|
||||
const adminController = new AdminController();
|
||||
@@ -45,6 +50,211 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
type VocabularyTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||
type WordRecordModeKey = 'chineseToEnglish' | 'audioToEnglish' | 'multipleChoice';
|
||||
|
||||
const TEST_TYPE_TO_MODE: Record<VocabularyTestType, WordRecordModeKey> = {
|
||||
'chinese-to-english': 'chineseToEnglish',
|
||||
'audio-to-english': 'audioToEnglish',
|
||||
'multiple-choice': 'multipleChoice'
|
||||
};
|
||||
|
||||
const WORD_RECORD_MODES: WordRecordModeKey[] = [
|
||||
'chineseToEnglish',
|
||||
'audioToEnglish',
|
||||
'multipleChoice'
|
||||
];
|
||||
|
||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||
|
||||
const parseAdminDateRange = (source: Record<string, any>) => {
|
||||
const start = new Date(String(source.start || source.startTime || ''));
|
||||
const end = new Date(String(source.end || source.endTime || ''));
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { error: '请选择有效的开始和结束时间' };
|
||||
}
|
||||
|
||||
if (start > end) {
|
||||
return { error: '开始时间不能晚于结束时间' };
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const createEmptyModeStats = () => ({
|
||||
streak: 0,
|
||||
totalCorrect: 0,
|
||||
totalWrong: 0,
|
||||
mastered: false,
|
||||
inWrongBook: false,
|
||||
lastTestedAt: undefined as Date | undefined,
|
||||
lastMasteredAt: undefined as Date | undefined
|
||||
});
|
||||
|
||||
const applyVocabularyAnswer = (modeStats: ReturnType<typeof createEmptyModeStats>, isCorrect: boolean, testedAt: Date) => {
|
||||
if (isCorrect) {
|
||||
modeStats.streak = (modeStats.streak || 0) + 1;
|
||||
modeStats.totalCorrect = (modeStats.totalCorrect || 0) + 1;
|
||||
} else {
|
||||
modeStats.streak = 0;
|
||||
modeStats.totalWrong = (modeStats.totalWrong || 0) + 1;
|
||||
}
|
||||
|
||||
modeStats.lastTestedAt = testedAt;
|
||||
|
||||
if (modeStats.streak >= 1) {
|
||||
modeStats.mastered = true;
|
||||
modeStats.lastMasteredAt = testedAt;
|
||||
modeStats.inWrongBook = false;
|
||||
}
|
||||
|
||||
if (!modeStats.mastered && modeStats.totalWrong >= 5) {
|
||||
modeStats.inWrongBook = true;
|
||||
}
|
||||
};
|
||||
|
||||
const getLatestMasteredAt = (state: Record<WordRecordModeKey, ReturnType<typeof createEmptyModeStats>>): Date | undefined => {
|
||||
let latest: Date | undefined;
|
||||
WORD_RECORD_MODES.forEach(mode => {
|
||||
[state[mode].lastMasteredAt, state[mode].lastTestedAt].forEach(value => {
|
||||
if (!value) return;
|
||||
if (!latest || value > latest) latest = value;
|
||||
});
|
||||
});
|
||||
return latest;
|
||||
};
|
||||
|
||||
const rebuildVocabularyWordRecordsForUser = async (
|
||||
userId: string,
|
||||
wordIds: string[]
|
||||
) => {
|
||||
const uniqueWordIds = Array.from(new Set(wordIds.filter(id => mongoose.isValidObjectId(id))));
|
||||
if (uniqueWordIds.length === 0) return { affectedWords: 0, rebuiltWords: 0 };
|
||||
|
||||
const userObjectId = new mongoose.Types.ObjectId(userId);
|
||||
const wordObjectIds = uniqueWordIds.map(id => new mongoose.Types.ObjectId(id));
|
||||
const wordIdSet = new Set(uniqueWordIds);
|
||||
const states = new Map<string, Record<WordRecordModeKey, ReturnType<typeof createEmptyModeStats>>>();
|
||||
|
||||
const records = await VocabularyTestRecord.find({
|
||||
user: userObjectId,
|
||||
invalidated: { $ne: true },
|
||||
'results.word': { $in: wordObjectIds }
|
||||
})
|
||||
.select('testType stats.endTime createdAt results attempt')
|
||||
.sort({ 'stats.endTime': 1, createdAt: 1 })
|
||||
.lean();
|
||||
|
||||
const attemptIds = records
|
||||
.map((record: any) => record.attempt?.toString?.() || '')
|
||||
.filter(id => mongoose.isValidObjectId(id));
|
||||
const attempts = attemptIds.length > 0
|
||||
? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }).select('riskFlags reviewDecision').lean()
|
||||
: [];
|
||||
const invalidatedAttemptIds = new Set(
|
||||
attempts
|
||||
.filter((attempt: any) =>
|
||||
attempt.reviewDecision !== 'approved' &&
|
||||
(attempt.riskFlags || []).some((flag: string) => INVALIDATING_VOCABULARY_RISK_FLAGS.includes(flag))
|
||||
)
|
||||
.map((attempt: any) => attempt._id.toString())
|
||||
);
|
||||
|
||||
records.forEach((record: any) => {
|
||||
const attemptId = record.attempt?.toString?.() || '';
|
||||
if (attemptId && invalidatedAttemptIds.has(attemptId)) return;
|
||||
|
||||
const modeKey = TEST_TYPE_TO_MODE[record.testType as VocabularyTestType];
|
||||
if (!modeKey) return;
|
||||
|
||||
const testedAt = new Date(record.stats?.endTime || record.createdAt || Date.now());
|
||||
(record.results || []).forEach((result: any) => {
|
||||
const wordId = result.word?.toString?.() || String(result.word || '');
|
||||
if (!wordIdSet.has(wordId)) return;
|
||||
|
||||
if (!states.has(wordId)) {
|
||||
states.set(wordId, {
|
||||
chineseToEnglish: createEmptyModeStats(),
|
||||
audioToEnglish: createEmptyModeStats(),
|
||||
multipleChoice: createEmptyModeStats()
|
||||
});
|
||||
}
|
||||
|
||||
const state = states.get(wordId)!;
|
||||
applyVocabularyAnswer(state[modeKey], Boolean(result.isCorrect), testedAt);
|
||||
});
|
||||
});
|
||||
|
||||
await WordRecord.deleteMany({ user: userObjectId, word: { $in: wordObjectIds } });
|
||||
|
||||
const rebuiltDocs = Array.from(states.entries()).map(([wordId, state]) => {
|
||||
const isFullyMastered = WORD_RECORD_MODES.every(mode => state[mode].mastered);
|
||||
return {
|
||||
user: userObjectId,
|
||||
word: new mongoose.Types.ObjectId(wordId),
|
||||
chineseToEnglish: state.chineseToEnglish,
|
||||
audioToEnglish: state.audioToEnglish,
|
||||
multipleChoice: state.multipleChoice,
|
||||
isFullyMastered,
|
||||
lastFullyMasteredAt: isFullyMastered ? getLatestMasteredAt(state) : undefined,
|
||||
createdAt: new Date()
|
||||
};
|
||||
});
|
||||
|
||||
if (rebuiltDocs.length > 0) {
|
||||
await WordRecord.insertMany(rebuiltDocs);
|
||||
}
|
||||
|
||||
return {
|
||||
affectedWords: uniqueWordIds.length,
|
||||
rebuiltWords: rebuiltDocs.length
|
||||
};
|
||||
};
|
||||
|
||||
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()
|
||||
@@ -321,4 +531,510 @@ router.get('/vocabulary/word-sets/:id/words', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 按时间段汇总学生词汇测试通过情况
|
||||
router.get('/vocabulary/test-pass-summary', 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 summaryFilters = buildVocabularySummaryFilters(req.query as Record<string, any>);
|
||||
if ('error' in summaryFilters) {
|
||||
return res.status(400).json({ message: summaryFilters.error });
|
||||
}
|
||||
|
||||
const { userMatchStages, baseMatch, minUniqueWords } = summaryFilters;
|
||||
const validPassItems = await VocabularyTestRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
...baseMatch,
|
||||
invalidated: { $ne: true },
|
||||
'stats.endTime': { $gte: start, $lte: end },
|
||||
'stats.correctWords': { $gt: 0 }
|
||||
}
|
||||
},
|
||||
{ $unwind: '$results' },
|
||||
{ $match: { 'results.isCorrect': true } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$user',
|
||||
passedWords: { $sum: 1 },
|
||||
uniqueWordIds: { $addToSet: '$results.word' },
|
||||
testRecordIds: { $addToSet: '$_id' },
|
||||
wordSetIds: { $addToSet: '$wordSet' },
|
||||
testTypes: { $addToSet: '$testType' },
|
||||
firstPassedAt: { $min: '$stats.endTime' },
|
||||
lastPassedAt: { $max: '$stats.endTime' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: '_id',
|
||||
foreignField: '_id',
|
||||
as: 'user'
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$user', preserveNullAndEmptyArrays: true } },
|
||||
...userMatchStages,
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
userId: { $toString: '$_id' },
|
||||
username: '$user.username',
|
||||
fullname: '$user.fullname',
|
||||
email: '$user.email',
|
||||
passedWords: 1,
|
||||
uniqueWords: { $size: '$uniqueWordIds' },
|
||||
testRecords: { $size: '$testRecordIds' },
|
||||
wordSets: { $size: '$wordSetIds' },
|
||||
testTypes: 1,
|
||||
firstPassedAt: 1,
|
||||
lastPassedAt: 1
|
||||
}
|
||||
},
|
||||
{ $sort: { passedWords: -1, lastPassedAt: -1 } }
|
||||
]);
|
||||
|
||||
const recordItems = await VocabularyTestRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
...baseMatch,
|
||||
'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 } },
|
||||
...userMatchStages,
|
||||
{
|
||||
$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 = applyVocabularySummaryItemFilters(Array.from(itemMap.values()), minUniqueWords).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) {
|
||||
console.error('获取词汇测试通过统计失败:', error);
|
||||
res.status(500).json({ message: '获取词汇测试通过统计失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 查看某个学生在时间段内的词汇测试明细(包含风控 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 summaryFilters = buildVocabularySummaryFilters(req.query as Record<string, any>);
|
||||
if ('error' in summaryFilters) {
|
||||
return res.status(400).json({ message: summaryFilters.error });
|
||||
}
|
||||
|
||||
const { baseMatch } = summaryFilters;
|
||||
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,
|
||||
...baseMatch,
|
||||
'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 {
|
||||
const range = parseAdminDateRange(req.body || {});
|
||||
if (range.error) {
|
||||
return res.status(400).json({ message: range.error });
|
||||
}
|
||||
|
||||
const { start, end } = range as { start: Date; end: Date };
|
||||
const summaryFilters = buildVocabularySummaryFilters(req.body || {});
|
||||
if ('error' in summaryFilters) {
|
||||
return res.status(400).json({ message: summaryFilters.error });
|
||||
}
|
||||
|
||||
const { baseMatch } = summaryFilters;
|
||||
const { userId } = req.body || {};
|
||||
if (!mongoose.isValidObjectId(userId)) {
|
||||
return res.status(400).json({ message: '学生ID无效' });
|
||||
}
|
||||
|
||||
const user = await User.findById(userId).select('_id username fullname');
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '学生不存在' });
|
||||
}
|
||||
|
||||
const records = await VocabularyTestRecord.find({
|
||||
user: user._id,
|
||||
invalidated: { $ne: true },
|
||||
...baseMatch,
|
||||
'stats.endTime': { $gte: start, $lte: end },
|
||||
'stats.correctWords': { $gt: 0 }
|
||||
})
|
||||
.select('_id stats.correctWords results.word results.isCorrect')
|
||||
.lean();
|
||||
|
||||
if (records.length === 0) {
|
||||
return res.json({
|
||||
message: '没有找到需要清除的词汇测试成绩',
|
||||
deletedRecords: 0,
|
||||
removedPassedWords: 0,
|
||||
affectedWords: 0,
|
||||
rebuiltWords: 0
|
||||
});
|
||||
}
|
||||
|
||||
const recordIds = records.map(record => record._id);
|
||||
const affectedWordIds = new Set<string>();
|
||||
let removedPassedWords = 0;
|
||||
|
||||
records.forEach((record: any) => {
|
||||
removedPassedWords += Number(record.stats?.correctWords || 0);
|
||||
(record.results || []).forEach((result: any) => {
|
||||
const wordId = result.word?.toString?.() || String(result.word || '');
|
||||
if (wordId) affectedWordIds.add(wordId);
|
||||
});
|
||||
});
|
||||
|
||||
await VocabularyTestRecord.deleteMany({ _id: { $in: recordIds } });
|
||||
const rebuildResult = await rebuildVocabularyWordRecordsForUser(userId, Array.from(affectedWordIds));
|
||||
|
||||
res.json({
|
||||
message: '词汇测试成绩已清除',
|
||||
userId,
|
||||
username: user.username,
|
||||
fullname: user.fullname,
|
||||
deletedRecords: records.length,
|
||||
removedPassedWords,
|
||||
affectedWords: rebuildResult.affectedWords,
|
||||
rebuiltWords: rebuildResult.rebuiltWords
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('清除词汇测试成绩失败:', error);
|
||||
res.status(500).json({ message: '清除词汇测试成绩失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -9,6 +9,10 @@ import { Word, WordSet, WordRecord, VocabularyTestRecord, VocabularyTestAttempt
|
||||
import mongoose from 'mongoose';
|
||||
import csv from 'csv-parser';
|
||||
import { config } from '../config';
|
||||
import {
|
||||
INVALIDATING_VOCABULARY_RISK_FLAGS,
|
||||
analyzeVocabularyAttemptResultRisk
|
||||
} from '../utils/vocabularyRisk';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -73,11 +77,19 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [
|
||||
'multipleChoice'
|
||||
];
|
||||
|
||||
const getNumericEnv = (names: string[], fallback: number): number => {
|
||||
for (const name of names) {
|
||||
const parsed = Number(process.env[name]);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const ATTEMPT_TTL_MS = 30 * 60 * 1000;
|
||||
const MIN_STUDY_WORD_COUNT = 10;
|
||||
const MAX_STUDY_WORD_COUNT = 100;
|
||||
const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 2);
|
||||
const MAX_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MAX_SECONDS_PER_QUESTION || 10);
|
||||
const MIN_SECONDS_PER_QUESTION = getNumericEnv(['VOCABULARY_MIN_SECONDS_PER_QUESTION', 'MIN_SECONDS_PER_QUESTION'], 2);
|
||||
const MAX_SECONDS_PER_QUESTION = getNumericEnv(['VOCABULARY_MAX_SECONDS_PER_QUESTION', 'MAX_SECONDS_PER_QUESTION'], 10);
|
||||
const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'https://d1kt.cn,http://localhost:3000,http://localhost:3001')
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
@@ -917,13 +929,14 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body;
|
||||
const userId = req.user?._id;
|
||||
const softRiskFlags: string[] = [];
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
softRiskFlags.push('invalid_origin');
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
@@ -945,9 +958,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||
attemptAny.status = 'expired';
|
||||
await attempt.save();
|
||||
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
softRiskFlags.push('attempt_expired');
|
||||
}
|
||||
|
||||
if (attemptAny.status !== 'active') {
|
||||
@@ -973,16 +984,14 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
|
||||
const submittedAtMs = Number.parseInt(String(submittedAt), 10);
|
||||
if (!Number.isFinite(submittedAtMs)) {
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
softRiskFlags.push('invalid_submitted_at');
|
||||
} else if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||||
softRiskFlags.push('submitted_at_out_of_sync');
|
||||
}
|
||||
|
||||
const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]);
|
||||
if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) {
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
softRiskFlags.push('invalid_question_token');
|
||||
}
|
||||
|
||||
let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500);
|
||||
@@ -991,24 +1000,31 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
const optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
|
||||
const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer));
|
||||
if (selectedOptionIndex < 0) {
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
softRiskFlags.push('invalid_option_token');
|
||||
const fallbackOptionIndex = optionTexts.findIndex((text: string) => text === normalizedUserAnswer);
|
||||
normalizedUserAnswer = fallbackOptionIndex >= 0
|
||||
? optionTexts[fallbackOptionIndex]
|
||||
: String(userAnswer ?? '').trim().slice(0, 500);
|
||||
} else {
|
||||
normalizedUserAnswer = optionTexts[selectedOptionIndex] || '';
|
||||
}
|
||||
normalizedUserAnswer = optionTexts[selectedOptionIndex] || '';
|
||||
}
|
||||
|
||||
const expectedProof = buildAnswerProof(attemptId.toString(), token, String(userAnswer ?? '').trim().slice(0, 500), submittedAtMs);
|
||||
if (!safeEqualString(expectedProof, String(answerProof ?? ''))) {
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
softRiskFlags.push('invalid_answer_proof');
|
||||
}
|
||||
|
||||
const questionShownAtMs = tokenIndex === 0
|
||||
? issuedAt.getTime()
|
||||
: new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || issuedAt).getTime();
|
||||
const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000);
|
||||
const effectiveSubmittedAtMs = Number.isFinite(submittedAtMs) ? submittedAtMs : now;
|
||||
const duration = Math.max(0, (effectiveSubmittedAtMs - questionShownAtMs) / 1000);
|
||||
const interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs);
|
||||
const riskFlags = Array.from(new Set([
|
||||
...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType),
|
||||
...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType)
|
||||
...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType),
|
||||
...softRiskFlags
|
||||
]));
|
||||
|
||||
const word = await Word.findOne({
|
||||
@@ -1030,7 +1046,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
userAnswer: evaluation.userAnswer,
|
||||
correctAnswer: evaluation.correctAnswer,
|
||||
isCorrect: evaluation.isCorrect,
|
||||
submittedAt: new Date(submittedAtMs),
|
||||
submittedAt: new Date(effectiveSubmittedAtMs),
|
||||
duration,
|
||||
riskFlags,
|
||||
interactionSummary
|
||||
@@ -1136,13 +1152,14 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { attemptId } = req.body;
|
||||
const userId = req.user?._id;
|
||||
const finalRiskFlags: string[] = [];
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
finalRiskFlags.push('invalid_origin');
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
@@ -1164,9 +1181,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||
attemptAny.status = 'expired';
|
||||
await attempt.save();
|
||||
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
finalRiskFlags.push('attempt_expired');
|
||||
}
|
||||
|
||||
if (attemptAny.status !== 'active') {
|
||||
@@ -1184,25 +1199,10 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000);
|
||||
const minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION);
|
||||
if (elapsedSeconds < minimumSeconds) {
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
finalRiskFlags.push('too_fast');
|
||||
}
|
||||
}
|
||||
|
||||
const batchRiskFlags = isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || []);
|
||||
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
|
||||
const shouldInvalidateBatch = batchRiskFlags.length > 0 ||
|
||||
['too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch'].some(flag => (attemptAny.riskFlags || []).includes(flag));
|
||||
|
||||
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||
{ _id: attempt._id, user: userId, status: 'active' },
|
||||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!claimedAttempt) {
|
||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word));
|
||||
const submittedWords = await Word.find({ _id: { $in: submittedWordIds } }).select('_id word');
|
||||
const submittedWordMap = new Map<string, string>();
|
||||
@@ -1221,14 +1221,37 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
const totalWords = evaluatedResults.length;
|
||||
const originallyCorrectWords = evaluatedResults.filter(result => result.isCorrect).length;
|
||||
const batchRiskFlags = [
|
||||
...finalRiskFlags,
|
||||
...(isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || [])),
|
||||
...analyzeVocabularyAttemptResultRisk({
|
||||
testType: attemptAny.testType,
|
||||
totalWords,
|
||||
correctWords: originallyCorrectWords
|
||||
})
|
||||
];
|
||||
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
|
||||
const shouldInvalidateBatch = INVALIDATING_VOCABULARY_RISK_FLAGS.some(flag => (attemptAny.riskFlags || []).includes(flag));
|
||||
|
||||
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||
{ _id: attempt._id, user: userId, status: 'active' },
|
||||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!claimedAttempt) {
|
||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!shouldInvalidateBatch) {
|
||||
for (const result of evaluatedResults) {
|
||||
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
||||
}
|
||||
}
|
||||
|
||||
const totalWords = evaluatedResults.length;
|
||||
const correctWords = shouldInvalidateBatch ? 0 : evaluatedResults.filter(result => result.isCorrect).length;
|
||||
const correctWords = shouldInvalidateBatch ? 0 : originallyCorrectWords;
|
||||
const verifiedStats = {
|
||||
totalWords,
|
||||
correctWords,
|
||||
@@ -1249,7 +1272,9 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
userAnswer: result.userAnswer,
|
||||
correctAnswer: result.correctAnswer,
|
||||
isCorrect: shouldInvalidateBatch ? false : result.isCorrect
|
||||
}))
|
||||
})),
|
||||
invalidated: shouldInvalidateBatch,
|
||||
riskFlags: attemptAny.riskFlags || []
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
||||
@@ -25,11 +25,13 @@ import sudokuRouter from './routes/sudoku';
|
||||
// 加载环境变量
|
||||
dotenv.config();
|
||||
|
||||
const BODY_LIMIT = process.env.BODY_LIMIT || '5mb';
|
||||
|
||||
const app = express();
|
||||
|
||||
// 中间件配置
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: BODY_LIMIT }));
|
||||
app.use(express.urlencoded({ extended: true, limit: BODY_LIMIT }));
|
||||
app.use(cors());
|
||||
|
||||
// 静态文件服务 - 优先处理 public 目录下的静态文件
|
||||
@@ -202,7 +204,40 @@ app.use((req, res, next) => {
|
||||
|
||||
// 错误处理中间件
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(err.stack);
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (
|
||||
err?.type === 'entity.too.large' ||
|
||||
err?.name === 'PayloadTooLargeError' ||
|
||||
err?.status === 413 ||
|
||||
err?.statusCode === 413
|
||||
) {
|
||||
console.warn('Request body too large:', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
limit: BODY_LIMIT
|
||||
});
|
||||
return res.status(413).json({
|
||||
error: '请求体过大,请缩小单次提交内容',
|
||||
code: 'PAYLOAD_TOO_LARGE'
|
||||
});
|
||||
}
|
||||
|
||||
if (err?.type === 'entity.parse.failed') {
|
||||
console.error('Invalid JSON payload:', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
message: err?.message
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: '请求体格式无效',
|
||||
code: 'INVALID_JSON'
|
||||
});
|
||||
}
|
||||
|
||||
console.error(err?.stack || err);
|
||||
res.status(500).send('Something broke!');
|
||||
});
|
||||
|
||||
|
||||
48
server/utils/vocabularyAuditFilters.test.ts
Normal file
48
server/utils/vocabularyAuditFilters.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyVocabularySummaryItemFilters,
|
||||
buildVocabularySummaryFilters
|
||||
} from './vocabularyAuditFilters';
|
||||
|
||||
const uniqueWordFilter = buildVocabularySummaryFilters({ minUniqueWords: '40' });
|
||||
assert.deepEqual(uniqueWordFilter, {
|
||||
minUniqueWords: 40,
|
||||
userMatchStages: [],
|
||||
baseMatch: {}
|
||||
});
|
||||
|
||||
const legacyFilter = buildVocabularySummaryFilters({ minCorrectWords: '35' });
|
||||
assert.deepEqual(legacyFilter, {
|
||||
minUniqueWords: 35,
|
||||
userMatchStages: [],
|
||||
baseMatch: {}
|
||||
});
|
||||
|
||||
const emptyFilter = buildVocabularySummaryFilters({});
|
||||
assert.deepEqual(emptyFilter, {
|
||||
minUniqueWords: undefined,
|
||||
userMatchStages: [],
|
||||
baseMatch: {}
|
||||
});
|
||||
|
||||
const studentFilter = buildVocabularySummaryFilters({ studentNo: 'A.12' });
|
||||
assert.deepEqual(studentFilter.userMatchStages, [{
|
||||
$match: {
|
||||
'user.username': {
|
||||
$regex: 'A\\.12',
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
const invalidFilter = buildVocabularySummaryFilters({ minUniqueWords: '4.5' });
|
||||
assert.deepEqual(invalidFilter, { error: '去重单词数量必须是正整数' });
|
||||
|
||||
const filteredItems = applyVocabularySummaryItemFilters([
|
||||
{ userId: 'below', uniqueWords: 39 },
|
||||
{ userId: 'equal', uniqueWords: 40 },
|
||||
{ userId: 'above', uniqueWords: 41 }
|
||||
], 40);
|
||||
assert.deepEqual(filteredItems.map(item => item.userId), ['equal', 'above']);
|
||||
|
||||
console.log('vocabularyAuditFilters tests passed');
|
||||
52
server/utils/vocabularyAuditFilters.ts
Normal file
52
server/utils/vocabularyAuditFilters.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const parseOptionalPositiveInt = (value: unknown, fieldName: string) => {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return { value: undefined as number | undefined };
|
||||
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
return { error: `${fieldName}必须是正整数` };
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return { error: `${fieldName}必须大于0` };
|
||||
}
|
||||
|
||||
return { value: parsed };
|
||||
};
|
||||
|
||||
export const buildVocabularySummaryFilters = (query: Record<string, any>) => {
|
||||
const studentNo = String(query.studentNo || '').trim();
|
||||
const rawMinUniqueWords = query.minUniqueWords ?? query.minCorrectWords ?? query.minWords;
|
||||
const wordCountResult = parseOptionalPositiveInt(rawMinUniqueWords, '去重单词数量');
|
||||
if ('error' in wordCountResult) return { error: wordCountResult.error };
|
||||
|
||||
const minUniqueWords = wordCountResult.value;
|
||||
const userMatchStages = studentNo
|
||||
? [{
|
||||
$match: {
|
||||
'user.username': {
|
||||
$regex: escapeRegex(studentNo),
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}]
|
||||
: [];
|
||||
|
||||
const baseMatch: Record<string, any> = {};
|
||||
|
||||
return {
|
||||
minUniqueWords,
|
||||
userMatchStages,
|
||||
baseMatch
|
||||
};
|
||||
};
|
||||
|
||||
export const applyVocabularySummaryItemFilters = <T extends { uniqueWords?: number }>(
|
||||
items: T[],
|
||||
minUniqueWords?: number
|
||||
): T[] => {
|
||||
if (typeof minUniqueWords !== 'number') return items;
|
||||
return items.filter(item => Number(item.uniqueWords || 0) >= minUniqueWords);
|
||||
};
|
||||
58
server/utils/vocabularyRisk.test.ts
Normal file
58
server/utils/vocabularyRisk.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD,
|
||||
VOCABULARY_FULL_CORRECT_RISK_FLAG,
|
||||
analyzeVocabularyAttemptResultRisk
|
||||
} from './vocabularyRisk';
|
||||
|
||||
const defaultThreshold = DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD;
|
||||
|
||||
assert.equal(defaultThreshold, 50);
|
||||
|
||||
assert.deepEqual(
|
||||
analyzeVocabularyAttemptResultRisk({
|
||||
testType: 'chinese-to-english',
|
||||
totalWords: defaultThreshold + 1,
|
||||
correctWords: defaultThreshold + 1
|
||||
}),
|
||||
[VOCABULARY_FULL_CORRECT_RISK_FLAG]
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
analyzeVocabularyAttemptResultRisk({
|
||||
testType: 'audio-to-english',
|
||||
totalWords: defaultThreshold,
|
||||
correctWords: defaultThreshold
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
analyzeVocabularyAttemptResultRisk({
|
||||
testType: 'chinese-to-english',
|
||||
totalWords: defaultThreshold + 1,
|
||||
correctWords: defaultThreshold
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
analyzeVocabularyAttemptResultRisk({
|
||||
testType: 'multiple-choice',
|
||||
totalWords: defaultThreshold + 1,
|
||||
correctWords: defaultThreshold + 1
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
analyzeVocabularyAttemptResultRisk({
|
||||
testType: 'audio-to-english',
|
||||
totalWords: 41,
|
||||
correctWords: 41,
|
||||
threshold: 40
|
||||
}),
|
||||
[VOCABULARY_FULL_CORRECT_RISK_FLAG]
|
||||
);
|
||||
|
||||
console.log('vocabularyRisk tests passed');
|
||||
59
server/utils/vocabularyRisk.ts
Normal file
59
server/utils/vocabularyRisk.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type VocabularyRiskTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||
|
||||
export const DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD = 50;
|
||||
export const VOCABULARY_FULL_CORRECT_RISK_FLAG = 'large_batch_all_correct';
|
||||
|
||||
export const INVALIDATING_VOCABULARY_RISK_FLAGS: string[] = [
|
||||
'invalid_origin',
|
||||
'invalid_answer_proof',
|
||||
'invalid_question_token',
|
||||
'invalid_option_token',
|
||||
'invalid_submitted_at',
|
||||
'submitted_at_out_of_sync',
|
||||
'attempt_expired',
|
||||
'too_fast',
|
||||
'too_slow',
|
||||
'missing_choice_interaction',
|
||||
'missing_input_value_trace',
|
||||
'input_value_mismatch',
|
||||
'uniform_answer_intervals',
|
||||
'repeated_whole_second_intervals',
|
||||
VOCABULARY_FULL_CORRECT_RISK_FLAG
|
||||
];
|
||||
|
||||
const parsePositiveThreshold = (value: unknown): number | undefined => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
};
|
||||
|
||||
export const getVocabularyFullCorrectWordThreshold = (): number => {
|
||||
return parsePositiveThreshold(process.env.VOCABULARY_FULL_CORRECT_WORD_THRESHOLD) ??
|
||||
DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD;
|
||||
};
|
||||
|
||||
export const analyzeVocabularyAttemptResultRisk = ({
|
||||
testType,
|
||||
totalWords,
|
||||
correctWords,
|
||||
threshold = getVocabularyFullCorrectWordThreshold()
|
||||
}: {
|
||||
testType: VocabularyRiskTestType | string;
|
||||
totalWords: number;
|
||||
correctWords: number;
|
||||
threshold?: number;
|
||||
}): string[] => {
|
||||
const effectiveThreshold = parsePositiveThreshold(threshold) ??
|
||||
DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD;
|
||||
const safeTotalWords = Number.isFinite(totalWords) ? totalWords : 0;
|
||||
const safeCorrectWords = Number.isFinite(correctWords) ? correctWords : 0;
|
||||
|
||||
if (
|
||||
testType !== 'multiple-choice' &&
|
||||
safeTotalWords > effectiveThreshold &&
|
||||
safeTotalWords === safeCorrectWords
|
||||
) {
|
||||
return [VOCABULARY_FULL_CORRECT_RISK_FLAG];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
184
src/api/admin.ts
184
src/api/admin.ts
@@ -94,6 +94,172 @@ export interface Word {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VocabularyPassSummaryItem {
|
||||
userId: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
email?: string;
|
||||
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 {
|
||||
start: string;
|
||||
end: string;
|
||||
totalStudents: number;
|
||||
totalPassedWords: number;
|
||||
totalRecords?: number;
|
||||
invalidatedRecords?: number;
|
||||
items: VocabularyPassSummaryItem[];
|
||||
}
|
||||
|
||||
export interface VocabularyAuditQueryParams {
|
||||
start: string;
|
||||
end: string;
|
||||
studentNo?: string;
|
||||
minUniqueWords?: string;
|
||||
}
|
||||
|
||||
export interface ClearVocabularyPassSummaryResponse {
|
||||
message: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
fullname?: string;
|
||||
deletedRecords: number;
|
||||
removedPassedWords: number;
|
||||
affectedWords: 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 = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
return api.get<User[]>('/admin/users');
|
||||
@@ -176,5 +342,21 @@ export const adminApi = {
|
||||
updateWords: async (words: any[]) => {
|
||||
return api.put('/api/vocabulary/words', { words });
|
||||
},
|
||||
};
|
||||
|
||||
getVocabularyPassSummary: async (params: VocabularyAuditQueryParams): Promise<VocabularyPassSummaryResponse> => {
|
||||
return api.get<VocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary', { params });
|
||||
},
|
||||
|
||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string; studentNo?: string; minUniqueWords?: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', data);
|
||||
},
|
||||
|
||||
getVocabularyPassSummaryDetails: async (params: { userId: string } & VocabularyAuditQueryParams): 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import AdminCodeManager from './AdminCodeManager';
|
||||
import AdminPracticeRecords from './AdminPracticeRecords';
|
||||
import AdminOAuth2Manager from './AdminOAuth2Manager';
|
||||
import AdminVocabularyManager from './AdminVocabularyManager';
|
||||
import AdminVocabularyScoreAudit from './AdminVocabularyScoreAudit';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -62,6 +63,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<Tab label="代码管理" />
|
||||
<Tab label="练习记录" />
|
||||
<Tab label="单词库管理" />
|
||||
<Tab label="词汇成绩核查" />
|
||||
{user.username === 'bobcoc' && <Tab label="OAuth2管理" />}
|
||||
</Tabs>
|
||||
|
||||
@@ -77,8 +79,11 @@ const AdminDashboard: React.FC = () => {
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
<AdminVocabularyManager />
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<AdminVocabularyScoreAudit />
|
||||
</TabPanel>
|
||||
{user.username === 'bobcoc' && (
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
<TabPanel value={tabValue} index={5}>
|
||||
<AdminOAuth2Manager />
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
658
src/components/AdminVocabularyScoreAudit.tsx
Normal file
658
src/components/AdminVocabularyScoreAudit.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Collapse, DatePicker, Descriptions, Input, message, Modal, Popconfirm, Space, Statistic, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CheckCircleOutlined, DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
adminApi,
|
||||
VocabularyAuditAnswerDetail,
|
||||
VocabularyAuditRecordDetail,
|
||||
VocabularyPassSummaryDetailsResponse,
|
||||
VocabularyPassSummaryItem,
|
||||
VocabularyPassSummaryResponse
|
||||
} from '../api/admin';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const TEST_TYPE_LABELS: Record<string, string> = {
|
||||
'chinese-to-english': '看中文写英文',
|
||||
'audio-to-english': '听发音写单词',
|
||||
'multiple-choice': '选择正确翻译'
|
||||
};
|
||||
|
||||
const toIsoString = (value: any): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value.toDate === 'function') return value.toDate().toISOString();
|
||||
if (typeof value.toISOString === 'function') return value.toISOString();
|
||||
return new Date(value).toISOString();
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
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 [range, setRange] = useState<[string, string] | null>(null);
|
||||
const [studentNo, setStudentNo] = useState('');
|
||||
const [minUniqueWords, setMinUniqueWords] = useState('');
|
||||
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 getFilterParams = () => ({
|
||||
studentNo: studentNo.trim() || undefined,
|
||||
minUniqueWords: minUniqueWords.trim() || undefined
|
||||
});
|
||||
|
||||
const fetchSummary = async () => {
|
||||
if (!range) {
|
||||
message.warning('请先选择开始和结束时间');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminApi.getVocabularyPassSummary({
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
...getFilterParams()
|
||||
});
|
||||
setSummary(response);
|
||||
} catch (error) {
|
||||
console.error('获取词汇测试通过统计失败:', error);
|
||||
message.error('获取词汇测试通过统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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],
|
||||
...getFilterParams()
|
||||
});
|
||||
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;
|
||||
|
||||
try {
|
||||
setClearingUserId(row.userId);
|
||||
const response = await adminApi.clearVocabularyPassSummary({
|
||||
userId: row.userId,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
...getFilterParams()
|
||||
});
|
||||
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
||||
await fetchSummary();
|
||||
if (selectedStudent?.userId === row.userId) {
|
||||
await fetchStudentDetails(row);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清除词汇测试成绩失败:', error);
|
||||
message.error('清除词汇测试成绩失败');
|
||||
} finally {
|
||||
setClearingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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> = [
|
||||
{
|
||||
title: '学生',
|
||||
key: 'student',
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{row.fullname || row.username || '未知学生'}</div>
|
||||
<Typography.Text type="secondary">{row.username || row.email || row.userId}</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '有效通过',
|
||||
dataIndex: 'passedWords',
|
||||
key: 'passedWords',
|
||||
sorter: (a, b) => a.passedWords - b.passedWords,
|
||||
defaultSortOrder: 'descend',
|
||||
render: (value: number) => <Tag color="green">{value}</Tag>
|
||||
},
|
||||
{
|
||||
title: '去重单词',
|
||||
dataIndex: 'uniqueWords',
|
||||
key: 'uniqueWords',
|
||||
sorter: (a, b) => a.uniqueWords - b.uniqueWords
|
||||
},
|
||||
{
|
||||
title: '测试记录',
|
||||
key: 'records',
|
||||
sorter: (a, b) => (a.totalRecords || a.testRecords || 0) - (b.totalRecords || b.testRecords || 0),
|
||||
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: '题型',
|
||||
dataIndex: 'testTypes',
|
||||
key: 'testTypes',
|
||||
render: (types: string[]) => (
|
||||
<Space wrap>
|
||||
{(types || []).map(type => (
|
||||
<Tag key={type} color="blue">{TEST_TYPE_LABELS[type] || type}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '首次记录',
|
||||
key: 'firstRecordAt',
|
||||
render: (_, row) => formatDateTime(row.firstRecordAt || row.firstPassedAt)
|
||||
},
|
||||
{
|
||||
title: '最后记录',
|
||||
key: 'lastRecordAt',
|
||||
render: (_, row) => formatDateTime(row.lastRecordAt || row.lastPassedAt)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button icon={<EyeOutlined />} onClick={() => openStudentDetails(row)}>
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认清除该学生在所选时间段内的词汇测试成绩?"
|
||||
description="会删除对应测试记录,并重算这些单词的掌握状态。"
|
||||
okText="确认清除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => clearStudentScores(row)}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={clearingUserId === row.userId}
|
||||
>
|
||||
清除成绩
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="词汇测试成绩核查">
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<RangePicker
|
||||
showTime
|
||||
onChange={(dates: any) => {
|
||||
if (!dates || !dates[0] || !dates[1]) {
|
||||
setRange(null);
|
||||
setSummary(null);
|
||||
return;
|
||||
}
|
||||
setRange([toIsoString(dates[0]), toIsoString(dates[1])]);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="输入学生学号"
|
||||
value={studentNo}
|
||||
onChange={event => setStudentNo(event.target.value)}
|
||||
onPressEnter={fetchSummary}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
type="number"
|
||||
placeholder="最少去重单词数"
|
||||
value={minUniqueWords}
|
||||
onChange={event => setMinUniqueWords(event.target.value)}
|
||||
onPressEnter={fetchSummary}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={fetchSummary}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Space wrap>
|
||||
<Statistic title="学生数" value={summary.totalStudents} />
|
||||
<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.end)} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={summary?.items || []}
|
||||
rowKey="userId"
|
||||
loading={loading}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: total => `共 ${total} 名学生`
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminVocabularyScoreAudit;
|
||||
@@ -619,7 +619,7 @@ const VocabularyStudy: React.FC = () => {
|
||||
));
|
||||
setUserAnswer(response.optionToken);
|
||||
} catch (error) {
|
||||
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||
setUserAnswer(option.text);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -642,13 +642,20 @@ const VocabularyStudy: React.FC = () => {
|
||||
answerSubmittingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (testType === 'multiple-choice' && options.length > 0 && options.every(option => option.token !== userAnswer)) {
|
||||
const normalizedUserAnswer = userAnswer.trim();
|
||||
if (!normalizedUserAnswer) {
|
||||
answerSubmittingRef.current = false;
|
||||
return;
|
||||
}
|
||||
const isKnownMultipleChoiceAnswer = options.some(option =>
|
||||
option.token === normalizedUserAnswer || option.text === normalizedUserAnswer
|
||||
);
|
||||
if (testType === 'multiple-choice' && options.length > 0 && !isKnownMultipleChoiceAnswer) {
|
||||
answerSubmittingRef.current = false;
|
||||
message.warning('请选择一个选项后再提交');
|
||||
return;
|
||||
}
|
||||
const submittedAt = Date.now();
|
||||
const normalizedUserAnswer = userAnswer.trim();
|
||||
const answerProof = buildAnswerProof(
|
||||
testAttemptId,
|
||||
currentQuestion.questionToken,
|
||||
@@ -1411,6 +1418,7 @@ const VocabularyStudy: React.FC = () => {
|
||||
style={{ marginBottom: 15, userSelect: 'none', WebkitUserSelect: 'none' }}
|
||||
onPressEnter={e => {
|
||||
e.stopPropagation();
|
||||
if (!userAnswer.trim() || showAnswer) return;
|
||||
submitAnswer();
|
||||
}}
|
||||
autoFocus
|
||||
@@ -1444,7 +1452,7 @@ const VocabularyStudy: React.FC = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={submitAnswer}
|
||||
disabled={!userAnswer || showAnswer}
|
||||
disabled={!userAnswer.trim() || showAnswer}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
提交答案
|
||||
|
||||
Reference in New Issue
Block a user