diff --git a/server/routes/vocabulary.ts b/server/routes/vocabulary.ts index 0556980..46a8705 100644 --- a/server/routes/vocabulary.ts +++ b/server/routes/vocabulary.ts @@ -42,7 +42,11 @@ interface InteractionEventPayload { type?: string; key?: string; inputType?: string; + value?: string; valueLength?: number; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; ts?: number; } @@ -70,7 +74,9 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [ ]; const ATTEMPT_TTL_MS = 30 * 60 * 1000; -const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 1); +const MIN_STUDY_WORD_COUNT = 10; +const MAX_STUDY_WORD_COUNT = 100; +const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 2); const MAX_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MAX_SECONDS_PER_QUESTION || 10); const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'https://d1kt.cn,http://localhost:3000,http://localhost:3001') .split(',') @@ -78,6 +84,7 @@ const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'h .filter(Boolean); const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET; const ANSWER_PROOF_SALT = 'd1ktsalt'; +const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。'; const isVocabularyTestType = (value: unknown): value is VocabularyTestType => { return value === 'chinese-to-english' || @@ -281,6 +288,60 @@ const summarizeInteractionEvents = ( }; }; +const buildKeySequenceText = (events: InteractionEventPayload[]): string => { + let text = ''; + + events.forEach(event => { + if (event?.type !== 'keydown' || event.ctrlKey || event.metaKey || event.altKey) return; + + const key = String(event.key ?? ''); + if (key === 'Backspace') { + text = text.slice(0, -1); + return; + } + if (key === 'Spacebar') { + text += ' '; + return; + } + if (key.length === 1) { + text += key; + } + }); + + return text.slice(0, 500); +}; + +const analyzeInputTraceRisk = ( + events: InteractionEventPayload[], + userAnswer: string, + testType: VocabularyTestType +): string[] => { + if (testType === 'multiple-choice') return []; + + const flags: string[] = []; + const expectedAnswer = normalizeEnglishAnswer(userAnswer); + if (!expectedAnswer) return flags; + + const safeEvents = Array.isArray(events) ? events.slice(0, 300) : []; + const inputValues = safeEvents + .filter(event => event?.type === 'input' && typeof event.value === 'string') + .map(event => String(event.value ?? '').slice(0, 500)); + const lastInputValue = inputValues[inputValues.length - 1]; + + if (inputValues.length === 0) { + flags.push('missing_input_value_trace'); + } else if (normalizeEnglishAnswer(lastInputValue) !== expectedAnswer) { + flags.push('input_value_mismatch'); + } + + const keySequence = normalizeEnglishAnswer(buildKeySequenceText(safeEvents)); + if (keySequence && keySequence !== expectedAnswer) { + flags.push('key_sequence_mismatch'); + } + + return flags; +}; + const analyzeAnswerRisk = ( durationSeconds: number, summary: InteractionSummary, @@ -288,12 +349,12 @@ const analyzeAnswerRisk = ( ): string[] => { const flags: string[] = []; - if (durationSeconds < MIN_SECONDS_PER_QUESTION) flags.push('too_fast'); if (durationSeconds > MAX_SECONDS_PER_QUESTION) flags.push('too_slow'); if (testType === 'multiple-choice') { if (summary.pointerCount === 0) flags.push('missing_choice_interaction'); } else { + if (durationSeconds < MIN_SECONDS_PER_QUESTION) flags.push('too_fast'); if (summary.keyCount === 0) flags.push('missing_key_events'); if (summary.inputCount === 0) flags.push('missing_input_events'); if (summary.pasteCount > 0) flags.push('paste_used'); @@ -588,7 +649,7 @@ router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => { if (req.query.count) { const parsed = parseInt(req.query.count as string, 10); if (!isNaN(parsed) && parsed > 0) { - targetCount = Math.min(parsed, 100); + targetCount = Math.max(MIN_STUDY_WORD_COUNT, Math.min(parsed, MAX_STUDY_WORD_COUNT)); } } @@ -738,24 +799,24 @@ router.post('/test-attempt', authMiddleware, async (req, res) => { const userId = req.user?._id; if (!userId) { - return res.status(401).json({ message: '用户信息无效' }); + return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { - return res.status(403).json({ message: '测试来源异常,请从官网页面开始测试' }); + return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(wordSetId)) { - return res.status(400).json({ message: '单词集ID无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isVocabularyTestType(testType)) { - return res.status(400).json({ message: '未知的测试类型' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const wordSet = await WordSet.findById(wordSetId); if (!wordSet) { - return res.status(404).json({ message: '未找到单词集' }); + return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE }); } let selectedWords: any[] = []; @@ -763,23 +824,23 @@ router.post('/test-attempt', authMiddleware, async (req, res) => { if (Array.isArray(wordIds) && wordIds.length > 0) { if (wordIds.length > 100) { - return res.status(400).json({ message: '单次测试最多100道题' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (wordIds.some((id: unknown) => !mongoose.isValidObjectId(id))) { - return res.status(400).json({ message: '包含无效单词ID' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))]; if (uniqueWordIds.length !== wordIds.length) { - return res.status(400).json({ message: '测试单词不能重复' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const wordMap = new Map(allWords.map(word => [getObjectIdString(word), word])); selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId)); if (selectedWords.some(word => !word)) { - return res.status(400).json({ message: '测试单词必须属于当前单词集' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } selectedWords = shuffle(selectedWords); @@ -792,7 +853,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => { } if (selectedWords.length === 0) { - return res.status(400).json({ message: '没有可测试的单词' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const issuedAt = new Date(); @@ -837,7 +898,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => { }); } catch (error) { console.error('创建测试会话失败:', error); - res.status(500).json({ message: '创建测试会话失败' }); + res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE }); } }); @@ -847,7 +908,7 @@ router.post('/word-record', (req, res, next) => { next(); }, authMiddleware, async (req, res) => { return res.status(410).json({ - message: '单题记录接口已停用,请通过测试会话提交记录' + message: INVALID_CREDENTIAL_MESSAGE }); }); @@ -858,15 +919,15 @@ router.post('/test-answer', authMiddleware, async (req, res) => { const userId = req.user?._id; if (!userId) { - return res.status(401).json({ message: '用户信息无效' }); + return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { - return res.status(403).json({ message: '测试来源异常,请从官网页面提交答案' }); + return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(attemptId)) { - return res.status(400).json({ message: '测试会话ID无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attempt = await VocabularyTestAttempt.findOne({ @@ -875,7 +936,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => { }); if (!attempt) { - return res.status(404).json({ message: '未找到测试会话' }); + return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attemptAny = attempt as any; @@ -886,42 +947,42 @@ router.post('/test-answer', authMiddleware, async (req, res) => { if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) { attemptAny.status = 'expired'; await attempt.save(); - return res.status(410).json({ message: '测试会话已过期,请重新开始测试' }); + return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (attemptAny.status !== 'active') { - return res.status(409).json({ message: '测试会话已提交或已失效' }); + return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const token = String(questionToken ?? ''); const tokenIndex = attemptAny.questionTokens.indexOf(token); if (!token || tokenIndex < 0) { - return res.status(400).json({ message: '测试答案的题目标识无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens) ? attemptAny.answeredQuestionTokens : []; if (answeredTokens.includes(token)) { - return res.status(409).json({ message: '该题已提交,请继续下一题' }); + return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (tokenIndex !== answeredTokens.length) { - return res.status(400).json({ message: '请按题目顺序提交答案' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const submittedAtMs = Number.parseInt(String(submittedAt), 10); if (!Number.isFinite(submittedAtMs)) { - return res.status(400).json({ message: '答案提交时间无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) { - return res.status(400).json({ message: '答案提交时间异常' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]); if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) { - return res.status(400).json({ message: '测试题目签名无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500); @@ -930,14 +991,14 @@ router.post('/test-answer', authMiddleware, async (req, res) => { const optionTexts = attemptAny.optionTexts?.[tokenIndex] || []; const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer)); if (selectedOptionIndex < 0) { - return res.status(400).json({ message: '选择项凭证无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } normalizedUserAnswer = optionTexts[selectedOptionIndex] || ''; } const expectedProof = buildAnswerProof(attemptId.toString(), token, String(userAnswer ?? '').trim().slice(0, 500), submittedAtMs); if (!safeEqualString(expectedProof, String(answerProof ?? ''))) { - return res.status(400).json({ message: '答案校验失败' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const questionShownAtMs = tokenIndex === 0 @@ -945,14 +1006,17 @@ router.post('/test-answer', authMiddleware, async (req, res) => { : new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || issuedAt).getTime(); const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000); const interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs); - const riskFlags = analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType); + const riskFlags = Array.from(new Set([ + ...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType), + ...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType) + ])); const word = await Word.findOne({ _id: wordId, wordSet: attemptAny.wordSet }); if (!word) { - return res.status(400).json({ message: '测试会话中的单词已不存在' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer); @@ -989,12 +1053,11 @@ router.post('/test-answer', authMiddleware, async (req, res) => { answered: attemptAny.answeredQuestionTokens.length, total: attemptAny.questionTokens.length, finished: attemptAny.answeredQuestionTokens.length === attemptAny.questionTokens.length - }, - riskFlags + } }); } catch (error) { console.error('提交单题答案失败:', error); - res.status(500).json({ message: '提交单题答案失败' }); + res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE }); } }); @@ -1005,15 +1068,15 @@ router.post('/test-option-token', authMiddleware, async (req, res) => { const userId = req.user?._id; if (!userId) { - return res.status(401).json({ message: '用户信息无效' }); + return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { - return res.status(403).json({ message: '测试来源异常,请从官网页面选择答案' }); + return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(attemptId)) { - return res.status(400).json({ message: '测试会话ID无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attempt = await VocabularyTestAttempt.findOne({ @@ -1022,40 +1085,40 @@ router.post('/test-option-token', authMiddleware, async (req, res) => { }); if (!attempt) { - return res.status(404).json({ message: '未找到测试会话' }); + return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attemptAny = attempt as any; if (attemptAny.status !== 'active') { - return res.status(409).json({ message: '测试会话已提交或已失效' }); + return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (attemptAny.testType !== 'multiple-choice') { - return res.status(400).json({ message: '当前题型不需要选项凭证' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const token = String(questionToken ?? ''); const tokenIndex = attemptAny.questionTokens.indexOf(token); if (!token || tokenIndex < 0) { - return res.status(400).json({ message: '测试题目标识无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const optionTexts = attemptAny.optionTexts?.[tokenIndex] || []; const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? '')); if (selectedIndex < 0) { - return res.status(400).json({ message: '选项无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens) ? attemptAny.answeredQuestionTokens : []; if (tokenIndex !== answeredTokens.length) { - return res.status(400).json({ message: '请按题目顺序选择答案' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex]; if (!optionToken) { - return res.status(400).json({ message: '选项凭证不存在,请重新开始测试' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } res.json({ @@ -1064,7 +1127,7 @@ router.post('/test-option-token', authMiddleware, async (req, res) => { }); } catch (error) { console.error('获取选择项凭证失败:', error); - res.status(500).json({ message: '获取选择项凭证失败' }); + res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE }); } }); @@ -1075,15 +1138,15 @@ router.post('/test-record', authMiddleware, async (req, res) => { const userId = req.user?._id; if (!userId) { - return res.status(401).json({ message: '用户信息无效' }); + return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { - return res.status(403).json({ message: '测试来源异常,请从官网页面完成测试' }); + return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(attemptId)) { - return res.status(400).json({ message: '测试会话ID无效' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attempt = await VocabularyTestAttempt.findOne({ @@ -1092,7 +1155,7 @@ router.post('/test-record', authMiddleware, async (req, res) => { }); if (!attempt) { - return res.status(404).json({ message: '未找到测试会话' }); + return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attemptAny = attempt as any; @@ -1103,29 +1166,32 @@ router.post('/test-record', authMiddleware, async (req, res) => { if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) { attemptAny.status = 'expired'; await attempt.save(); - return res.status(410).json({ message: '测试会话已过期,请重新开始测试' }); + return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (attemptAny.status !== 'active') { - return res.status(409).json({ message: '测试会话已提交或已失效' }); + return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const submittedAnswers = Array.isArray(attemptAny.answers) ? attemptAny.answers : []; if (submittedAnswers.length !== attemptAny.questionTokens.length) { - return res.status(400).json({ message: '请完成所有题目后再保存测试记录' }); + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const lastSubmittedAtMs = Math.max(...submittedAnswers.map((item: any) => new Date(item.submittedAt).getTime())); - const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000); - const minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION); - if (elapsedSeconds < minimumSeconds) { - return res.status(400).json({ message: '答题速度异常,请重新作答' }); + const isMultipleChoiceAttempt = attemptAny.testType === 'multiple-choice'; + if (!isMultipleChoiceAttempt) { + const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000); + const minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION); + if (elapsedSeconds < minimumSeconds) { + return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); + } } - const batchRiskFlags = analyzeBatchRisk(attemptAny.answerDurations || []); + const batchRiskFlags = isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || []); attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags])); const shouldInvalidateBatch = batchRiskFlags.length > 0 || - ['too_fast', 'too_slow'].some(flag => (attemptAny.riskFlags || []).includes(flag)); + ['too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch'].some(flag => (attemptAny.riskFlags || []).includes(flag)); const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate( { _id: attempt._id, user: userId, status: 'active' }, @@ -1134,7 +1200,7 @@ router.post('/test-record', authMiddleware, async (req, res) => { ); if (!claimedAttempt) { - return res.status(409).json({ message: '测试会话已提交或已失效' }); + return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word)); @@ -1187,7 +1253,7 @@ router.post('/test-record', authMiddleware, async (req, res) => { }); res.status(201).json({ - message: shouldInvalidateBatch ? '测试行为异常,本次记录已保存但不计入掌握' : '测试记录已保存', + message: shouldInvalidateBatch ? INVALID_CREDENTIAL_MESSAGE : '测试记录已保存', attemptId: attempt._id.toString(), stats: verifiedStats, results: submittedAnswers.map((result: any) => ({ @@ -1198,12 +1264,11 @@ router.post('/test-record', authMiddleware, async (req, res) => { isCorrect: shouldInvalidateBatch ? false : result.isCorrect })), submittedAt: new Date(lastSubmittedAtMs), - riskFlags: attemptAny.riskFlags || [], invalidated: shouldInvalidateBatch }); } catch (error) { console.error('保存测试记录失败:', error); - res.status(500).json({ message: '保存测试记录失败' }); + res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE }); } }); diff --git a/src/components/VocabularyStudy.tsx b/src/components/VocabularyStudy.tsx index 7c05ee6..ad8864d 100644 --- a/src/components/VocabularyStudy.tsx +++ b/src/components/VocabularyStudy.tsx @@ -78,7 +78,11 @@ interface InteractionEventPayload { ts: number; key?: string; inputType?: string; + value?: string; valueLength?: number; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; } interface NativeInputLikeEvent extends Event { @@ -110,7 +114,6 @@ interface SavedVocabularyTestRecordResponse { duration: number; }; results: TestResult[]; - riskFlags?: string[]; invalidated?: boolean; } @@ -129,7 +132,6 @@ interface SubmitAnswerResponse { total: number; finished: boolean; }; - riskFlags?: string[]; } interface OptionTokenResponse { @@ -138,6 +140,9 @@ interface OptionTokenResponse { } const ANSWER_PROOF_SALT = 'd1ktsalt'; +const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。'; +const MIN_STUDY_WORD_COUNT = 10; +const MAX_STUDY_WORD_COUNT = 100; const VocabularyStudy: React.FC = () => { const navigate = useNavigate(); @@ -165,7 +170,6 @@ const VocabularyStudy: React.FC = () => { const [pendingAnswers, setPendingAnswers] = useState([]); const [testAttemptLoading, setTestAttemptLoading] = useState(false); const [options, setOptions] = useState([]); - const [testRiskFlags, setTestRiskFlags] = useState([]); const [isModalVisible, setIsModalVisible] = useState(false); const timeOffsetRef = useRef(0); const [timer, setTimer] = useState(null); @@ -332,7 +336,7 @@ const VocabularyStudy: React.FC = () => { // 处理单词数量变更 const handleWordCountChange = (value: number | null) => { if (value !== null) { - setWordCount(value); + setWordCount(Math.max(MIN_STUDY_WORD_COUNT, Math.min(value, MAX_STUDY_WORD_COUNT))); } }; @@ -495,7 +499,6 @@ const VocabularyStudy: React.FC = () => { setTestResults([]); setPendingAnswers([]); setOptions(normalizeOptions(questions[0])); - setTestRiskFlags([]); resetInteractionTrace(); setIsModalVisible(false); setStudyStats({ @@ -506,11 +509,7 @@ const VocabularyStudy: React.FC = () => { duration: 0 }); } catch (error) { - if (error instanceof ApiError) { - message.error(`创建测试会话失败: ${error.message}`); - } else { - message.error('创建测试会话失败'); - } + message.error(INVALID_CREDENTIAL_MESSAGE); } finally { setTestAttemptLoading(false); } @@ -558,6 +557,40 @@ const VocabularyStudy: React.FC = () => { ].slice(-300); }; + const getTestInputElement = (): HTMLInputElement | null => { + return inputRef.current?.input || null; + }; + + const moveTestInputCaretToEnd = () => { + window.setTimeout(() => { + const input = getTestInputElement(); + if (!input) return; + const end = input.value.length; + input.setSelectionRange(end, end); + }, 0); + }; + + const focusTestInputAtEnd = () => { + inputRef.current?.focus?.(); + moveTestInputCaretToEnd(); + }; + + const shouldBlockInputKey = (event: React.KeyboardEvent): boolean => { + const input = event.currentTarget; + const selectionStart = input.selectionStart ?? input.value.length; + const selectionEnd = input.selectionEnd ?? input.value.length; + const hasSelection = selectionStart !== selectionEnd; + const caretAtEnd = selectionStart === input.value.length && selectionEnd === input.value.length; + const navigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; + const shortcutKey = event.key.toLowerCase(); + + if (navigationKeys.includes(event.key) || event.key === 'Delete') return true; + if ((event.ctrlKey || event.metaKey) && ['a', 'x', 'v', 'z', 'y'].includes(shortcutKey)) return true; + if (event.key === 'Backspace') return hasSelection || !caretAtEnd; + + return false; + }; + const handleMultipleChoiceChange = async (option: TestOption) => { const currentQuestion = testWords[testIndex]; if (!currentQuestion || !testAttemptId || showAnswer) return; @@ -586,11 +619,7 @@ const VocabularyStudy: React.FC = () => { )); setUserAnswer(response.optionToken); } catch (error) { - if (error instanceof ApiError) { - message.error(`获取选项凭证失败: ${error.message}`); - } else { - message.error('获取选项凭证失败'); - } + message.error(INVALID_CREDENTIAL_MESSAGE); } }; @@ -653,7 +682,6 @@ const VocabularyStudy: React.FC = () => { setPendingAnswers(updatedPendingAnswers); setTestResults(prev => [...prev, response.result]); - setTestRiskFlags(prev => Array.from(new Set([...prev, ...(response.riskFlags || [])]))); setShowAnswer(true); setUserAnswer(''); @@ -679,11 +707,7 @@ const VocabularyStudy: React.FC = () => { }, 1500); } catch (error) { answerSubmittingRef.current = false; - if (error instanceof ApiError) { - message.error(`提交答案失败: ${error.message}`); - } else { - message.error('提交答案失败'); - } + message.error(INVALID_CREDENTIAL_MESSAGE); } }; @@ -712,24 +736,19 @@ const VocabularyStudy: React.FC = () => { duration: savedRecord.stats.duration }); setTestResults(savedRecord.results); - setTestRiskFlags(savedRecord.riskFlags || []); setPendingAnswers([]); setTestAttemptId(''); setShowAnswer(false); setTestFinished(true); if (savedRecord.invalidated) { - message.warning('测试行为异常,本次不计入掌握'); + message.error(INVALID_CREDENTIAL_MESSAGE); } else { message.success('测试完成,记录已保存'); } setIsModalVisible(true); } catch (error) { - if (error instanceof ApiError) { - message.error(`保存测试记录失败: ${error.message}`); - } else { - message.error('保存测试记录失败'); - } + message.error(INVALID_CREDENTIAL_MESSAGE); } }; @@ -751,7 +770,6 @@ const VocabularyStudy: React.FC = () => { setTestResults([]); setPendingAnswers([]); setOptions([]); - setTestRiskFlags([]); interactionsRef.current = []; questionShownAtRef.current = 0; answerSubmittingRef.current = false; @@ -973,14 +991,14 @@ const VocabularyStudy: React.FC = () => {

学习单词数量:

- (范围: 5-100个单词) + (范围: {MIN_STUDY_WORD_COUNT}-{MAX_STUDY_WORD_COUNT}个单词) @@ -1267,19 +1285,6 @@ const VocabularyStudy: React.FC = () => { />
- {testRiskFlags.length > 0 && ( -
- 本次测试存在异常交互信号,请按正常节奏作答 -
- )} - {testType === 'chinese-to-english' && (
请输入对应的英文单词
@@ -1366,21 +1371,44 @@ const VocabularyStudy: React.FC = () => { recordInteraction({ type: 'input', inputType: (e.nativeEvent as NativeInputLikeEvent)?.inputType, + value: e.target.value, valueLength: e.target.value.length }); setUserAnswer(e.target.value); + moveTestInputCaretToEnd(); }} onKeyDown={e => { recordInteraction({ type: 'keydown', key: e.key, - valueLength: userAnswer.length + value: userAnswer, + valueLength: userAnswer.length, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + altKey: e.altKey }); + if (shouldBlockInputKey(e)) { + e.preventDefault(); + moveTestInputCaretToEnd(); + } + }} + onFocus={() => { + recordInteraction({ type: 'focus', valueLength: userAnswer.length }); + moveTestInputCaretToEnd(); }} - onFocus={() => recordInteraction({ type: 'focus', valueLength: userAnswer.length })} onBlur={() => recordInteraction({ type: 'blur', valueLength: userAnswer.length })} + onMouseDown={e => { + e.preventDefault(); + focusTestInputAtEnd(); + }} + onMouseUp={moveTestInputCaretToEnd} + onSelect={moveTestInputCaretToEnd} + onKeyUp={moveTestInputCaretToEnd} + onContextMenu={e => e.preventDefault()} + onCut={e => e.preventDefault()} + onDrop={e => e.preventDefault()} disabled={showAnswer} - style={{ marginBottom: 15 }} + style={{ marginBottom: 15, userSelect: 'none', WebkitUserSelect: 'none' }} onPressEnter={e => { e.stopPropagation(); submitAnswer(); @@ -1388,7 +1416,7 @@ const VocabularyStudy: React.FC = () => { autoFocus size="large" onPaste={e => { - recordInteraction({ type: 'paste', valueLength: userAnswer.length }); + recordInteraction({ type: 'paste', value: userAnswer, valueLength: userAnswer.length }); e.preventDefault(); }} />