check vocabulary
This commit is contained in:
@@ -56,6 +56,8 @@ export interface IVocabularyTestRecord extends Document {
|
||||
correctAnswer: string;
|
||||
isCorrect: boolean;
|
||||
}>;
|
||||
invalidated?: boolean;
|
||||
riskFlags?: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -174,6 +176,8 @@ const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
||||
correctAnswer: { type: String, required: true },
|
||||
isCorrect: { type: Boolean, required: true }
|
||||
}],
|
||||
invalidated: { type: Boolean, default: false },
|
||||
riskFlags: [{ type: String }],
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ 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';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -45,6 +45,172 @@ 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 INVALIDATING_VOCABULARY_RISK_FLAGS = [
|
||||
'too_fast',
|
||||
'too_slow',
|
||||
'missing_input_value_trace',
|
||||
'input_value_mismatch',
|
||||
'uniform_answer_intervals',
|
||||
'repeated_whole_second_intervals'
|
||||
];
|
||||
|
||||
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').lean()
|
||||
: [];
|
||||
const invalidatedAttemptIds = new Set(
|
||||
attempts
|
||||
.filter((attempt: any) => (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
|
||||
};
|
||||
};
|
||||
|
||||
router.get('/users', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const users = await User.find()
|
||||
@@ -321,4 +487,145 @@ router.get('/vocabulary/word-sets/:id/words', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
// 按时间段汇总学生词汇测试通过情况
|
||||
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 items = await VocabularyTestRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
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 } },
|
||||
{
|
||||
$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 } }
|
||||
]);
|
||||
|
||||
res.json({
|
||||
start,
|
||||
end,
|
||||
totalStudents: items.length,
|
||||
totalPassedWords: items.reduce((sum: number, item: any) => sum + (item.passedWords || 0), 0),
|
||||
items
|
||||
});
|
||||
} 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 { 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 },
|
||||
'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;
|
||||
|
||||
@@ -73,11 +73,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())
|
||||
@@ -1249,7 +1257,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({
|
||||
|
||||
@@ -94,6 +94,39 @@ export interface Word {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VocabularyPassSummaryItem {
|
||||
userId: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
email?: string;
|
||||
passedWords: number;
|
||||
uniqueWords: number;
|
||||
testRecords: number;
|
||||
wordSets: number;
|
||||
testTypes: string[];
|
||||
firstPassedAt: string;
|
||||
lastPassedAt: string;
|
||||
}
|
||||
|
||||
export interface VocabularyPassSummaryResponse {
|
||||
start: string;
|
||||
end: string;
|
||||
totalStudents: number;
|
||||
totalPassedWords: number;
|
||||
items: VocabularyPassSummaryItem[];
|
||||
}
|
||||
|
||||
export interface ClearVocabularyPassSummaryResponse {
|
||||
message: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
fullname?: string;
|
||||
deletedRecords: number;
|
||||
removedPassedWords: number;
|
||||
affectedWords: number;
|
||||
rebuiltWords: number;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
return api.get<User[]>('/admin/users');
|
||||
@@ -176,5 +209,12 @@ export const adminApi = {
|
||||
updateWords: async (words: any[]) => {
|
||||
return api.put('/api/vocabulary/words', { words });
|
||||
},
|
||||
};
|
||||
|
||||
getVocabularyPassSummary: async (params: { start: string; end: string }): Promise<VocabularyPassSummaryResponse> => {
|
||||
return api.get<VocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary', { params });
|
||||
},
|
||||
|
||||
clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise<ClearVocabularyPassSummaryResponse> => {
|
||||
return api.post<ClearVocabularyPassSummaryResponse>('/admin/vocabulary/test-pass-summary/clear', 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>
|
||||
)}
|
||||
@@ -87,4 +92,4 @@ const AdminDashboard: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
export default AdminDashboard;
|
||||
|
||||
199
src/components/AdminVocabularyScoreAudit.tsx
Normal file
199
src/components/AdminVocabularyScoreAudit.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, DatePicker, message, Popconfirm, Space, Statistic, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { adminApi, VocabularyPassSummaryItem, VocabularyPassSummaryResponse } from '../api/admin';
|
||||
|
||||
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 AdminVocabularyScoreAudit: React.FC = () => {
|
||||
const [range, setRange] = useState<[string, string] | null>(null);
|
||||
const [summary, setSummary] = useState<VocabularyPassSummaryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [clearingUserId, setClearingUserId] = useState<string | null>(null);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
if (!range) {
|
||||
message.warning('请先选择开始和结束时间');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminApi.getVocabularyPassSummary({
|
||||
start: range[0],
|
||||
end: range[1]
|
||||
});
|
||||
setSummary(response);
|
||||
} catch (error) {
|
||||
console.error('获取词汇测试通过统计失败:', error);
|
||||
message.error('获取词汇测试通过统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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]
|
||||
});
|
||||
message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`);
|
||||
await fetchSummary();
|
||||
} catch (error) {
|
||||
console.error('清除词汇测试成绩失败:', error);
|
||||
message.error('清除词汇测试成绩失败');
|
||||
} finally {
|
||||
setClearingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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: '测试记录',
|
||||
dataIndex: 'testRecords',
|
||||
key: 'testRecords',
|
||||
sorter: (a, b) => a.testRecords - b.testRecords
|
||||
},
|
||||
{
|
||||
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: '首次通过',
|
||||
dataIndex: 'firstPassedAt',
|
||||
key: 'firstPassedAt',
|
||||
render: formatDateTime
|
||||
},
|
||||
{
|
||||
title: '最后通过',
|
||||
dataIndex: 'lastPassedAt',
|
||||
key: 'lastPassedAt',
|
||||
render: formatDateTime
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title="确认清除该学生在所选时间段内的词汇测试成绩?"
|
||||
description="会删除对应测试记录,并重算这些单词的掌握状态。"
|
||||
okText="确认清除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => clearStudentScores(row)}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={clearingUserId === row.userId}
|
||||
>
|
||||
清除成绩
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
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])]);
|
||||
}}
|
||||
/>
|
||||
<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={formatDateTime(summary.start)} />
|
||||
<Statistic title="结束时间" value={formatDateTime(summary.end)} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={summary?.items || []}
|
||||
rowKey="userId"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: total => `共 ${total} 名学生`
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminVocabularyScoreAudit;
|
||||
Reference in New Issue
Block a user