fixed fake post attack

This commit is contained in:
2026-05-21 16:44:53 +08:00
parent dec29186e0
commit 7f44194416
4 changed files with 805 additions and 333 deletions

View File

@@ -1,6 +1,7 @@
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 { SoundOutlined, CaretLeftOutlined, CaretRightOutlined, TrophyOutlined, HistoryOutlined, ReloadOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons';
import CryptoJS from 'crypto-js';
import { useNavigate } from 'react-router-dom';
import { api, ApiError, authEvents } from '../api/apiClient';
import { API_PATHS } from '../config';
@@ -8,6 +9,7 @@ import type { TabsProps } from 'antd';
import type { RadioChangeEvent } from 'antd/lib/radio';
interface Word {
_id?: string;
id: string;
word: string;
translation: string;
@@ -56,11 +58,57 @@ interface LeaderboardItem {
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 navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [wordSets, setWordSets] = useState<WordSet[]>([]);
const [selectedWordSet, setSelectedWordSet] = useState<string>('');
const [loadedWordSetId, setLoadedWordSetId] = useState<string>('');
const [currentWords, setCurrentWords] = useState<Word[]>([]);
const [studyStats, setStudyStats] = useState<StudyStats>({
totalWords: 0,
@@ -76,12 +124,10 @@ const VocabularyStudy: React.FC = () => {
const [showAnswer, setShowAnswer] = useState(false);
const [testStarted, setTestStarted] = useState(false);
const [testFinished, setTestFinished] = useState(false);
const [testResults, setTestResults] = useState<{
word: string;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
}[]>([]);
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [testAttemptId, setTestAttemptId] = useState<string>('');
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
const [options, setOptions] = useState<string[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const timeOffsetRef = useRef<number>(0);
@@ -96,6 +142,7 @@ const VocabularyStudy: React.FC = () => {
// 使用ref存储当前选中ID这样可以立即访问
const currentWordSetIdRef = useRef<string>('');
const answerSubmittingRef = useRef(false);
// 测试输入框ref
const inputRef = useRef<any>(null);
@@ -103,7 +150,7 @@ const VocabularyStudy: React.FC = () => {
// 新增 state
const [studyWords, setStudyWords] = useState<Word[]>([]);
const [studyIndex, setStudyIndex] = useState(0);
const [testWords, setTestWords] = useState<Word[]>([]);
const [testWords, setTestWords] = useState<TestQuestion[]>([]);
const [testIndex, setTestIndex] = useState(0);
// 添加声音相关的状态
@@ -192,9 +239,14 @@ const VocabularyStudy: React.FC = () => {
console.log('获取到的学习单词:', response);
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);
setStudyIndex(0);
setLoadedWordSetId(effectiveId);
setActiveTab('study');
message.success(`成功加载 ${shuffledWords.length} 个单词`);
} else {
@@ -365,54 +417,61 @@ const VocabularyStudy: React.FC = () => {
}
};
// 开始测试
const startTest = () => {
// 开始测试会话
const startTest = async () => {
if (studyWords.length === 0) {
message.error('请先加载单词');
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');
};
// 生成多选题选项
const generateMultipleChoiceOptions = (index: number, wordsArr?: Word[]) => {
const arr = wordsArr || testWords;
const correctTranslation = arr[index].translation;
let availableOptions = arr
.filter(w => w.translation !== correctTranslation)
.map(w => w.translation);
// 如果可用选项太少,添加一些假选项
if (availableOptions.length < 3) {
const fakeOptions = [
'假选项1', '假选项2', '假选项3', '假选项4', '假选项5'
].filter(opt => opt !== correctTranslation);
availableOptions = [...availableOptions, ...fakeOptions];
try {
setTestAttemptLoading(true);
setTestAttemptId('');
setPendingAnswers([]);
setTestResults([]);
const wordIds = studyWords.map(getWordId).filter(Boolean);
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 => ({
...question,
options: question.options || []
}));
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;
};
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 {
return str
.replace(//g, '(')
@@ -438,130 +508,92 @@ const VocabularyStudy: React.FC = () => {
// 提交答案
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];
let isCorrect = false;
let correctAnswer = '';
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 currentQuestion = testWords[testIndex];
if (!currentQuestion) {
answerSubmittingRef.current = false;
return;
}
const submittedAt = Date.now();
const normalizedUserAnswer = userAnswer.trim();
const answerProof = buildAnswerProof(
testAttemptId,
currentQuestion.questionToken,
normalizedUserAnswer,
submittedAt
);
// 更新测试结果数组
const updatedResults = [
...testResults,
const updatedPendingAnswers = [
...pendingAnswers,
{
word: currentWord.word,
userAnswer,
correctAnswer,
isCorrect
questionToken: currentQuestion.questionToken,
userAnswer: normalizedUserAnswer,
submittedAt,
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);
setUserAnswer('');
setTimeout(() => {
if (testIndex < testWords.length - 1) {
setTestIndex(testIndex + 1);
setUserAnswer('');
setShowAnswer(false);
// 多选题生成选项
if (testType === 'multiple-choice') {
generateMultipleChoiceOptions(testIndex + 1);
}
// 听力自动播放
answerSubmittingRef.current = false;
if (testType === 'audio-to-english') {
setTimeout(() => {
playWordSound(testWords[testIndex + 1].word);
playWordSound(testWords[testIndex + 1]?.word || '');
}, 500);
}
if (testType === 'multiple-choice') {
setOptions(testWords[testIndex + 1]?.options || []);
}
} else {
// 最后一题,使用本地更新的结果,避免异步状态更新问题
finishTestWithResults(updatedResults);
answerSubmittingRef.current = false;
finishTestWithResults(updatedPendingAnswers);
}
}, 1500);
};
// 使用指定结果完成测试
const finishTestWithResults = async (finalResults: any[]) => {
const finishTestWithResults = async (finalAnswers: PendingAnswer[]) => {
try {
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('测试完成状态(带结果):', {
finalResultsLength: finalResults.length,
finalResultsLength: finalAnswers.length,
testWordsLength: testWords.length,
correctCount,
totalCount,
attemptId: testAttemptId,
currentStudyStats: studyStats
});
// 直接使用传入的测试结果计算
const updatedStats = {
totalWords: totalCount,
correctWords: correctCount,
accuracy: totalCount > 0 ? (correctCount / totalCount) * 100 : 0,
startTime: studyStats.startTime,
endTime: new Date(serverTime),
duration: (serverTime - studyStats.startTime.getTime()) / 1000
};
// 更新统计信息
setStudyStats(updatedStats);
// 提交测试记录 - 使用计算好的最新数据
await api.post(API_PATHS.VOCABULARY.TEST_RECORD, {
wordSetId: selectedWordSet,
testType,
stats: updatedStats,
results: finalResults // 发送详细的测试结果,包括每道题的回答情况
// 提交测试记录,后端会用数据库答案重新判分
const savedRecord = await api.post<SavedVocabularyTestRecordResponse>(API_PATHS.VOCABULARY.TEST_RECORD, {
attemptId: testAttemptId,
answers: finalAnswers.map(answer => ({
questionToken: answer.questionToken,
userAnswer: answer.userAnswer,
submittedAt: answer.submittedAt,
answerProof: answer.answerProof
}))
});
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('测试完成,记录已保存');
setIsModalVisible(true);
@@ -577,19 +609,22 @@ const VocabularyStudy: React.FC = () => {
// 完成测试 - 兼容原来的调用方式
const finishTest = async () => {
// 使用当前的测试结果完成测试
await finishTestWithResults([...testResults]);
await finishTestWithResults([...pendingAnswers]);
};
// 重新开始测试
const restartTest = () => {
const shuffled = shuffleArray([...testWords]);
setTestWords(shuffled);
setTestWords([]);
setTestIndex(0);
setTestAttemptId('');
setTestStarted(false); // 回到模式选择界面
setTestFinished(false);
setUserAnswer('');
setShowAnswer(false);
setTestResults([]);
setPendingAnswers([]);
setOptions([]);
answerSubmittingRef.current = false;
// 重置统计信息
setStudyStats({
totalWords: 0,
@@ -704,7 +739,7 @@ const VocabularyStudy: React.FC = () => {
}
}, [testIndex, testStarted, testType, testFinished]);
// 切换题目时自动生成选项
// 切换题目时同步多选题选项
useEffect(() => {
if (
testStarted &&
@@ -712,7 +747,7 @@ const VocabularyStudy: React.FC = () => {
testType === 'multiple-choice' &&
testWords.length > 0
) {
generateMultipleChoiceOptions(testIndex);
setOptions(testWords[testIndex]?.options || []);
}
}, [testIndex, testType, testStarted, testFinished, testWords]);
@@ -729,6 +764,14 @@ const VocabularyStudy: React.FC = () => {
}
}, [testStarted, testType, testWords, testFinished, testIndex]);
const handleTabChange = (nextTab: string) => {
if (testStarted && !testFinished && activeTab === 'test' && nextTab !== 'test') {
message.warning('测试进行中,请完成本次测试后再切换页面');
return;
}
setActiveTab(nextTab);
};
// Tab 切换
const items: TabsProps['items'] = [
{
@@ -976,30 +1019,9 @@ const VocabularyStudy: React.FC = () => {
</div>
<Button
type="primary"
onClick={() => {
// 只有这里才初始化测试
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);
}
}}
onClick={startTest}
disabled={studyWords.length === 0}
loading={testAttemptLoading}
style={{
width: '100%',
background: studyWords.length === 0 ? '#f5f5f5' : undefined,
@@ -1169,6 +1191,7 @@ const VocabularyStudy: React.FC = () => {
{testType === 'multiple-choice' ? (
<Radio.Group
value={userAnswer}
disabled={showAnswer}
onChange={e => setUserAnswer(e.target.value)}
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
>
@@ -1199,23 +1222,19 @@ const VocabularyStudy: React.FC = () => {
{showAnswer && (
<div style={{
padding: 15,
backgroundColor: testResults[testResults.length - 1]?.isCorrect ? '#f6ffed' : '#fff2f0',
backgroundColor: '#f6f8fa',
borderRadius: 4,
marginBottom: 15,
border: `1px solid ${testResults[testResults.length - 1]?.isCorrect ? '#b7eb8f' : '#ffccc7'}`
border: '1px solid #d9d9d9'
}}>
<div style={{
fontWeight: 'bold',
color: testResults[testResults.length - 1]?.isCorrect ? '#52c41a' : '#f5222d',
color: '#1677ff',
marginBottom: 5
}}>
{testResults[testResults.length - 1]?.isCorrect ? '✓ 回答正确' : '✗ 回答错误'}
</div>
<div>: {testType === 'multiple-choice'
? testWords[testIndex].translation
: testWords[testIndex].word}
</div>
<div>: {userAnswer}</div>
<div>...</div>
</div>
)}
@@ -1516,7 +1535,7 @@ const VocabularyStudy: React.FC = () => {
</Button>
</div>
<Tabs activeKey={activeTab} items={items} onChange={setActiveTab} />
<Tabs activeKey={activeTab} items={items} onChange={handleTabChange} />
{/* 添加声音设置弹窗 */}
{renderVoiceSettings()}
@@ -1570,4 +1589,4 @@ const VocabularyStudy: React.FC = () => {
);
};
export default VocabularyStudy;
export default VocabularyStudy;

View File

@@ -48,6 +48,7 @@ export const API_PATHS = {
WORD_SETS: '/vocabulary/word-sets',
STUDY_WORDS: '/vocabulary/study-words',
UPLOAD: '/vocabulary/upload',
TEST_ATTEMPT: '/vocabulary/test-attempt',
WORD_RECORD: '/vocabulary/word-record',
TEST_RECORD: '/vocabulary/test-record',
STUDY_RECORDS: '/vocabulary/test-records',
@@ -117,4 +118,4 @@ export const config = {
} as const;
// 默认导出
export default config;
export default config;