fixed cheating michanism

This commit is contained in:
2026-05-24 20:11:00 +08:00
parent 0de72a629c
commit 6cac2f0c9a
4 changed files with 694 additions and 142 deletions

View File

@@ -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();
}}
/>
)}

View File

@@ -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',