fixed cheating michanism
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
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, 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';
|
||||
import type { TabsProps } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd/lib/radio';
|
||||
|
||||
interface Word {
|
||||
_id?: string;
|
||||
@@ -74,13 +73,30 @@ interface PendingAnswer {
|
||||
answerProof: string;
|
||||
}
|
||||
|
||||
interface InteractionEventPayload {
|
||||
type: string;
|
||||
ts: number;
|
||||
key?: string;
|
||||
inputType?: string;
|
||||
valueLength?: number;
|
||||
}
|
||||
|
||||
interface NativeInputLikeEvent extends Event {
|
||||
inputType?: string;
|
||||
}
|
||||
|
||||
interface TestOption {
|
||||
text: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface TestQuestion {
|
||||
questionToken: string;
|
||||
wordId: string;
|
||||
word?: string;
|
||||
translation?: string;
|
||||
pronunciation?: string;
|
||||
options?: string[];
|
||||
options?: Array<string | TestOption>;
|
||||
}
|
||||
|
||||
interface SavedVocabularyTestRecordResponse {
|
||||
@@ -94,6 +110,8 @@ interface SavedVocabularyTestRecordResponse {
|
||||
duration: number;
|
||||
};
|
||||
results: TestResult[];
|
||||
riskFlags?: string[];
|
||||
invalidated?: boolean;
|
||||
}
|
||||
|
||||
interface TestAttemptResponse {
|
||||
@@ -103,6 +121,22 @@ interface TestAttemptResponse {
|
||||
questions: TestQuestion[];
|
||||
}
|
||||
|
||||
interface SubmitAnswerResponse {
|
||||
message: string;
|
||||
result: TestResult;
|
||||
progress: {
|
||||
answered: number;
|
||||
total: number;
|
||||
finished: boolean;
|
||||
};
|
||||
riskFlags?: string[];
|
||||
}
|
||||
|
||||
interface OptionTokenResponse {
|
||||
questionToken: string;
|
||||
optionToken: string;
|
||||
}
|
||||
|
||||
const ANSWER_PROOF_SALT = 'd1ktsalt';
|
||||
|
||||
const VocabularyStudy: React.FC = () => {
|
||||
@@ -130,7 +164,8 @@ const VocabularyStudy: React.FC = () => {
|
||||
const [testAttemptId, setTestAttemptId] = useState<string>('');
|
||||
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
|
||||
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [options, setOptions] = useState<TestOption[]>([]);
|
||||
const [testRiskFlags, setTestRiskFlags] = useState<string[]>([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const timeOffsetRef = useRef<number>(0);
|
||||
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
|
||||
@@ -145,6 +180,8 @@ const VocabularyStudy: React.FC = () => {
|
||||
// 使用ref存储当前选中ID,这样可以立即访问
|
||||
const currentWordSetIdRef = useRef<string>('');
|
||||
const answerSubmittingRef = useRef(false);
|
||||
const interactionsRef = useRef<InteractionEventPayload[]>([]);
|
||||
const questionShownAtRef = useRef<number>(0);
|
||||
|
||||
// 测试输入框ref
|
||||
const inputRef = useRef<any>(null);
|
||||
@@ -457,7 +494,9 @@ const VocabularyStudy: React.FC = () => {
|
||||
setShowAnswer(false);
|
||||
setTestResults([]);
|
||||
setPendingAnswers([]);
|
||||
setOptions(questions[0]?.options || []);
|
||||
setOptions(normalizeOptions(questions[0]));
|
||||
setTestRiskFlags([]);
|
||||
resetInteractionTrace();
|
||||
setIsModalVisible(false);
|
||||
setStudyStats({
|
||||
totalWords: 0,
|
||||
@@ -489,6 +528,72 @@ const VocabularyStudy: React.FC = () => {
|
||||
|
||||
const getWordId = (word: Word): string => word._id || word.id;
|
||||
|
||||
const normalizeOptions = (question?: TestQuestion): TestOption[] => {
|
||||
if (!question?.options) return [];
|
||||
return question.options.map(option => {
|
||||
if (typeof option === 'string') {
|
||||
return { text: option, token: '' };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
};
|
||||
|
||||
const resetInteractionTrace = () => {
|
||||
const now = Date.now();
|
||||
interactionsRef.current = [{
|
||||
type: 'question-shown',
|
||||
ts: now
|
||||
}];
|
||||
questionShownAtRef.current = now;
|
||||
};
|
||||
|
||||
const recordInteraction = (event: InteractionEventPayload) => {
|
||||
if (!testStarted || testFinished || showAnswer) return;
|
||||
interactionsRef.current = [
|
||||
...interactionsRef.current,
|
||||
{
|
||||
...event,
|
||||
ts: Date.now()
|
||||
}
|
||||
].slice(-300);
|
||||
};
|
||||
|
||||
const handleMultipleChoiceChange = async (option: TestOption) => {
|
||||
const currentQuestion = testWords[testIndex];
|
||||
if (!currentQuestion || !testAttemptId || showAnswer) return;
|
||||
|
||||
recordInteraction({
|
||||
type: 'change',
|
||||
valueLength: option.text.length
|
||||
});
|
||||
|
||||
if (option.token) {
|
||||
setUserAnswer(option.token);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post<OptionTokenResponse>(API_PATHS.VOCABULARY.TEST_OPTION_TOKEN, {
|
||||
attemptId: testAttemptId,
|
||||
questionToken: currentQuestion.questionToken,
|
||||
optionText: option.text
|
||||
});
|
||||
|
||||
setOptions(prev => prev.map(item =>
|
||||
item.text === option.text
|
||||
? { ...item, token: response.optionToken }
|
||||
: item
|
||||
));
|
||||
setUserAnswer(response.optionToken);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
message.error(`获取选项凭证失败: ${error.message}`);
|
||||
} else {
|
||||
message.error('获取选项凭证失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildAnswerProof = (
|
||||
attemptId: string,
|
||||
questionToken: string,
|
||||
@@ -508,6 +613,11 @@ const VocabularyStudy: React.FC = () => {
|
||||
answerSubmittingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (testType === 'multiple-choice' && options.length > 0 && options.every(option => option.token !== userAnswer)) {
|
||||
answerSubmittingRef.current = false;
|
||||
message.warning('请选择一个选项后再提交');
|
||||
return;
|
||||
}
|
||||
const submittedAt = Date.now();
|
||||
const normalizedUserAnswer = userAnswer.trim();
|
||||
const answerProof = buildAnswerProof(
|
||||
@@ -517,39 +627,64 @@ const VocabularyStudy: React.FC = () => {
|
||||
submittedAt
|
||||
);
|
||||
|
||||
const updatedPendingAnswers = [
|
||||
...pendingAnswers,
|
||||
{
|
||||
try {
|
||||
const submittedAnswer = {
|
||||
questionToken: currentQuestion.questionToken,
|
||||
userAnswer: normalizedUserAnswer,
|
||||
submittedAt,
|
||||
answerProof
|
||||
}
|
||||
];
|
||||
answerProof,
|
||||
interactions: interactionsRef.current
|
||||
};
|
||||
|
||||
setPendingAnswers(updatedPendingAnswers);
|
||||
setShowAnswer(true);
|
||||
setUserAnswer('');
|
||||
const response = await api.post<SubmitAnswerResponse>(API_PATHS.VOCABULARY.TEST_ANSWER, {
|
||||
attemptId: testAttemptId,
|
||||
...submittedAnswer
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (testIndex < testWords.length - 1) {
|
||||
const nextIndex = testIndex + 1;
|
||||
setTestIndex(nextIndex);
|
||||
setShowAnswer(false);
|
||||
answerSubmittingRef.current = false;
|
||||
if (testType === 'audio-to-english') {
|
||||
setTimeout(() => {
|
||||
playWordSound(testWords[nextIndex]?.word || '');
|
||||
}, 500);
|
||||
const updatedPendingAnswers = [
|
||||
...pendingAnswers,
|
||||
{
|
||||
questionToken: currentQuestion.questionToken,
|
||||
userAnswer: normalizedUserAnswer,
|
||||
submittedAt,
|
||||
answerProof
|
||||
}
|
||||
if (testType === 'multiple-choice') {
|
||||
setOptions(testWords[nextIndex]?.options || []);
|
||||
];
|
||||
|
||||
setPendingAnswers(updatedPendingAnswers);
|
||||
setTestResults(prev => [...prev, response.result]);
|
||||
setTestRiskFlags(prev => Array.from(new Set([...prev, ...(response.riskFlags || [])])));
|
||||
setShowAnswer(true);
|
||||
setUserAnswer('');
|
||||
|
||||
setTimeout(() => {
|
||||
if (testIndex < testWords.length - 1) {
|
||||
const nextIndex = testIndex + 1;
|
||||
setTestIndex(nextIndex);
|
||||
setShowAnswer(false);
|
||||
resetInteractionTrace();
|
||||
answerSubmittingRef.current = false;
|
||||
if (testType === 'audio-to-english') {
|
||||
setTimeout(() => {
|
||||
playWordSound(testWords[nextIndex]?.word || '');
|
||||
}, 500);
|
||||
}
|
||||
if (testType === 'multiple-choice') {
|
||||
setOptions(normalizeOptions(testWords[nextIndex]));
|
||||
}
|
||||
} else {
|
||||
answerSubmittingRef.current = false;
|
||||
finishTestWithResults(updatedPendingAnswers);
|
||||
}
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
answerSubmittingRef.current = false;
|
||||
if (error instanceof ApiError) {
|
||||
message.error(`提交答案失败: ${error.message}`);
|
||||
} else {
|
||||
answerSubmittingRef.current = false;
|
||||
finishTestWithResults(updatedPendingAnswers);
|
||||
message.error('提交答案失败');
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用指定结果完成测试
|
||||
@@ -563,15 +698,9 @@ const VocabularyStudy: React.FC = () => {
|
||||
currentStudyStats: studyStats
|
||||
});
|
||||
|
||||
// 提交测试记录,后端会用数据库答案重新判分
|
||||
// 提交测试记录,后端会用服务端累计的逐题答案重新判分
|
||||
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
|
||||
}))
|
||||
attemptId: testAttemptId
|
||||
});
|
||||
|
||||
setStudyStats({
|
||||
@@ -583,12 +712,17 @@ const VocabularyStudy: React.FC = () => {
|
||||
duration: savedRecord.stats.duration
|
||||
});
|
||||
setTestResults(savedRecord.results);
|
||||
setTestRiskFlags(savedRecord.riskFlags || []);
|
||||
setPendingAnswers([]);
|
||||
setTestAttemptId('');
|
||||
setShowAnswer(false);
|
||||
setTestFinished(true);
|
||||
|
||||
message.success('测试完成,记录已保存');
|
||||
if (savedRecord.invalidated) {
|
||||
message.warning('测试行为异常,本次不计入掌握');
|
||||
} else {
|
||||
message.success('测试完成,记录已保存');
|
||||
}
|
||||
setIsModalVisible(true);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
@@ -617,6 +751,9 @@ const VocabularyStudy: React.FC = () => {
|
||||
setTestResults([]);
|
||||
setPendingAnswers([]);
|
||||
setOptions([]);
|
||||
setTestRiskFlags([]);
|
||||
interactionsRef.current = [];
|
||||
questionShownAtRef.current = 0;
|
||||
answerSubmittingRef.current = false;
|
||||
// 重置统计信息
|
||||
setStudyStats({
|
||||
@@ -740,10 +877,16 @@ const VocabularyStudy: React.FC = () => {
|
||||
testType === 'multiple-choice' &&
|
||||
testWords.length > 0
|
||||
) {
|
||||
setOptions(testWords[testIndex]?.options || []);
|
||||
setOptions(normalizeOptions(testWords[testIndex]));
|
||||
}
|
||||
}, [testIndex, testType, testStarted, testFinished, testWords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (testStarted && !testFinished && testWords.length > 0) {
|
||||
resetInteractionTrace();
|
||||
}
|
||||
}, [testIndex, testStarted, testFinished, testWords.length]);
|
||||
|
||||
// 自动播放第一个听力单词
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -1124,6 +1267,19 @@ const VocabularyStudy: React.FC = () => {
|
||||
/>
|
||||
|
||||
<div style={{ margin: '20px 0' }}>
|
||||
{testRiskFlags.length > 0 && (
|
||||
<div style={{
|
||||
marginBottom: 12,
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #faad14',
|
||||
borderRadius: 4,
|
||||
color: '#ad6800',
|
||||
background: '#fffbe6'
|
||||
}}>
|
||||
本次测试存在异常交互信号,请按正常节奏作答
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testType === 'chinese-to-english' && (
|
||||
<div style={{ fontSize: 24, marginBottom: 15, textAlign: 'center' }}>
|
||||
<div style={{ color: '#666', fontSize: '0.8em', marginBottom: 5 }}>请输入对应的英文单词</div>
|
||||
@@ -1185,12 +1341,19 @@ const VocabularyStudy: React.FC = () => {
|
||||
<Radio.Group
|
||||
value={userAnswer}
|
||||
disabled={showAnswer}
|
||||
onChange={e => setUserAnswer(e.target.value)}
|
||||
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{options.map(option => (
|
||||
<Radio key={option} value={option} style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}>
|
||||
{option}
|
||||
<Radio
|
||||
key={option.text}
|
||||
value={option.token || option.text}
|
||||
onClick={() => {
|
||||
recordInteraction({ type: 'click', valueLength: option.text.length });
|
||||
handleMultipleChoiceChange(option);
|
||||
}}
|
||||
style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}
|
||||
>
|
||||
{option.text}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
@@ -1199,7 +1362,23 @@ const VocabularyStudy: React.FC = () => {
|
||||
ref={inputRef}
|
||||
placeholder="请输入英文单词"
|
||||
value={userAnswer}
|
||||
onChange={e => setUserAnswer(e.target.value)}
|
||||
onChange={e => {
|
||||
recordInteraction({
|
||||
type: 'input',
|
||||
inputType: (e.nativeEvent as NativeInputLikeEvent)?.inputType,
|
||||
valueLength: e.target.value.length
|
||||
});
|
||||
setUserAnswer(e.target.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
recordInteraction({
|
||||
type: 'keydown',
|
||||
key: e.key,
|
||||
valueLength: userAnswer.length
|
||||
});
|
||||
}}
|
||||
onFocus={() => recordInteraction({ type: 'focus', valueLength: userAnswer.length })}
|
||||
onBlur={() => recordInteraction({ type: 'blur', valueLength: userAnswer.length })}
|
||||
disabled={showAnswer}
|
||||
style={{ marginBottom: 15 }}
|
||||
onPressEnter={e => {
|
||||
@@ -1208,7 +1387,10 @@ const VocabularyStudy: React.FC = () => {
|
||||
}}
|
||||
autoFocus
|
||||
size="large"
|
||||
onPaste={e => e.preventDefault()}
|
||||
onPaste={e => {
|
||||
recordInteraction({ type: 'paste', valueLength: userAnswer.length });
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export const API_PATHS = {
|
||||
STUDY_WORDS: '/vocabulary/study-words',
|
||||
UPLOAD: '/vocabulary/upload',
|
||||
TEST_ATTEMPT: '/vocabulary/test-attempt',
|
||||
TEST_ANSWER: '/vocabulary/test-answer',
|
||||
TEST_OPTION_TOKEN: '/vocabulary/test-option-token',
|
||||
WORD_RECORD: '/vocabulary/word-record',
|
||||
TEST_RECORD: '/vocabulary/test-record',
|
||||
STUDY_RECORDS: '/vocabulary/test-records',
|
||||
|
||||
Reference in New Issue
Block a user