fixed fake post attack
This commit is contained in:
@@ -41,6 +41,7 @@ export interface IVocabularyTestRecord extends Document {
|
|||||||
user: mongoose.Types.ObjectId;
|
user: mongoose.Types.ObjectId;
|
||||||
wordSet: mongoose.Types.ObjectId;
|
wordSet: mongoose.Types.ObjectId;
|
||||||
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||||
|
attempt?: mongoose.Types.ObjectId;
|
||||||
stats: {
|
stats: {
|
||||||
totalWords: number;
|
totalWords: number;
|
||||||
correctWords: number;
|
correctWords: number;
|
||||||
@@ -49,9 +50,29 @@ export interface IVocabularyTestRecord extends Document {
|
|||||||
endTime: Date;
|
endTime: Date;
|
||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
results?: Array<{
|
||||||
|
word: mongoose.Types.ObjectId;
|
||||||
|
userAnswer: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IVocabularyTestAttempt extends Document {
|
||||||
|
user: mongoose.Types.ObjectId;
|
||||||
|
wordSet: mongoose.Types.ObjectId;
|
||||||
|
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||||
|
questionWordIds: mongoose.Types.ObjectId[];
|
||||||
|
questionTokens: string[];
|
||||||
|
issuedAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
submittedAt?: Date;
|
||||||
|
status: 'active' | 'submitted' | 'expired';
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
// 单词模式
|
// 单词模式
|
||||||
const WordSchema = new Schema<IWord>({
|
const WordSchema = new Schema<IWord>({
|
||||||
word: { type: String, required: true, trim: true },
|
word: { type: String, required: true, trim: true },
|
||||||
@@ -83,7 +104,8 @@ const ModeStatsSchema = new Schema({
|
|||||||
totalWrong: { type: Number, default: 0 },
|
totalWrong: { type: Number, default: 0 },
|
||||||
mastered: { type: Boolean, default: false },
|
mastered: { type: Boolean, default: false },
|
||||||
inWrongBook: { type: Boolean, default: false },
|
inWrongBook: { type: Boolean, default: false },
|
||||||
lastTestedAt: Date
|
lastTestedAt: Date,
|
||||||
|
lastMasteredAt: Date
|
||||||
}, { _id: false });
|
}, { _id: false });
|
||||||
|
|
||||||
// 简化后的主记录模式
|
// 简化后的主记录模式
|
||||||
@@ -107,6 +129,7 @@ const WordRecordSchema = new Schema({
|
|||||||
const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
||||||
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
|
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
|
||||||
|
attempt: { type: Schema.Types.ObjectId, ref: 'VocabularyTestAttempt' },
|
||||||
testType: {
|
testType: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
|
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
|
||||||
@@ -120,18 +143,51 @@ const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
|||||||
endTime: { type: Date, required: true },
|
endTime: { type: Date, required: true },
|
||||||
duration: { type: Number, required: true }
|
duration: { type: Number, required: true }
|
||||||
},
|
},
|
||||||
|
results: [{
|
||||||
|
word: { type: Schema.Types.ObjectId, ref: 'Word', required: true },
|
||||||
|
userAnswer: { type: String, default: '' },
|
||||||
|
correctAnswer: { type: String, required: true },
|
||||||
|
isCorrect: { type: Boolean, required: true }
|
||||||
|
}],
|
||||||
createdAt: { type: Date, default: Date.now }
|
createdAt: { type: Date, default: Date.now }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 单词测试会话模式,用于防止直接伪造测试提交
|
||||||
|
const VocabularyTestAttemptSchema = new Schema<IVocabularyTestAttempt>({
|
||||||
|
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
|
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
|
||||||
|
testType: {
|
||||||
|
type: String,
|
||||||
|
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
questionWordIds: [{ type: Schema.Types.ObjectId, ref: 'Word', required: true }],
|
||||||
|
questionTokens: [{ type: String, required: true }],
|
||||||
|
issuedAt: { type: Date, required: true },
|
||||||
|
expiresAt: { type: Date, required: true },
|
||||||
|
submittedAt: Date,
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['active', 'submitted', 'expired'],
|
||||||
|
default: 'active',
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
VocabularyTestAttemptSchema.index({ user: 1, status: 1, expiresAt: 1 });
|
||||||
|
VocabularyTestAttemptSchema.index({ expiresAt: 1 });
|
||||||
|
|
||||||
// 创建和导出模型
|
// 创建和导出模型
|
||||||
export const Word = mongoose.model<IWord>('Word', WordSchema);
|
export const Word = mongoose.model<IWord>('Word', WordSchema);
|
||||||
export const WordSet = mongoose.model<IWordSet>('WordSet', WordSetSchema);
|
export const WordSet = mongoose.model<IWordSet>('WordSet', WordSetSchema);
|
||||||
export const WordRecord = mongoose.model('WordRecord', WordRecordSchema);
|
export const WordRecord = mongoose.model('WordRecord', WordRecordSchema);
|
||||||
export const VocabularyTestRecord = mongoose.model<IVocabularyTestRecord>('VocabularyTestRecord', VocabularyTestRecordSchema);
|
export const VocabularyTestRecord = mongoose.model<IVocabularyTestRecord>('VocabularyTestRecord', VocabularyTestRecordSchema);
|
||||||
|
export const VocabularyTestAttempt = mongoose.model<IVocabularyTestAttempt>('VocabularyTestAttempt', VocabularyTestAttemptSchema);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Word,
|
Word,
|
||||||
WordSet,
|
WordSet,
|
||||||
WordRecord,
|
WordRecord,
|
||||||
VocabularyTestRecord
|
VocabularyTestRecord,
|
||||||
|
VocabularyTestAttempt
|
||||||
};
|
};
|
||||||
@@ -3,14 +3,299 @@ import express from 'express';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { auth as authMiddleware } from '../middleware/auth';
|
import { auth as authMiddleware } from '../middleware/auth';
|
||||||
import { Word, WordSet, WordRecord, VocabularyTestRecord } from '../models/Vocabulary';
|
import { Word, WordSet, WordRecord, VocabularyTestRecord, VocabularyTestAttempt } from '../models/Vocabulary';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import csv from 'csv-parser';
|
import csv from 'csv-parser';
|
||||||
import { User } from '../models/User';
|
import { User } from '../models/User';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
type VocabularyTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||||
|
type WordRecordModeKey = 'chineseToEnglish' | 'audioToEnglish' | 'multipleChoice';
|
||||||
|
|
||||||
|
interface EvaluatedVocabularyAnswer {
|
||||||
|
wordId: string;
|
||||||
|
word: string;
|
||||||
|
userAnswer: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VocabularyAttemptQuestion {
|
||||||
|
questionToken: string;
|
||||||
|
wordId: string;
|
||||||
|
word?: string;
|
||||||
|
translation?: string;
|
||||||
|
pronunciation?: string;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ATTEMPT_TTL_MS = 30 * 60 * 1000;
|
||||||
|
const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 0.4);
|
||||||
|
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
|
||||||
|
|
||||||
|
const isVocabularyTestType = (value: unknown): value is VocabularyTestType => {
|
||||||
|
return value === 'chinese-to-english' ||
|
||||||
|
value === 'audio-to-english' ||
|
||||||
|
value === 'multiple-choice';
|
||||||
|
};
|
||||||
|
|
||||||
|
const shuffle = <T,>(array: T[]): T[] => {
|
||||||
|
const newArray = [...array];
|
||||||
|
for (let i = newArray.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||||
|
}
|
||||||
|
return newArray;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||||
|
|
||||||
|
const signQuestionToken = (
|
||||||
|
attemptId: string,
|
||||||
|
wordId: string,
|
||||||
|
index: number,
|
||||||
|
issuedAt: Date
|
||||||
|
): string => {
|
||||||
|
const issuedAtMs = issuedAt.getTime();
|
||||||
|
const payload = `${attemptId}:${wordId}:${index}:${issuedAtMs}`;
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', ATTEMPT_SECRET)
|
||||||
|
.update(payload)
|
||||||
|
.digest('hex');
|
||||||
|
return `${index}.${issuedAtMs}.${signature}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeEqualString = (left: string, right: string): boolean => {
|
||||||
|
const leftBuffer = Buffer.from(left || '');
|
||||||
|
const rightBuffer = Buffer.from(right || '');
|
||||||
|
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyQuestionToken = (
|
||||||
|
token: string,
|
||||||
|
attemptId: string,
|
||||||
|
wordId: string,
|
||||||
|
index: number,
|
||||||
|
issuedAt: Date
|
||||||
|
): boolean => {
|
||||||
|
const expected = signQuestionToken(attemptId, wordId, index, issuedAt);
|
||||||
|
return safeEqualString(expected, token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAnswerProof = (
|
||||||
|
attemptId: string,
|
||||||
|
questionToken: string,
|
||||||
|
userAnswer: string,
|
||||||
|
submittedAt: number
|
||||||
|
): string => crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${attemptId}:${questionToken}:${submittedAt}:${String(userAnswer ?? '')}`)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const buildMultipleChoiceOptions = (word: any, pool: any[]): string[] => {
|
||||||
|
const correctTranslation = String(word.translation ?? '').trim();
|
||||||
|
let incorrectOptions = shuffle(
|
||||||
|
pool
|
||||||
|
.filter(item => getObjectIdString(item) !== getObjectIdString(word))
|
||||||
|
.map(item => String(item.translation ?? '').trim())
|
||||||
|
.filter(item => item && item !== correctTranslation)
|
||||||
|
).slice(0, 3);
|
||||||
|
|
||||||
|
if (incorrectOptions.length < 3) {
|
||||||
|
const fakeOptions = ['假选项1', '假选项2', '假选项3', '假选项4', '假选项5']
|
||||||
|
.filter(item => item !== correctTranslation && !incorrectOptions.includes(item));
|
||||||
|
incorrectOptions = [...incorrectOptions, ...fakeOptions].slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return shuffle([correctTranslation, ...incorrectOptions]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildQuestionPayload = (
|
||||||
|
word: any,
|
||||||
|
testType: VocabularyTestType,
|
||||||
|
questionToken: string,
|
||||||
|
optionsPool: any[]
|
||||||
|
): VocabularyAttemptQuestion => {
|
||||||
|
const base = {
|
||||||
|
questionToken,
|
||||||
|
wordId: getObjectIdString(word),
|
||||||
|
pronunciation: word.pronunciation
|
||||||
|
};
|
||||||
|
|
||||||
|
if (testType === 'chinese-to-english') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
translation: word.translation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testType === 'audio-to-english') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
word: word.word
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
word: word.word,
|
||||||
|
options: buildMultipleChoiceOptions(word, optionsPool)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeEnglishAnswer = (value: unknown): string => {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/(/g, '(')
|
||||||
|
.replace(/)/g, ')')
|
||||||
|
.replace(/,/g, ',')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.replace(/,/g, '')
|
||||||
|
.replace(/[^\w()]/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateWordAnswer = (word: any, testType: VocabularyTestType, answer: unknown): EvaluatedVocabularyAnswer => {
|
||||||
|
const userAnswer = String(answer ?? '').trim().slice(0, 500);
|
||||||
|
const correctAnswer = testType === 'multiple-choice'
|
||||||
|
? String(word.translation ?? '').trim()
|
||||||
|
: String(word.word ?? '').trim();
|
||||||
|
const isCorrect = testType === 'multiple-choice'
|
||||||
|
? userAnswer === correctAnswer
|
||||||
|
: normalizeEnglishAnswer(userAnswer) === normalizeEnglishAnswer(correctAnswer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
wordId: word._id.toString(),
|
||||||
|
word: word.word,
|
||||||
|
userAnswer,
|
||||||
|
correctAnswer,
|
||||||
|
isCorrect
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMasteryStatus = (record: any) => {
|
||||||
|
const masteryStatus: Record<string, any> = {};
|
||||||
|
WORD_RECORD_MODES.forEach(mode => {
|
||||||
|
masteryStatus[mode] = {
|
||||||
|
mastered: record[mode]?.mastered || false,
|
||||||
|
streak: record[mode]?.streak || 0,
|
||||||
|
totalCorrect: record[mode]?.totalCorrect || 0,
|
||||||
|
totalWrong: record[mode]?.totalWrong || 0,
|
||||||
|
inWrongBook: record[mode]?.inWrongBook || false,
|
||||||
|
lastMasteredAt: record[mode]?.lastMasteredAt || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return masteryStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWordRecordForAnswer = async (
|
||||||
|
userId: string,
|
||||||
|
wordId: string,
|
||||||
|
testType: VocabularyTestType,
|
||||||
|
isCorrect: boolean
|
||||||
|
) => {
|
||||||
|
const modeKey = TEST_TYPE_TO_MODE[testType];
|
||||||
|
|
||||||
|
let record: any = await WordRecord.findOne({ user: userId, word: wordId });
|
||||||
|
const modeObj = record ? { ...(record[modeKey]?.toObject?.() || record[modeKey] || {}) } : {};
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
modeObj.streak = (modeObj.streak || 0) + 1;
|
||||||
|
modeObj.totalCorrect = (modeObj.totalCorrect || 0) + 1;
|
||||||
|
} else {
|
||||||
|
modeObj.streak = 0;
|
||||||
|
modeObj.totalWrong = (modeObj.totalWrong || 0) + 1;
|
||||||
|
}
|
||||||
|
modeObj.lastTestedAt = new Date();
|
||||||
|
|
||||||
|
if (modeObj.streak >= 1) {
|
||||||
|
modeObj.mastered = true;
|
||||||
|
modeObj.lastMasteredAt = new Date();
|
||||||
|
modeObj.inWrongBook = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modeObj.mastered && modeObj.totalWrong >= 5) {
|
||||||
|
modeObj.inWrongBook = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateObj: Record<string, any> = {};
|
||||||
|
updateObj[modeKey] = modeObj;
|
||||||
|
|
||||||
|
record = await WordRecord.findOneAndUpdate(
|
||||||
|
{ user: userId, word: wordId },
|
||||||
|
{ $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordAny = record as any;
|
||||||
|
const isFullyMastered = WORD_RECORD_MODES.every(mode => recordAny[mode]?.mastered);
|
||||||
|
|
||||||
|
if (isFullyMastered) {
|
||||||
|
let latestMasteredAt: Date | null = null;
|
||||||
|
WORD_RECORD_MODES.forEach(mode => {
|
||||||
|
[recordAny[mode]?.lastMasteredAt, recordAny[mode]?.lastTestedAt].forEach(value => {
|
||||||
|
if (!value) return;
|
||||||
|
const candidate = new Date(value);
|
||||||
|
if (!Number.isNaN(candidate.getTime()) && (!latestMasteredAt || candidate > latestMasteredAt)) {
|
||||||
|
latestMasteredAt = candidate;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recordAny.isFullyMastered || !recordAny.lastFullyMasteredAt ||
|
||||||
|
(latestMasteredAt && recordAny.lastFullyMasteredAt < latestMasteredAt)) {
|
||||||
|
record = await WordRecord.findOneAndUpdate(
|
||||||
|
{ _id: recordAny._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
isFullyMastered: true,
|
||||||
|
lastFullyMasteredAt: latestMasteredAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (recordAny.isFullyMastered) {
|
||||||
|
record = await WordRecord.findOneAndUpdate(
|
||||||
|
{ _id: recordAny._id },
|
||||||
|
{ $set: { isFullyMastered: false } },
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRecord = record as any;
|
||||||
|
return {
|
||||||
|
masteryStatus: buildMasteryStatus(updatedRecord),
|
||||||
|
isFullyMastered: updatedRecord.isFullyMastered,
|
||||||
|
lastMasteredAt: updatedRecord.lastFullyMasteredAt,
|
||||||
|
inWrongBook: WORD_RECORD_MODES.some(mode => updatedRecord[mode]?.inWrongBook)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientStartTime = (stats: any, fallbackTime: number): Date => {
|
||||||
|
const parsed = stats?.startTime ? new Date(stats.startTime).getTime() : NaN;
|
||||||
|
const maxDuration = 4 * 60 * 60 * 1000;
|
||||||
|
if (Number.isFinite(parsed) && parsed <= fallbackTime && fallbackTime - parsed <= maxDuration) {
|
||||||
|
return new Date(parsed);
|
||||||
|
}
|
||||||
|
return new Date(fallbackTime);
|
||||||
|
};
|
||||||
|
|
||||||
// 配置 multer 用于文件上传
|
// 配置 multer 用于文件上传
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
@@ -315,177 +600,288 @@ router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建测试会话:服务端固定题目顺序并签发每题防伪token
|
||||||
|
router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { wordSetId, testType, wordIds, count } = req.body;
|
||||||
|
const userId = req.user?._id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ message: '用户信息无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mongoose.isValidObjectId(wordSetId)) {
|
||||||
|
return res.status(400).json({ message: '单词集ID无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVocabularyTestType(testType)) {
|
||||||
|
return res.status(400).json({ message: '未知的测试类型' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordSet = await WordSet.findById(wordSetId);
|
||||||
|
if (!wordSet) {
|
||||||
|
return res.status(404).json({ message: '未找到单词集' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedWords: any[] = [];
|
||||||
|
const allWords = await Word.find({ wordSet: wordSetId });
|
||||||
|
|
||||||
|
if (Array.isArray(wordIds) && wordIds.length > 0) {
|
||||||
|
if (wordIds.length > 100) {
|
||||||
|
return res.status(400).json({ message: '单次测试最多100道题' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordIds.some((id: unknown) => !mongoose.isValidObjectId(id))) {
|
||||||
|
return res.status(400).json({ message: '包含无效单词ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))];
|
||||||
|
if (uniqueWordIds.length !== wordIds.length) {
|
||||||
|
return res.status(400).json({ message: '测试单词不能重复' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordMap = new Map<string, any>(allWords.map(word => [getObjectIdString(word), word]));
|
||||||
|
selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId));
|
||||||
|
|
||||||
|
if (selectedWords.some(word => !word)) {
|
||||||
|
return res.status(400).json({ message: '测试单词必须属于当前单词集' });
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedWords = shuffle(selectedWords);
|
||||||
|
} else {
|
||||||
|
const parsedCount = Number.parseInt(String(count || 100), 10);
|
||||||
|
const targetCount = Number.isFinite(parsedCount)
|
||||||
|
? Math.max(1, Math.min(parsedCount, 100))
|
||||||
|
: 100;
|
||||||
|
selectedWords = shuffle(allWords).slice(0, targetCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedWords.length === 0) {
|
||||||
|
return res.status(400).json({ message: '没有可测试的单词' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuedAt = new Date();
|
||||||
|
const expiresAt = new Date(issuedAt.getTime() + ATTEMPT_TTL_MS);
|
||||||
|
const attempt = await VocabularyTestAttempt.create({
|
||||||
|
user: userId,
|
||||||
|
wordSet: wordSetId,
|
||||||
|
testType,
|
||||||
|
questionWordIds: selectedWords.map(word => word._id),
|
||||||
|
questionTokens: [],
|
||||||
|
issuedAt,
|
||||||
|
expiresAt,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
const attemptId = attempt._id.toString();
|
||||||
|
const questionTokens = selectedWords.map((word, index) =>
|
||||||
|
signQuestionToken(attemptId, getObjectIdString(word), index, issuedAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
attempt.questionTokens = questionTokens;
|
||||||
|
await attempt.save();
|
||||||
|
|
||||||
|
const questions = selectedWords.map((word, index) =>
|
||||||
|
buildQuestionPayload(word, testType, questionTokens[index], allWords)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
attemptId,
|
||||||
|
testType,
|
||||||
|
expiresAt,
|
||||||
|
questions
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建测试会话失败:', error);
|
||||||
|
res.status(500).json({ message: '创建测试会话失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 记录单词学习结果(嵌套结构版,upsert防止重复)
|
// 记录单词学习结果(嵌套结构版,upsert防止重复)
|
||||||
router.post('/word-record', (req, res, next) => {
|
router.post('/word-record', (req, res, next) => {
|
||||||
console.log('收到 /word-record 请求', req.method, req.body);
|
console.log('收到 /word-record 请求', req.method, req.body);
|
||||||
next();
|
next();
|
||||||
}, authMiddleware, async (req, res) => {
|
}, authMiddleware, async (req, res) => {
|
||||||
|
return res.status(410).json({
|
||||||
|
message: '单题记录接口已停用,请通过测试会话提交记录'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存测试记录:只能通过服务端签发的 attempt 提交
|
||||||
|
router.post('/test-record', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { wordId, isCorrect, testType } = req.body;
|
const { attemptId, answers } = req.body;
|
||||||
const userId = req.user._id;
|
const userId = req.user?._id;
|
||||||
|
|
||||||
// 检查单词是否存在
|
if (!userId) {
|
||||||
const word = await Word.findById(wordId);
|
return res.status(401).json({ message: '用户信息无效' });
|
||||||
if (!word) {
|
|
||||||
return res.status(404).json({ message: '未找到单词' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 嵌套字段名映射
|
if (!mongoose.isValidObjectId(attemptId)) {
|
||||||
const modeMap = {
|
return res.status(400).json({ message: '测试会话ID无效' });
|
||||||
'chinese-to-english': 'chineseToEnglish',
|
|
||||||
'audio-to-english': 'audioToEnglish',
|
|
||||||
'multiple-choice': 'multipleChoice'
|
|
||||||
};
|
|
||||||
const modeKey = modeMap[testType];
|
|
||||||
if (!modeKey) {
|
|
||||||
return res.status(400).json({ message: '未知的测试类型' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先查当前记录
|
if (!Array.isArray(answers) || answers.length === 0) {
|
||||||
let record = await WordRecord.findOne({ user: userId, word: wordId });
|
return res.status(400).json({ message: '测试答案不能为空' });
|
||||||
let modeObj = record ? (record[modeKey] || {}) : {};
|
|
||||||
|
|
||||||
// 更新 streak、totalCorrect、totalWrong
|
|
||||||
if (isCorrect) {
|
|
||||||
modeObj.streak = (modeObj.streak || 0) + 1;
|
|
||||||
modeObj.totalCorrect = (modeObj.totalCorrect || 0) + 1;
|
|
||||||
} else {
|
|
||||||
modeObj.streak = 0;
|
|
||||||
modeObj.totalWrong = (modeObj.totalWrong || 0) + 1;
|
|
||||||
}
|
|
||||||
modeObj.lastTestedAt = new Date();
|
|
||||||
|
|
||||||
// 判定是否掌握
|
|
||||||
if (modeObj.streak >= 1) {
|
|
||||||
modeObj.mastered = true;
|
|
||||||
modeObj.lastMasteredAt = new Date();
|
|
||||||
modeObj.inWrongBook = false; // 掌握后自动移出错词本
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判定是否进入错词本
|
const attempt = await VocabularyTestAttempt.findOne({
|
||||||
if (!modeObj.mastered && modeObj.totalWrong >= 5) {
|
_id: attemptId,
|
||||||
modeObj.inWrongBook = true;
|
user: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attempt) {
|
||||||
|
return res.status(404).json({ message: '未找到测试会话' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造更新对象
|
const attemptAny = attempt as any;
|
||||||
const updateObj = {};
|
const issuedAt = new Date(attemptAny.issuedAt || attemptAny.createdAt);
|
||||||
updateObj[modeKey] = modeObj;
|
const expiresAt = new Date(attemptAny.expiresAt);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
record = await WordRecord.findOneAndUpdate(
|
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||||
{ user: userId, word: wordId },
|
attemptAny.status = 'expired';
|
||||||
{ $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } },
|
await attempt.save();
|
||||||
{ upsert: true, new: true }
|
return res.status(410).json({ message: '测试会话已过期,请重新开始测试' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptAny.status !== 'active') {
|
||||||
|
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answers.length !== attemptAny.questionTokens.length) {
|
||||||
|
return res.status(400).json({ message: '答案数量与测试题目不一致' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenIndexMap = new Map<string, number>();
|
||||||
|
attemptAny.questionTokens.forEach((token: string, index: number) => {
|
||||||
|
tokenIndexMap.set(token, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const answerSeen = new Set<string>();
|
||||||
|
const evaluatedResults: EvaluatedVocabularyAnswer[] = [];
|
||||||
|
const submittedAnswers: Array<{ token: string; userAnswer: string; submittedAt: number; answerProof: string }> = [];
|
||||||
|
|
||||||
|
for (const item of answers) {
|
||||||
|
const token = String(item?.questionToken ?? '');
|
||||||
|
const userAnswer = String(item?.userAnswer ?? '').trim().slice(0, 500);
|
||||||
|
const answerProof = String(item?.answerProof ?? '');
|
||||||
|
const submittedAtMs = Number.parseInt(String(item?.submittedAt), 10);
|
||||||
|
|
||||||
|
if (!token || !tokenIndexMap.has(token)) {
|
||||||
|
return res.status(400).json({ message: '测试答案的题目标识无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answerSeen.has(token)) {
|
||||||
|
return res.status(400).json({ message: '测试答案中存在重复题目' });
|
||||||
|
}
|
||||||
|
answerSeen.add(token);
|
||||||
|
|
||||||
|
if (!Number.isFinite(submittedAtMs)) {
|
||||||
|
return res.status(400).json({ message: '答案提交时间无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||||||
|
return res.status(400).json({ message: '答案提交时间异常' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = tokenIndexMap.get(token)!;
|
||||||
|
const wordId = getObjectIdString(attemptAny.questionWordIds[index]);
|
||||||
|
if (!wordId) {
|
||||||
|
return res.status(400).json({ message: '测试会话题目数据损坏' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyQuestionToken(token, attemptId.toString(), wordId, index, issuedAt)) {
|
||||||
|
return res.status(400).json({ message: '测试题目签名无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedProof = buildAnswerProof(attemptId.toString(), token, userAnswer, submittedAtMs);
|
||||||
|
if (!safeEqualString(expectedProof, answerProof)) {
|
||||||
|
return res.status(400).json({ message: '答案校验失败' });
|
||||||
|
}
|
||||||
|
|
||||||
|
submittedAnswers.push({ token, userAnswer, submittedAt: submittedAtMs, answerProof });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSubmittedAtMs = Math.max(...submittedAnswers.map(item => item.submittedAt));
|
||||||
|
const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000);
|
||||||
|
const minimumSeconds = Math.max(2, answers.length * MIN_SECONDS_PER_QUESTION);
|
||||||
|
if (elapsedSeconds < minimumSeconds) {
|
||||||
|
return res.status(400).json({ message: '答题速度异常,请重新作答' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionWordIds = attemptAny.questionWordIds.map((wordId: any) => getObjectIdString(wordId));
|
||||||
|
const wordMap = new Map<string, any>();
|
||||||
|
const words = await Word.find({
|
||||||
|
_id: { $in: questionWordIds },
|
||||||
|
wordSet: attemptAny.wordSet
|
||||||
|
});
|
||||||
|
words.forEach(word => {
|
||||||
|
wordMap.set(getObjectIdString(word), word);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of submittedAnswers) {
|
||||||
|
const index = tokenIndexMap.get(item.token)!;
|
||||||
|
const wordId = questionWordIds[index];
|
||||||
|
const word = wordMap.get(wordId);
|
||||||
|
if (!word) {
|
||||||
|
return res.status(400).json({ message: '测试会话中的单词已不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluation = evaluateWordAnswer(word, attemptAny.testType, item.userAnswer);
|
||||||
|
evaluatedResults.push({
|
||||||
|
...evaluation,
|
||||||
|
wordId,
|
||||||
|
word: evaluation.word
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||||
|
{ _id: attempt._id, user: userId, status: 'active' },
|
||||||
|
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs) } },
|
||||||
|
{ new: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 检查三种模式是否都已掌握
|
if (!claimedAttempt) {
|
||||||
const allModes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice'];
|
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||||
const isFullyMastered = allModes.every(m => record[m]?.mastered);
|
|
||||||
let lastMasteredAt: Date | null = null;
|
|
||||||
if (isFullyMastered) {
|
|
||||||
// 取三种模式中最近一次掌握的时间
|
|
||||||
lastMasteredAt = allModes.reduce((latest: Date | null, m) => {
|
|
||||||
const t = record[m]?.lastMasteredAt;
|
|
||||||
if (!latest && t) return t;
|
|
||||||
if (t instanceof Date && latest instanceof Date && t > latest) {
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
return latest;
|
|
||||||
}, null);
|
|
||||||
|
|
||||||
// 取三种模式中最近一次测试的时间,如果比掌握时间更新则使用测试时间
|
|
||||||
const lastTestedAt = allModes.reduce((latest: Date | null, m) => {
|
|
||||||
const t = record[m]?.lastTestedAt;
|
|
||||||
if (!latest && t) return t;
|
|
||||||
if (t instanceof Date && latest instanceof Date && t > latest) {
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
return latest;
|
|
||||||
}, null);
|
|
||||||
|
|
||||||
// 使用最新的日期(掌握时间或测试时间)
|
|
||||||
if (lastTestedAt && (!lastMasteredAt || lastTestedAt > lastMasteredAt)) {
|
|
||||||
lastMasteredAt = lastTestedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新isFullyMastered和lastFullyMasteredAt字段
|
|
||||||
if (!record.isFullyMastered || !record.lastFullyMasteredAt ||
|
|
||||||
(lastMasteredAt && record.lastFullyMasteredAt < lastMasteredAt)) {
|
|
||||||
record = await WordRecord.findOneAndUpdate(
|
|
||||||
{ _id: record._id },
|
|
||||||
{
|
|
||||||
$set: {
|
|
||||||
isFullyMastered: true,
|
|
||||||
lastFullyMasteredAt: lastMasteredAt
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ new: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (record.isFullyMastered) {
|
|
||||||
// 如果之前标记为完全掌握,但现在不是,更新状态
|
|
||||||
record = await WordRecord.findOneAndUpdate(
|
|
||||||
{ _id: record._id },
|
|
||||||
{ $set: { isFullyMastered: false } },
|
|
||||||
{ new: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回当前单词的所有模式掌握状态和是否在错词本
|
for (const result of evaluatedResults) {
|
||||||
const masteryStatus = {};
|
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
||||||
allModes.forEach(m => {
|
}
|
||||||
masteryStatus[m] = {
|
|
||||||
mastered: record[m]?.mastered || false,
|
const totalWords = evaluatedResults.length;
|
||||||
streak: record[m]?.streak || 0,
|
const correctWords = evaluatedResults.filter(result => result.isCorrect).length;
|
||||||
totalCorrect: record[m]?.totalCorrect || 0,
|
const verifiedStats = {
|
||||||
totalWrong: record[m]?.totalWrong || 0,
|
totalWords,
|
||||||
inWrongBook: record[m]?.inWrongBook || false,
|
correctWords,
|
||||||
lastMasteredAt: record[m]?.lastMasteredAt || null
|
accuracy: totalWords > 0 ? (correctWords / totalWords) * 100 : 0,
|
||||||
};
|
startTime: issuedAt,
|
||||||
|
endTime: new Date(lastSubmittedAtMs),
|
||||||
|
duration: Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
await VocabularyTestRecord.create({
|
||||||
|
user: userId,
|
||||||
|
wordSet: attemptAny.wordSet,
|
||||||
|
attempt: attempt._id,
|
||||||
|
testType: attemptAny.testType,
|
||||||
|
stats: verifiedStats,
|
||||||
|
results: evaluatedResults.map(result => ({
|
||||||
|
word: result.wordId,
|
||||||
|
userAnswer: result.userAnswer,
|
||||||
|
correctAnswer: result.correctAnswer,
|
||||||
|
isCorrect: result.isCorrect
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: '记录已保存',
|
message: '测试记录已保存',
|
||||||
masteryStatus,
|
attemptId: attempt._id.toString(),
|
||||||
isFullyMastered: record.isFullyMastered,
|
stats: verifiedStats,
|
||||||
lastMasteredAt: record.lastFullyMasteredAt,
|
results: evaluatedResults,
|
||||||
inWrongBook: allModes.some(m => record[m]?.inWrongBook)
|
submittedAt: new Date(lastSubmittedAtMs)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('保存单词学习记录失败:', error);
|
|
||||||
res.status(500).json({ message: '保存单词学习记录失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存测试记录
|
|
||||||
router.post('/test-record', authMiddleware, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { wordSetId, testType, stats } = req.body;
|
|
||||||
|
|
||||||
// 检查单词集是否存在并属于当前用户
|
|
||||||
const wordSet = await WordSet.findOne({
|
|
||||||
_id: wordSetId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!wordSet) {
|
|
||||||
return res.status(404).json({ message: '未找到单词集' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建测试记录
|
|
||||||
await VocabularyTestRecord.create({
|
|
||||||
user: req.user._id,
|
|
||||||
wordSet: wordSetId,
|
|
||||||
testType,
|
|
||||||
stats: {
|
|
||||||
totalWords: stats.totalWords,
|
|
||||||
correctWords: stats.correctWords,
|
|
||||||
accuracy: stats.accuracy,
|
|
||||||
startTime: new Date(stats.startTime),
|
|
||||||
endTime: new Date(stats.endTime),
|
|
||||||
duration: stats.duration
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json({ message: '测试记录已保存' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存测试记录失败:', error);
|
console.error('保存测试记录失败:', error);
|
||||||
res.status(500).json({ message: '保存测试记录失败' });
|
res.status(500).json({ message: '保存测试记录失败' });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, Select, Table, Tag, InputNumber, Space } from 'antd';
|
import { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, Select, Table, Tag, InputNumber, Space } from 'antd';
|
||||||
import { SoundOutlined, CaretLeftOutlined, CaretRightOutlined, TrophyOutlined, HistoryOutlined, ReloadOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons';
|
import { SoundOutlined, CaretLeftOutlined, CaretRightOutlined, TrophyOutlined, HistoryOutlined, ReloadOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons';
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api, ApiError, authEvents } from '../api/apiClient';
|
import { api, ApiError, authEvents } from '../api/apiClient';
|
||||||
import { API_PATHS } from '../config';
|
import { API_PATHS } from '../config';
|
||||||
@@ -8,6 +9,7 @@ import type { TabsProps } from 'antd';
|
|||||||
import type { RadioChangeEvent } from 'antd/lib/radio';
|
import type { RadioChangeEvent } from 'antd/lib/radio';
|
||||||
|
|
||||||
interface Word {
|
interface Word {
|
||||||
|
_id?: string;
|
||||||
id: string;
|
id: string;
|
||||||
word: string;
|
word: string;
|
||||||
translation: string;
|
translation: string;
|
||||||
@@ -56,11 +58,57 @@ interface LeaderboardItem {
|
|||||||
rank: number;
|
rank: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
questionToken?: string;
|
||||||
|
wordId: string;
|
||||||
|
word: string;
|
||||||
|
userAnswer: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingAnswer {
|
||||||
|
questionToken: string;
|
||||||
|
userAnswer: string;
|
||||||
|
submittedAt: number;
|
||||||
|
answerProof: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestQuestion {
|
||||||
|
questionToken: string;
|
||||||
|
wordId: string;
|
||||||
|
word?: string;
|
||||||
|
translation?: string;
|
||||||
|
pronunciation?: string;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SavedVocabularyTestRecordResponse {
|
||||||
|
message: string;
|
||||||
|
stats: {
|
||||||
|
totalWords: number;
|
||||||
|
correctWords: number;
|
||||||
|
accuracy: number;
|
||||||
|
startTime: string | Date;
|
||||||
|
endTime: string | Date;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
results: TestResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestAttemptResponse {
|
||||||
|
attemptId: string;
|
||||||
|
testType: string;
|
||||||
|
expiresAt: string | Date;
|
||||||
|
questions: TestQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
const VocabularyStudy: React.FC = () => {
|
const VocabularyStudy: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wordSets, setWordSets] = useState<WordSet[]>([]);
|
const [wordSets, setWordSets] = useState<WordSet[]>([]);
|
||||||
const [selectedWordSet, setSelectedWordSet] = useState<string>('');
|
const [selectedWordSet, setSelectedWordSet] = useState<string>('');
|
||||||
|
const [loadedWordSetId, setLoadedWordSetId] = useState<string>('');
|
||||||
const [currentWords, setCurrentWords] = useState<Word[]>([]);
|
const [currentWords, setCurrentWords] = useState<Word[]>([]);
|
||||||
const [studyStats, setStudyStats] = useState<StudyStats>({
|
const [studyStats, setStudyStats] = useState<StudyStats>({
|
||||||
totalWords: 0,
|
totalWords: 0,
|
||||||
@@ -76,12 +124,10 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
const [showAnswer, setShowAnswer] = useState(false);
|
const [showAnswer, setShowAnswer] = useState(false);
|
||||||
const [testStarted, setTestStarted] = useState(false);
|
const [testStarted, setTestStarted] = useState(false);
|
||||||
const [testFinished, setTestFinished] = useState(false);
|
const [testFinished, setTestFinished] = useState(false);
|
||||||
const [testResults, setTestResults] = useState<{
|
const [testResults, setTestResults] = useState<TestResult[]>([]);
|
||||||
word: string;
|
const [testAttemptId, setTestAttemptId] = useState<string>('');
|
||||||
userAnswer: string;
|
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
|
||||||
correctAnswer: string;
|
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
|
||||||
isCorrect: boolean;
|
|
||||||
}[]>([]);
|
|
||||||
const [options, setOptions] = useState<string[]>([]);
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const timeOffsetRef = useRef<number>(0);
|
const timeOffsetRef = useRef<number>(0);
|
||||||
@@ -96,6 +142,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
|
|
||||||
// 使用ref存储当前选中ID,这样可以立即访问
|
// 使用ref存储当前选中ID,这样可以立即访问
|
||||||
const currentWordSetIdRef = useRef<string>('');
|
const currentWordSetIdRef = useRef<string>('');
|
||||||
|
const answerSubmittingRef = useRef(false);
|
||||||
|
|
||||||
// 测试输入框ref
|
// 测试输入框ref
|
||||||
const inputRef = useRef<any>(null);
|
const inputRef = useRef<any>(null);
|
||||||
@@ -103,7 +150,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
// 新增 state
|
// 新增 state
|
||||||
const [studyWords, setStudyWords] = useState<Word[]>([]);
|
const [studyWords, setStudyWords] = useState<Word[]>([]);
|
||||||
const [studyIndex, setStudyIndex] = useState(0);
|
const [studyIndex, setStudyIndex] = useState(0);
|
||||||
const [testWords, setTestWords] = useState<Word[]>([]);
|
const [testWords, setTestWords] = useState<TestQuestion[]>([]);
|
||||||
const [testIndex, setTestIndex] = useState(0);
|
const [testIndex, setTestIndex] = useState(0);
|
||||||
|
|
||||||
// 添加声音相关的状态
|
// 添加声音相关的状态
|
||||||
@@ -192,9 +239,14 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
console.log('获取到的学习单词:', response);
|
console.log('获取到的学习单词:', response);
|
||||||
|
|
||||||
if (response && response.length > 0) {
|
if (response && response.length > 0) {
|
||||||
const shuffledWords = shuffleArray(response);
|
const normalizedWords = response.map(item => ({
|
||||||
|
...item,
|
||||||
|
id: item.id || item._id || ''
|
||||||
|
}));
|
||||||
|
const shuffledWords = shuffleArray(normalizedWords);
|
||||||
setStudyWords(shuffledWords);
|
setStudyWords(shuffledWords);
|
||||||
setStudyIndex(0);
|
setStudyIndex(0);
|
||||||
|
setLoadedWordSetId(effectiveId);
|
||||||
setActiveTab('study');
|
setActiveTab('study');
|
||||||
message.success(`成功加载 ${shuffledWords.length} 个单词`);
|
message.success(`成功加载 ${shuffledWords.length} 个单词`);
|
||||||
} else {
|
} else {
|
||||||
@@ -365,54 +417,61 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 开始测试
|
// 开始测试会话
|
||||||
const startTest = () => {
|
const startTest = async () => {
|
||||||
if (studyWords.length === 0) {
|
if (studyWords.length === 0) {
|
||||||
message.error('请先加载单词');
|
message.error('请先加载单词');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shuffled = shuffleArray([...studyWords]);
|
|
||||||
setTestWords(shuffled);
|
|
||||||
setTestIndex(0);
|
|
||||||
setTestStarted(true);
|
|
||||||
setTestFinished(false);
|
|
||||||
setUserAnswer('');
|
|
||||||
setShowAnswer(false);
|
|
||||||
setTestResults([]);
|
|
||||||
// 重置统计信息
|
|
||||||
setStudyStats({
|
|
||||||
totalWords: 0,
|
|
||||||
correctWords: 0,
|
|
||||||
accuracy: 0,
|
|
||||||
startTime: new Date(),
|
|
||||||
duration: 0
|
|
||||||
});
|
|
||||||
setActiveTab('test');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成多选题选项
|
try {
|
||||||
const generateMultipleChoiceOptions = (index: number, wordsArr?: Word[]) => {
|
setTestAttemptLoading(true);
|
||||||
const arr = wordsArr || testWords;
|
setTestAttemptId('');
|
||||||
const correctTranslation = arr[index].translation;
|
setPendingAnswers([]);
|
||||||
let availableOptions = arr
|
setTestResults([]);
|
||||||
.filter(w => w.translation !== correctTranslation)
|
const wordIds = studyWords.map(getWordId).filter(Boolean);
|
||||||
.map(w => w.translation);
|
if (wordIds.length === 0) {
|
||||||
|
message.error('单词数据不完整,请重新加载后再试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await api.post<TestAttemptResponse>(API_PATHS.VOCABULARY.TEST_ATTEMPT, {
|
||||||
|
wordSetId: loadedWordSetId || selectedWordSet || currentWordSetIdRef.current,
|
||||||
|
testType,
|
||||||
|
wordIds
|
||||||
|
});
|
||||||
|
|
||||||
// 如果可用选项太少,添加一些假选项
|
const questions = response.questions.map(question => ({
|
||||||
if (availableOptions.length < 3) {
|
...question,
|
||||||
const fakeOptions = [
|
options: question.options || []
|
||||||
'假选项1', '假选项2', '假选项3', '假选项4', '假选项5'
|
}));
|
||||||
].filter(opt => opt !== correctTranslation);
|
|
||||||
availableOptions = [...availableOptions, ...fakeOptions];
|
setTestAttemptId(response.attemptId);
|
||||||
|
setTestWords(questions);
|
||||||
|
setTestIndex(0);
|
||||||
|
answerSubmittingRef.current = false;
|
||||||
|
setTestStarted(true);
|
||||||
|
setTestFinished(false);
|
||||||
|
setUserAnswer('');
|
||||||
|
setShowAnswer(false);
|
||||||
|
setTestResults([]);
|
||||||
|
setPendingAnswers([]);
|
||||||
|
setOptions(questions[0]?.options || []);
|
||||||
|
setStudyStats({
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
startTime: new Date(),
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
message.error(`创建测试会话失败: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
message.error('创建测试会话失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setTestAttemptLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打乱并选择3个错误选项
|
|
||||||
availableOptions = shuffleArray(availableOptions);
|
|
||||||
const incorrectOptions = availableOptions.slice(0, 3);
|
|
||||||
|
|
||||||
// 合并正确选项和错误选项,然后打乱
|
|
||||||
const allOptions = shuffleArray([correctTranslation, ...incorrectOptions]);
|
|
||||||
setOptions(allOptions);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数组随机排序
|
// 数组随机排序
|
||||||
@@ -425,6 +484,17 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
return newArray;
|
return newArray;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getWordId = (word: Word): string => word._id || word.id;
|
||||||
|
|
||||||
|
const buildAnswerProof = (
|
||||||
|
attemptId: string,
|
||||||
|
questionToken: string,
|
||||||
|
userAnswer: string,
|
||||||
|
submittedAt: number
|
||||||
|
): string => {
|
||||||
|
return CryptoJS.SHA256(`${attemptId}:${questionToken}:${submittedAt}:${userAnswer}`).toString();
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeAnswer(str: string): string {
|
function normalizeAnswer(str: string): string {
|
||||||
return str
|
return str
|
||||||
.replace(/(/g, '(')
|
.replace(/(/g, '(')
|
||||||
@@ -438,131 +508,93 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
|
|
||||||
// 提交答案
|
// 提交答案
|
||||||
const submitAnswer = async () => {
|
const submitAnswer = async () => {
|
||||||
if (!testStarted || testWords.length === 0) return;
|
if (!testStarted || testWords.length === 0 || !testAttemptId || answerSubmittingRef.current) return;
|
||||||
|
answerSubmittingRef.current = true;
|
||||||
|
|
||||||
const currentWord = testWords[testIndex];
|
const currentQuestion = testWords[testIndex];
|
||||||
let isCorrect = false;
|
if (!currentQuestion) {
|
||||||
let correctAnswer = '';
|
answerSubmittingRef.current = false;
|
||||||
|
return;
|
||||||
switch (testType) {
|
|
||||||
case 'chinese-to-english':
|
|
||||||
case 'audio-to-english':
|
|
||||||
correctAnswer = currentWord.word;
|
|
||||||
const normUser = normalizeAnswer(userAnswer);
|
|
||||||
const normCorrect = normalizeAnswer(correctAnswer);
|
|
||||||
isCorrect = normUser === normCorrect;
|
|
||||||
break;
|
|
||||||
case 'multiple-choice':
|
|
||||||
correctAnswer = currentWord.translation;
|
|
||||||
isCorrect = userAnswer === correctAnswer;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
const submittedAt = Date.now();
|
||||||
|
const normalizedUserAnswer = userAnswer.trim();
|
||||||
|
const answerProof = buildAnswerProof(
|
||||||
|
testAttemptId,
|
||||||
|
currentQuestion.questionToken,
|
||||||
|
normalizedUserAnswer,
|
||||||
|
submittedAt
|
||||||
|
);
|
||||||
|
|
||||||
// 更新测试结果数组
|
const updatedPendingAnswers = [
|
||||||
const updatedResults = [
|
...pendingAnswers,
|
||||||
...testResults,
|
|
||||||
{
|
{
|
||||||
word: currentWord.word,
|
questionToken: currentQuestion.questionToken,
|
||||||
userAnswer,
|
userAnswer: normalizedUserAnswer,
|
||||||
correctAnswer,
|
submittedAt,
|
||||||
isCorrect
|
answerProof
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
setTestResults(updatedResults);
|
|
||||||
|
|
||||||
// 更新学习统计信息
|
|
||||||
const correctCount = updatedResults.filter(r => r.isCorrect).length;
|
|
||||||
const totalCount = updatedResults.length;
|
|
||||||
|
|
||||||
setStudyStats(prev => ({
|
|
||||||
...prev,
|
|
||||||
totalWords: totalCount,
|
|
||||||
correctWords: correctCount,
|
|
||||||
accuracy: (correctCount / totalCount) * 100
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 记录学习记录
|
|
||||||
try {
|
|
||||||
await api.post(API_PATHS.VOCABULARY.WORD_RECORD, {
|
|
||||||
wordId: currentWord._id || currentWord.id,
|
|
||||||
isCorrect,
|
|
||||||
testType
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('记录单词学习结果失败', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCorrect) {
|
|
||||||
message.success('正确!');
|
|
||||||
} else {
|
|
||||||
message.error(`错误! 正确答案是: ${correctAnswer}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
setPendingAnswers(updatedPendingAnswers);
|
||||||
setShowAnswer(true);
|
setShowAnswer(true);
|
||||||
|
setUserAnswer('');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (testIndex < testWords.length - 1) {
|
if (testIndex < testWords.length - 1) {
|
||||||
setTestIndex(testIndex + 1);
|
setTestIndex(testIndex + 1);
|
||||||
setUserAnswer('');
|
|
||||||
setShowAnswer(false);
|
setShowAnswer(false);
|
||||||
// 多选题生成选项
|
answerSubmittingRef.current = false;
|
||||||
if (testType === 'multiple-choice') {
|
|
||||||
generateMultipleChoiceOptions(testIndex + 1);
|
|
||||||
}
|
|
||||||
// 听力自动播放
|
|
||||||
if (testType === 'audio-to-english') {
|
if (testType === 'audio-to-english') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
playWordSound(testWords[testIndex + 1].word);
|
playWordSound(testWords[testIndex + 1]?.word || '');
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
if (testType === 'multiple-choice') {
|
||||||
|
setOptions(testWords[testIndex + 1]?.options || []);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 最后一题,使用本地更新的结果,避免异步状态更新问题
|
answerSubmittingRef.current = false;
|
||||||
finishTestWithResults(updatedResults);
|
finishTestWithResults(updatedPendingAnswers);
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用指定结果完成测试
|
// 使用指定结果完成测试
|
||||||
const finishTestWithResults = async (finalResults: any[]) => {
|
const finishTestWithResults = async (finalAnswers: PendingAnswer[]) => {
|
||||||
try {
|
try {
|
||||||
setTestFinished(true);
|
setTestFinished(true);
|
||||||
|
|
||||||
// 获取服务器时间
|
|
||||||
const { serverTime } = await api.get<{ serverTime: number }>(API_PATHS.SYSTEM.SERVER_TIME);
|
|
||||||
|
|
||||||
const correctCount = finalResults.filter(r => r.isCorrect).length;
|
|
||||||
const totalCount = finalResults.length;
|
|
||||||
|
|
||||||
// 添加调试信息
|
// 添加调试信息
|
||||||
console.log('测试完成状态(带结果):', {
|
console.log('测试完成状态(带结果):', {
|
||||||
finalResultsLength: finalResults.length,
|
finalResultsLength: finalAnswers.length,
|
||||||
testWordsLength: testWords.length,
|
testWordsLength: testWords.length,
|
||||||
correctCount,
|
attemptId: testAttemptId,
|
||||||
totalCount,
|
|
||||||
currentStudyStats: studyStats
|
currentStudyStats: studyStats
|
||||||
});
|
});
|
||||||
|
|
||||||
// 直接使用传入的测试结果计算
|
// 提交测试记录,后端会用数据库答案重新判分
|
||||||
const updatedStats = {
|
const savedRecord = await api.post<SavedVocabularyTestRecordResponse>(API_PATHS.VOCABULARY.TEST_RECORD, {
|
||||||
totalWords: totalCount,
|
attemptId: testAttemptId,
|
||||||
correctWords: correctCount,
|
answers: finalAnswers.map(answer => ({
|
||||||
accuracy: totalCount > 0 ? (correctCount / totalCount) * 100 : 0,
|
questionToken: answer.questionToken,
|
||||||
startTime: studyStats.startTime,
|
userAnswer: answer.userAnswer,
|
||||||
endTime: new Date(serverTime),
|
submittedAt: answer.submittedAt,
|
||||||
duration: (serverTime - studyStats.startTime.getTime()) / 1000
|
answerProof: answer.answerProof
|
||||||
};
|
}))
|
||||||
|
|
||||||
// 更新统计信息
|
|
||||||
setStudyStats(updatedStats);
|
|
||||||
|
|
||||||
// 提交测试记录 - 使用计算好的最新数据
|
|
||||||
await api.post(API_PATHS.VOCABULARY.TEST_RECORD, {
|
|
||||||
wordSetId: selectedWordSet,
|
|
||||||
testType,
|
|
||||||
stats: updatedStats,
|
|
||||||
results: finalResults // 发送详细的测试结果,包括每道题的回答情况
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setStudyStats({
|
||||||
|
totalWords: savedRecord.stats.totalWords,
|
||||||
|
correctWords: savedRecord.stats.correctWords,
|
||||||
|
accuracy: savedRecord.stats.accuracy,
|
||||||
|
startTime: new Date(savedRecord.stats.startTime),
|
||||||
|
endTime: new Date(savedRecord.stats.endTime),
|
||||||
|
duration: savedRecord.stats.duration
|
||||||
|
});
|
||||||
|
setTestResults(savedRecord.results);
|
||||||
|
setPendingAnswers([]);
|
||||||
|
setTestAttemptId('');
|
||||||
|
|
||||||
message.success('测试完成,记录已保存');
|
message.success('测试完成,记录已保存');
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -577,19 +609,22 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
// 完成测试 - 兼容原来的调用方式
|
// 完成测试 - 兼容原来的调用方式
|
||||||
const finishTest = async () => {
|
const finishTest = async () => {
|
||||||
// 使用当前的测试结果完成测试
|
// 使用当前的测试结果完成测试
|
||||||
await finishTestWithResults([...testResults]);
|
await finishTestWithResults([...pendingAnswers]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重新开始测试
|
// 重新开始测试
|
||||||
const restartTest = () => {
|
const restartTest = () => {
|
||||||
const shuffled = shuffleArray([...testWords]);
|
setTestWords([]);
|
||||||
setTestWords(shuffled);
|
|
||||||
setTestIndex(0);
|
setTestIndex(0);
|
||||||
|
setTestAttemptId('');
|
||||||
setTestStarted(false); // 回到模式选择界面
|
setTestStarted(false); // 回到模式选择界面
|
||||||
setTestFinished(false);
|
setTestFinished(false);
|
||||||
setUserAnswer('');
|
setUserAnswer('');
|
||||||
setShowAnswer(false);
|
setShowAnswer(false);
|
||||||
setTestResults([]);
|
setTestResults([]);
|
||||||
|
setPendingAnswers([]);
|
||||||
|
setOptions([]);
|
||||||
|
answerSubmittingRef.current = false;
|
||||||
// 重置统计信息
|
// 重置统计信息
|
||||||
setStudyStats({
|
setStudyStats({
|
||||||
totalWords: 0,
|
totalWords: 0,
|
||||||
@@ -704,7 +739,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [testIndex, testStarted, testType, testFinished]);
|
}, [testIndex, testStarted, testType, testFinished]);
|
||||||
|
|
||||||
// 切换题目时自动生成选项
|
// 切换题目时同步多选题选项
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
testStarted &&
|
testStarted &&
|
||||||
@@ -712,7 +747,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
testType === 'multiple-choice' &&
|
testType === 'multiple-choice' &&
|
||||||
testWords.length > 0
|
testWords.length > 0
|
||||||
) {
|
) {
|
||||||
generateMultipleChoiceOptions(testIndex);
|
setOptions(testWords[testIndex]?.options || []);
|
||||||
}
|
}
|
||||||
}, [testIndex, testType, testStarted, testFinished, testWords]);
|
}, [testIndex, testType, testStarted, testFinished, testWords]);
|
||||||
|
|
||||||
@@ -729,6 +764,14 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [testStarted, testType, testWords, testFinished, testIndex]);
|
}, [testStarted, testType, testWords, testFinished, testIndex]);
|
||||||
|
|
||||||
|
const handleTabChange = (nextTab: string) => {
|
||||||
|
if (testStarted && !testFinished && activeTab === 'test' && nextTab !== 'test') {
|
||||||
|
message.warning('测试进行中,请完成本次测试后再切换页面');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(nextTab);
|
||||||
|
};
|
||||||
|
|
||||||
// Tab 切换
|
// Tab 切换
|
||||||
const items: TabsProps['items'] = [
|
const items: TabsProps['items'] = [
|
||||||
{
|
{
|
||||||
@@ -976,30 +1019,9 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={startTest}
|
||||||
// 只有这里才初始化测试
|
|
||||||
const shuffled = shuffleArray([...studyWords]);
|
|
||||||
setTestWords(shuffled);
|
|
||||||
setTestIndex(0);
|
|
||||||
setTestStarted(true);
|
|
||||||
setTestFinished(false);
|
|
||||||
setUserAnswer('');
|
|
||||||
setShowAnswer(false);
|
|
||||||
setTestResults([]);
|
|
||||||
// 重置统计信息
|
|
||||||
setStudyStats({
|
|
||||||
totalWords: 0,
|
|
||||||
correctWords: 0,
|
|
||||||
accuracy: 0,
|
|
||||||
startTime: new Date(),
|
|
||||||
duration: 0
|
|
||||||
});
|
|
||||||
// 如果是选择翻译,生成选项
|
|
||||||
if (testType === 'multiple-choice') {
|
|
||||||
generateMultipleChoiceOptions(0, shuffled);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={studyWords.length === 0}
|
disabled={studyWords.length === 0}
|
||||||
|
loading={testAttemptLoading}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
background: studyWords.length === 0 ? '#f5f5f5' : undefined,
|
background: studyWords.length === 0 ? '#f5f5f5' : undefined,
|
||||||
@@ -1169,6 +1191,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
{testType === 'multiple-choice' ? (
|
{testType === 'multiple-choice' ? (
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={userAnswer}
|
value={userAnswer}
|
||||||
|
disabled={showAnswer}
|
||||||
onChange={e => setUserAnswer(e.target.value)}
|
onChange={e => setUserAnswer(e.target.value)}
|
||||||
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
|
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
|
||||||
>
|
>
|
||||||
@@ -1199,23 +1222,19 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
{showAnswer && (
|
{showAnswer && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: 15,
|
padding: 15,
|
||||||
backgroundColor: testResults[testResults.length - 1]?.isCorrect ? '#f6ffed' : '#fff2f0',
|
backgroundColor: '#f6f8fa',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
marginBottom: 15,
|
marginBottom: 15,
|
||||||
border: `1px solid ${testResults[testResults.length - 1]?.isCorrect ? '#b7eb8f' : '#ffccc7'}`
|
border: '1px solid #d9d9d9'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: testResults[testResults.length - 1]?.isCorrect ? '#52c41a' : '#f5222d',
|
color: '#1677ff',
|
||||||
marginBottom: 5
|
marginBottom: 5
|
||||||
}}>
|
}}>
|
||||||
{testResults[testResults.length - 1]?.isCorrect ? '✓ 回答正确' : '✗ 回答错误'}
|
本题已提交
|
||||||
</div>
|
</div>
|
||||||
<div>正确答案: {testType === 'multiple-choice'
|
<div>正在进入下一题...</div>
|
||||||
? testWords[testIndex].translation
|
|
||||||
: testWords[testIndex].word}
|
|
||||||
</div>
|
|
||||||
<div>你的答案: {userAnswer}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1516,7 +1535,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs activeKey={activeTab} items={items} onChange={setActiveTab} />
|
<Tabs activeKey={activeTab} items={items} onChange={handleTabChange} />
|
||||||
|
|
||||||
{/* 添加声音设置弹窗 */}
|
{/* 添加声音设置弹窗 */}
|
||||||
{renderVoiceSettings()}
|
{renderVoiceSettings()}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const API_PATHS = {
|
|||||||
WORD_SETS: '/vocabulary/word-sets',
|
WORD_SETS: '/vocabulary/word-sets',
|
||||||
STUDY_WORDS: '/vocabulary/study-words',
|
STUDY_WORDS: '/vocabulary/study-words',
|
||||||
UPLOAD: '/vocabulary/upload',
|
UPLOAD: '/vocabulary/upload',
|
||||||
|
TEST_ATTEMPT: '/vocabulary/test-attempt',
|
||||||
WORD_RECORD: '/vocabulary/word-record',
|
WORD_RECORD: '/vocabulary/word-record',
|
||||||
TEST_RECORD: '/vocabulary/test-record',
|
TEST_RECORD: '/vocabulary/test-record',
|
||||||
STUDY_RECORDS: '/vocabulary/test-records',
|
STUDY_RECORDS: '/vocabulary/test-records',
|
||||||
|
|||||||
Reference in New Issue
Block a user