From 1de143740a84c24b744a14815c07804b50def50d Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 25 May 2026 11:25:12 +0800 Subject: [PATCH] check vocabulary --- server/models/Vocabulary.ts | 4 + server/routes/admin.ts | 311 ++++++++++++++++++- server/routes/vocabulary.ts | 16 +- src/api/admin.ts | 42 ++- src/components/AdminDashboard.tsx | 9 +- src/components/AdminVocabularyScoreAudit.tsx | 199 ++++++++++++ 6 files changed, 573 insertions(+), 8 deletions(-) create mode 100644 src/components/AdminVocabularyScoreAudit.tsx diff --git a/server/models/Vocabulary.ts b/server/models/Vocabulary.ts index cf7bcbc..0ecea93 100644 --- a/server/models/Vocabulary.ts +++ b/server/models/Vocabulary.ts @@ -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({ correctAnswer: { type: String, required: true }, isCorrect: { type: Boolean, required: true } }], + invalidated: { type: Boolean, default: false }, + riskFlags: [{ type: String }], createdAt: { type: Date, default: Date.now } }); diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 2f43dcc..d27b4eb 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -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 = { + '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) => { + 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, 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>): 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>>(); + + 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; \ No newline at end of file +// 按时间段汇总学生词汇测试通过情况 +router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => { + try { + const range = parseAdminDateRange(req.query as Record); + if (range.error) { + return res.status(400).json({ message: range.error }); + } + + const { start, end } = range as { start: Date; end: Date }; + const 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(); + 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; diff --git a/server/routes/vocabulary.ts b/server/routes/vocabulary.ts index 46a8705..852d960 100644 --- a/server/routes/vocabulary.ts +++ b/server/routes/vocabulary.ts @@ -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({ diff --git a/src/api/admin.ts b/src/api/admin.ts index 314e2a0..4678902 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -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 => { return api.get('/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 => { + return api.get('/admin/vocabulary/test-pass-summary', { params }); + }, + + clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise => { + return api.post('/admin/vocabulary/test-pass-summary/clear', data); + }, +}; diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index e6aa966..94e9765 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -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 = () => { + {user.username === 'bobcoc' && } @@ -77,8 +79,11 @@ const AdminDashboard: React.FC = () => { + + + {user.username === 'bobcoc' && ( - + )} @@ -87,4 +92,4 @@ const AdminDashboard: React.FC = () => { ); }; -export default AdminDashboard; \ No newline at end of file +export default AdminDashboard; diff --git a/src/components/AdminVocabularyScoreAudit.tsx b/src/components/AdminVocabularyScoreAudit.tsx new file mode 100644 index 0000000..5bb83f2 --- /dev/null +++ b/src/components/AdminVocabularyScoreAudit.tsx @@ -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 = { + '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(null); + const [loading, setLoading] = useState(false); + const [clearingUserId, setClearingUserId] = useState(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 = [ + { + title: '学生', + key: 'student', + render: (_, row) => ( +
+
{row.fullname || row.username || '未知学生'}
+ {row.username || row.email || row.userId} +
+ ) + }, + { + title: '通过单词数', + dataIndex: 'passedWords', + key: 'passedWords', + sorter: (a, b) => a.passedWords - b.passedWords, + defaultSortOrder: 'descend', + render: (value: number) => {value} + }, + { + 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[]) => ( + + {(types || []).map(type => ( + {TEST_TYPE_LABELS[type] || type} + ))} + + ) + }, + { + title: '首次通过', + dataIndex: 'firstPassedAt', + key: 'firstPassedAt', + render: formatDateTime + }, + { + title: '最后通过', + dataIndex: 'lastPassedAt', + key: 'lastPassedAt', + render: formatDateTime + }, + { + title: '操作', + key: 'actions', + render: (_, row) => ( + clearStudentScores(row)} + > + + + ) + } + ]; + + return ( + + + + { + if (!dates || !dates[0] || !dates[1]) { + setRange(null); + setSummary(null); + return; + } + setRange([toIsoString(dates[0]), toIsoString(dates[1])]); + }} + /> + + + + {summary && ( + + + + + + + )} + + `共 ${total} 名学生` + }} + /> + + + ); +}; + +export default AdminVocabularyScoreAudit;