Compare commits
2 Commits
0de72a629c
...
4088fbc9e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4088fbc9e7 | |||
| 6cac2f0c9a |
@@ -65,6 +65,31 @@ export interface IVocabularyTestAttempt extends Document {
|
|||||||
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||||
questionWordIds: mongoose.Types.ObjectId[];
|
questionWordIds: mongoose.Types.ObjectId[];
|
||||||
questionTokens: string[];
|
questionTokens: string[];
|
||||||
|
optionTokens?: string[][];
|
||||||
|
optionTexts?: string[][];
|
||||||
|
answeredQuestionTokens?: string[];
|
||||||
|
answerDurations?: number[];
|
||||||
|
answers?: Array<{
|
||||||
|
questionToken: string;
|
||||||
|
word: mongoose.Types.ObjectId;
|
||||||
|
userAnswer: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
submittedAt: Date;
|
||||||
|
duration: number;
|
||||||
|
riskFlags: string[];
|
||||||
|
interactionSummary?: {
|
||||||
|
keyCount: number;
|
||||||
|
inputCount: number;
|
||||||
|
pasteCount: number;
|
||||||
|
focusCount: number;
|
||||||
|
blurCount: number;
|
||||||
|
pointerCount: number;
|
||||||
|
firstEventOffset: number;
|
||||||
|
lastEventOffset: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
riskFlags?: string[];
|
||||||
issuedAt: Date;
|
issuedAt: Date;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
submittedAt?: Date;
|
submittedAt?: Date;
|
||||||
@@ -163,6 +188,31 @@ const VocabularyTestAttemptSchema = new Schema<IVocabularyTestAttempt>({
|
|||||||
},
|
},
|
||||||
questionWordIds: [{ type: Schema.Types.ObjectId, ref: 'Word', required: true }],
|
questionWordIds: [{ type: Schema.Types.ObjectId, ref: 'Word', required: true }],
|
||||||
questionTokens: [{ type: String, required: true }],
|
questionTokens: [{ type: String, required: true }],
|
||||||
|
optionTokens: [[{ type: String }]],
|
||||||
|
optionTexts: [[{ type: String }]],
|
||||||
|
answeredQuestionTokens: [{ type: String }],
|
||||||
|
answerDurations: [{ type: Number }],
|
||||||
|
answers: [{
|
||||||
|
questionToken: { type: String, required: true },
|
||||||
|
word: { type: Schema.Types.ObjectId, ref: 'Word', required: true },
|
||||||
|
userAnswer: { type: String, default: '' },
|
||||||
|
correctAnswer: { type: String, required: true },
|
||||||
|
isCorrect: { type: Boolean, required: true },
|
||||||
|
submittedAt: { type: Date, required: true },
|
||||||
|
duration: { type: Number, required: true },
|
||||||
|
riskFlags: [{ type: String }],
|
||||||
|
interactionSummary: {
|
||||||
|
keyCount: { type: Number, default: 0 },
|
||||||
|
inputCount: { type: Number, default: 0 },
|
||||||
|
pasteCount: { type: Number, default: 0 },
|
||||||
|
focusCount: { type: Number, default: 0 },
|
||||||
|
blurCount: { type: Number, default: 0 },
|
||||||
|
pointerCount: { type: Number, default: 0 },
|
||||||
|
firstEventOffset: { type: Number, default: 0 },
|
||||||
|
lastEventOffset: { type: Number, default: 0 }
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
riskFlags: [{ type: String }],
|
||||||
issuedAt: { type: Date, required: true },
|
issuedAt: { type: Date, required: true },
|
||||||
expiresAt: { type: Date, required: true },
|
expiresAt: { type: Date, required: true },
|
||||||
submittedAt: Date,
|
submittedAt: Date,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { auth as authMiddleware } from '../middleware/auth';
|
|||||||
import { Word, WordSet, WordRecord, VocabularyTestRecord, VocabularyTestAttempt } 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 { config } from '../config';
|
import { config } from '../config';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -24,13 +23,42 @@ interface EvaluatedVocabularyAnswer {
|
|||||||
isCorrect: boolean;
|
isCorrect: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VocabularyOptionPayload {
|
||||||
|
token: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface VocabularyAttemptQuestion {
|
interface VocabularyAttemptQuestion {
|
||||||
questionToken: string;
|
questionToken: string;
|
||||||
wordId: string;
|
wordId: string;
|
||||||
word?: string;
|
word?: string;
|
||||||
translation?: string;
|
translation?: string;
|
||||||
pronunciation?: string;
|
pronunciation?: string;
|
||||||
options?: string[];
|
options?: Array<string | VocabularyOptionPayload>;
|
||||||
|
optionTokenRequest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractionEventPayload {
|
||||||
|
type?: string;
|
||||||
|
key?: string;
|
||||||
|
inputType?: string;
|
||||||
|
value?: string;
|
||||||
|
valueLength?: number;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
ts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractionSummary {
|
||||||
|
keyCount: number;
|
||||||
|
inputCount: number;
|
||||||
|
pasteCount: number;
|
||||||
|
focusCount: number;
|
||||||
|
blurCount: number;
|
||||||
|
pointerCount: number;
|
||||||
|
firstEventOffset: number;
|
||||||
|
lastEventOffset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEST_TYPE_TO_MODE: Record<VocabularyTestType, WordRecordModeKey> = {
|
const TEST_TYPE_TO_MODE: Record<VocabularyTestType, WordRecordModeKey> = {
|
||||||
@@ -46,9 +74,17 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ATTEMPT_TTL_MS = 30 * 60 * 1000;
|
const ATTEMPT_TTL_MS = 30 * 60 * 1000;
|
||||||
const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 0.4);
|
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(',')
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
|
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
|
||||||
const ANSWER_PROOF_SALT = 'd1ktsalt';
|
const ANSWER_PROOF_SALT = 'd1ktsalt';
|
||||||
|
const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。';
|
||||||
|
|
||||||
const isVocabularyTestType = (value: unknown): value is VocabularyTestType => {
|
const isVocabularyTestType = (value: unknown): value is VocabularyTestType => {
|
||||||
return value === 'chinese-to-english' ||
|
return value === 'chinese-to-english' ||
|
||||||
@@ -67,6 +103,28 @@ const shuffle = <T,>(array: T[]): T[] => {
|
|||||||
|
|
||||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||||
|
|
||||||
|
const getRequestOrigin = (req: express.Request): string => {
|
||||||
|
const origin = req.get('origin');
|
||||||
|
if (origin) return origin;
|
||||||
|
|
||||||
|
const referer = req.get('referer');
|
||||||
|
if (!referer) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(referer).origin;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedVocabularyOrigin = (req: express.Request): boolean => {
|
||||||
|
const origin = getRequestOrigin(req);
|
||||||
|
if (!origin) {
|
||||||
|
return config.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
return VOCABULARY_ALLOWED_ORIGINS.includes(origin);
|
||||||
|
};
|
||||||
|
|
||||||
const signQuestionToken = (
|
const signQuestionToken = (
|
||||||
attemptId: string,
|
attemptId: string,
|
||||||
wordId: string,
|
wordId: string,
|
||||||
@@ -82,6 +140,20 @@ const signQuestionToken = (
|
|||||||
return `${index}.${issuedAtMs}.${signature}`;
|
return `${index}.${issuedAtMs}.${signature}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const signOptionToken = (
|
||||||
|
attemptId: string,
|
||||||
|
questionToken: string,
|
||||||
|
optionText: string,
|
||||||
|
index: number
|
||||||
|
): string => {
|
||||||
|
const payload = `${attemptId}:${questionToken}:${index}:${optionText}`;
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', ATTEMPT_SECRET)
|
||||||
|
.update(payload)
|
||||||
|
.digest('hex');
|
||||||
|
return `${index}.${signature}`;
|
||||||
|
};
|
||||||
|
|
||||||
const safeEqualString = (left: string, right: string): boolean => {
|
const safeEqualString = (left: string, right: string): boolean => {
|
||||||
const leftBuffer = Buffer.from(left || '');
|
const leftBuffer = Buffer.from(left || '');
|
||||||
const rightBuffer = Buffer.from(right || '');
|
const rightBuffer = Buffer.from(right || '');
|
||||||
@@ -131,7 +203,8 @@ const buildQuestionPayload = (
|
|||||||
word: any,
|
word: any,
|
||||||
testType: VocabularyTestType,
|
testType: VocabularyTestType,
|
||||||
questionToken: string,
|
questionToken: string,
|
||||||
optionsPool: any[]
|
optionsPool: any[],
|
||||||
|
suppliedOptions?: Array<string | VocabularyOptionPayload>
|
||||||
): VocabularyAttemptQuestion => {
|
): VocabularyAttemptQuestion => {
|
||||||
const base = {
|
const base = {
|
||||||
questionToken,
|
questionToken,
|
||||||
@@ -153,10 +226,13 @@ const buildQuestionPayload = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const options = suppliedOptions || buildMultipleChoiceOptions(word, optionsPool);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
word: word.word,
|
word: word.word,
|
||||||
options: buildMultipleChoiceOptions(word, optionsPool)
|
options,
|
||||||
|
optionTokenRequest: options.some(option => typeof option === 'string')
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +265,121 @@ const evaluateWordAnswer = (word: any, testType: VocabularyTestType, answer: unk
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const summarizeInteractionEvents = (
|
||||||
|
events: InteractionEventPayload[],
|
||||||
|
questionShownAtMs: number
|
||||||
|
): InteractionSummary => {
|
||||||
|
const safeEvents = Array.isArray(events) ? events.slice(0, 300) : [];
|
||||||
|
const timestamps = safeEvents
|
||||||
|
.map(event => Number(event?.ts))
|
||||||
|
.filter(value => Number.isFinite(value));
|
||||||
|
const firstEventTs = timestamps.length > 0 ? Math.min(...timestamps) : questionShownAtMs;
|
||||||
|
const lastEventTs = timestamps.length > 0 ? Math.max(...timestamps) : questionShownAtMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyCount: safeEvents.filter(event => event?.type === 'keydown' || event?.type === 'keyup').length,
|
||||||
|
inputCount: safeEvents.filter(event => event?.type === 'input').length,
|
||||||
|
pasteCount: safeEvents.filter(event => event?.type === 'paste').length,
|
||||||
|
focusCount: safeEvents.filter(event => event?.type === 'focus').length,
|
||||||
|
blurCount: safeEvents.filter(event => event?.type === 'blur').length,
|
||||||
|
pointerCount: safeEvents.filter(event => event?.type === 'pointerdown' || event?.type === 'click' || event?.type === 'change').length,
|
||||||
|
firstEventOffset: Math.max(0, firstEventTs - questionShownAtMs),
|
||||||
|
lastEventOffset: Math.max(0, lastEventTs - questionShownAtMs)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
testType: VocabularyTestType
|
||||||
|
): string[] => {
|
||||||
|
const flags: string[] = [];
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyzeBatchRisk = (durations: number[]): string[] => {
|
||||||
|
if (durations.length < 3) return [];
|
||||||
|
|
||||||
|
const roundedDurations = durations.map(value => Math.round(value * 10) / 10);
|
||||||
|
const average = roundedDurations.reduce((sum, value) => sum + value, 0) / roundedDurations.length;
|
||||||
|
const variance = roundedDurations.reduce((sum, value) => sum + Math.pow(value - average, 2), 0) / roundedDurations.length;
|
||||||
|
const uniqueWholeSecondCount = new Set(durations.map(value => Math.round(value))).size;
|
||||||
|
const durationRange = Math.max(...durations) - Math.min(...durations);
|
||||||
|
const flags: string[] = [];
|
||||||
|
|
||||||
|
if (durationRange <= 1) flags.push('uniform_answer_intervals');
|
||||||
|
if (variance < 0.05 && roundedDurations.length >= 4) flags.push('uniform_answer_intervals');
|
||||||
|
if (uniqueWholeSecondCount <= 2 && durations.length >= 5) flags.push('repeated_whole_second_intervals');
|
||||||
|
|
||||||
|
return Array.from(new Set(flags));
|
||||||
|
};
|
||||||
|
|
||||||
const buildMasteryStatus = (record: any) => {
|
const buildMasteryStatus = (record: any) => {
|
||||||
const masteryStatus: Record<string, any> = {};
|
const masteryStatus: Record<string, any> = {};
|
||||||
WORD_RECORD_MODES.forEach(mode => {
|
WORD_RECORD_MODES.forEach(mode => {
|
||||||
@@ -458,7 +649,7 @@ router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => {
|
|||||||
if (req.query.count) {
|
if (req.query.count) {
|
||||||
const parsed = parseInt(req.query.count as string, 10);
|
const parsed = parseInt(req.query.count as string, 10);
|
||||||
if (!isNaN(parsed) && parsed > 0) {
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
targetCount = Math.min(parsed, 100);
|
targetCount = Math.max(MIN_STUDY_WORD_COUNT, Math.min(parsed, MAX_STUDY_WORD_COUNT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,20 +799,24 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
|||||||
const userId = req.user?._id;
|
const userId = req.user?._id;
|
||||||
|
|
||||||
if (!userId) {
|
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: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mongoose.isValidObjectId(wordSetId)) {
|
if (!mongoose.isValidObjectId(wordSetId)) {
|
||||||
return res.status(400).json({ message: '单词集ID无效' });
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVocabularyTestType(testType)) {
|
if (!isVocabularyTestType(testType)) {
|
||||||
return res.status(400).json({ message: '未知的测试类型' });
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordSet = await WordSet.findById(wordSetId);
|
const wordSet = await WordSet.findById(wordSetId);
|
||||||
if (!wordSet) {
|
if (!wordSet) {
|
||||||
return res.status(404).json({ message: '未找到单词集' });
|
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedWords: any[] = [];
|
let selectedWords: any[] = [];
|
||||||
@@ -629,23 +824,23 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
if (Array.isArray(wordIds) && wordIds.length > 0) {
|
if (Array.isArray(wordIds) && wordIds.length > 0) {
|
||||||
if (wordIds.length > 100) {
|
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))) {
|
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()))];
|
const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))];
|
||||||
if (uniqueWordIds.length !== wordIds.length) {
|
if (uniqueWordIds.length !== wordIds.length) {
|
||||||
return res.status(400).json({ message: '测试单词不能重复' });
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordMap = new Map<string, any>(allWords.map(word => [getObjectIdString(word), word]));
|
const wordMap = new Map<string, any>(allWords.map(word => [getObjectIdString(word), word]));
|
||||||
selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId));
|
selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId));
|
||||||
|
|
||||||
if (selectedWords.some(word => !word)) {
|
if (selectedWords.some(word => !word)) {
|
||||||
return res.status(400).json({ message: '测试单词必须属于当前单词集' });
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedWords = shuffle(selectedWords);
|
selectedWords = shuffle(selectedWords);
|
||||||
@@ -658,7 +853,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedWords.length === 0) {
|
if (selectedWords.length === 0) {
|
||||||
return res.status(400).json({ message: '没有可测试的单词' });
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
const issuedAt = new Date();
|
const issuedAt = new Date();
|
||||||
@@ -680,10 +875,19 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
attempt.questionTokens = questionTokens;
|
attempt.questionTokens = questionTokens;
|
||||||
|
if (testType === 'multiple-choice') {
|
||||||
|
const optionTexts = selectedWords.map(word => buildMultipleChoiceOptions(word, allWords));
|
||||||
|
attempt.optionTexts = optionTexts;
|
||||||
|
attempt.optionTokens = optionTexts.map((options, index) =>
|
||||||
|
options.map((option, optionIndex) => signOptionToken(attemptId, questionTokens[index], option, optionIndex))
|
||||||
|
);
|
||||||
|
}
|
||||||
await attempt.save();
|
await attempt.save();
|
||||||
|
|
||||||
const questions = selectedWords.map((word, index) =>
|
const questions = selectedWords.map((word, index) =>
|
||||||
buildQuestionPayload(word, testType, questionTokens[index], allWords)
|
buildQuestionPayload(word, testType, questionTokens[index], allWords, testType === 'multiple-choice'
|
||||||
|
? attempt.optionTexts?.[index] || []
|
||||||
|
: undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
@@ -694,7 +898,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建测试会话失败:', error);
|
console.error('创建测试会话失败:', error);
|
||||||
res.status(500).json({ message: '创建测试会话失败' });
|
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -704,26 +908,26 @@ router.post('/word-record', (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
}, authMiddleware, async (req, res) => {
|
}, authMiddleware, async (req, res) => {
|
||||||
return res.status(410).json({
|
return res.status(410).json({
|
||||||
message: '单题记录接口已停用,请通过测试会话提交记录'
|
message: INVALID_CREDENTIAL_MESSAGE
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 保存测试记录:只能通过服务端签发的 attempt 提交
|
// 单题提交:服务端逐题判分并累计结果
|
||||||
router.post('/test-record', authMiddleware, async (req, res) => {
|
router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { attemptId, answers } = req.body;
|
const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body;
|
||||||
const userId = req.user?._id;
|
const userId = req.user?._id;
|
||||||
|
|
||||||
if (!userId) {
|
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: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mongoose.isValidObjectId(attemptId)) {
|
if (!mongoose.isValidObjectId(attemptId)) {
|
||||||
return res.status(400).json({ message: '测试会话ID无效' });
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(answers) || answers.length === 0) {
|
|
||||||
return res.status(400).json({ message: '测试答案不能为空' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const attempt = await VocabularyTestAttempt.findOne({
|
const attempt = await VocabularyTestAttempt.findOne({
|
||||||
@@ -732,7 +936,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!attempt) {
|
if (!attempt) {
|
||||||
return res.status(404).json({ message: '未找到测试会话' });
|
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
const attemptAny = attempt as any;
|
const attemptAny = attempt as any;
|
||||||
@@ -743,116 +947,288 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
|||||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||||
attemptAny.status = 'expired';
|
attemptAny.status = 'expired';
|
||||||
await attempt.save();
|
await attempt.save();
|
||||||
return res.status(410).json({ message: '测试会话已过期,请重新开始测试' });
|
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attemptAny.status !== 'active') {
|
if (attemptAny.status !== 'active') {
|
||||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (answers.length !== attemptAny.questionTokens.length) {
|
const token = String(questionToken ?? '');
|
||||||
return res.status(400).json({ message: '答案数量与测试题目不一致' });
|
const tokenIndex = attemptAny.questionTokens.indexOf(token);
|
||||||
|
if (!token || tokenIndex < 0) {
|
||||||
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenIndexMap = new Map<string, number>();
|
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||||||
attemptAny.questionTokens.forEach((token: string, index: number) => {
|
? attemptAny.answeredQuestionTokens
|
||||||
tokenIndexMap.set(token, index);
|
: [];
|
||||||
});
|
if (answeredTokens.includes(token)) {
|
||||||
|
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
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));
|
if (tokenIndex !== answeredTokens.length) {
|
||||||
const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000);
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
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 submittedAtMs = Number.parseInt(String(submittedAt), 10);
|
||||||
const wordMap = new Map<string, any>();
|
if (!Number.isFinite(submittedAtMs)) {
|
||||||
const words = await Word.find({
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
_id: { $in: questionWordIds },
|
}
|
||||||
|
|
||||||
|
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||||||
|
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: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500);
|
||||||
|
if (attemptAny.testType === 'multiple-choice') {
|
||||||
|
const optionTokens = attemptAny.optionTokens?.[tokenIndex] || [];
|
||||||
|
const optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
|
||||||
|
const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer));
|
||||||
|
if (selectedOptionIndex < 0) {
|
||||||
|
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: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionShownAtMs = tokenIndex === 0
|
||||||
|
? issuedAt.getTime()
|
||||||
|
: new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || issuedAt).getTime();
|
||||||
|
const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000);
|
||||||
|
const interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs);
|
||||||
|
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
|
wordSet: attemptAny.wordSet
|
||||||
});
|
});
|
||||||
words.forEach(word => {
|
if (!word) {
|
||||||
wordMap.set(getObjectIdString(word), word);
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer);
|
||||||
|
attemptAny.answeredQuestionTokens = [...answeredTokens, token];
|
||||||
|
attemptAny.answerDurations = [...(attemptAny.answerDurations || []), duration];
|
||||||
|
attemptAny.answers = [
|
||||||
|
...(attemptAny.answers || []),
|
||||||
|
{
|
||||||
|
questionToken: token,
|
||||||
|
word: word._id,
|
||||||
|
userAnswer: evaluation.userAnswer,
|
||||||
|
correctAnswer: evaluation.correctAnswer,
|
||||||
|
isCorrect: evaluation.isCorrect,
|
||||||
|
submittedAt: new Date(submittedAtMs),
|
||||||
|
duration,
|
||||||
|
riskFlags,
|
||||||
|
interactionSummary
|
||||||
|
}
|
||||||
|
];
|
||||||
|
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...riskFlags]));
|
||||||
|
await attempt.save();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: '答案已提交',
|
||||||
|
result: {
|
||||||
|
questionToken: token,
|
||||||
|
wordId,
|
||||||
|
word: evaluation.word,
|
||||||
|
userAnswer: evaluation.userAnswer,
|
||||||
|
correctAnswer: evaluation.correctAnswer,
|
||||||
|
isCorrect: evaluation.isCorrect
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
answered: attemptAny.answeredQuestionTokens.length,
|
||||||
|
total: attemptAny.questionTokens.length,
|
||||||
|
finished: attemptAny.answeredQuestionTokens.length === attemptAny.questionTokens.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交单题答案失败:', error);
|
||||||
|
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 选择题点击选项后换取该选项的一次性凭证
|
||||||
|
router.post('/test-option-token', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { attemptId, questionToken, optionText } = req.body;
|
||||||
|
const userId = req.user?._id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedVocabularyOrigin(req)) {
|
||||||
|
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mongoose.isValidObjectId(attemptId)) {
|
||||||
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = await VocabularyTestAttempt.findOne({
|
||||||
|
_id: attemptId,
|
||||||
|
user: userId
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const item of submittedAnswers) {
|
if (!attempt) {
|
||||||
const index = tokenIndexMap.get(item.token)!;
|
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
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 attemptAny = attempt as any;
|
||||||
|
if (attemptAny.status !== 'active') {
|
||||||
|
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptAny.testType !== 'multiple-choice') {
|
||||||
|
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: 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: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||||||
|
? attemptAny.answeredQuestionTokens
|
||||||
|
: [];
|
||||||
|
if (tokenIndex !== answeredTokens.length) {
|
||||||
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex];
|
||||||
|
if (!optionToken) {
|
||||||
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
questionToken: token,
|
||||||
|
optionToken
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取选择项凭证失败:', error);
|
||||||
|
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存测试记录:只能通过服务端签发的 attempt 提交
|
||||||
|
router.post('/test-record', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { attemptId } = req.body;
|
||||||
|
const userId = req.user?._id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedVocabularyOrigin(req)) {
|
||||||
|
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mongoose.isValidObjectId(attemptId)) {
|
||||||
|
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = await VocabularyTestAttempt.findOne({
|
||||||
|
_id: attemptId,
|
||||||
|
user: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attempt) {
|
||||||
|
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptAny = attempt as any;
|
||||||
|
const issuedAt = new Date(attemptAny.issuedAt || attemptAny.createdAt);
|
||||||
|
const expiresAt = new Date(attemptAny.expiresAt);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||||
|
attemptAny.status = 'expired';
|
||||||
|
await attempt.save();
|
||||||
|
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptAny.status !== 'active') {
|
||||||
|
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: INVALID_CREDENTIAL_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSubmittedAtMs = Math.max(...submittedAnswers.map((item: any) => new Date(item.submittedAt).getTime()));
|
||||||
|
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 = isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || []);
|
||||||
|
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
|
||||||
|
const shouldInvalidateBatch = batchRiskFlags.length > 0 ||
|
||||||
|
['too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch'].some(flag => (attemptAny.riskFlags || []).includes(flag));
|
||||||
|
|
||||||
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||||
{ _id: attempt._id, user: userId, status: 'active' },
|
{ _id: attempt._id, user: userId, status: 'active' },
|
||||||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs) } },
|
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } },
|
||||||
{ new: true }
|
{ new: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!claimedAttempt) {
|
if (!claimedAttempt) {
|
||||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const result of evaluatedResults) {
|
const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word));
|
||||||
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
const submittedWords = await Word.find({ _id: { $in: submittedWordIds } }).select('_id word');
|
||||||
|
const submittedWordMap = new Map<string, string>();
|
||||||
|
submittedWords.forEach(word => {
|
||||||
|
submittedWordMap.set(getObjectIdString(word), word.word);
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluatedResults: EvaluatedVocabularyAnswer[] = submittedAnswers.map((item: any) => {
|
||||||
|
const wordId = getObjectIdString(item.word);
|
||||||
|
return {
|
||||||
|
wordId,
|
||||||
|
word: submittedWordMap.get(wordId) || '',
|
||||||
|
userAnswer: item.userAnswer,
|
||||||
|
correctAnswer: item.correctAnswer,
|
||||||
|
isCorrect: Boolean(item.isCorrect)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldInvalidateBatch) {
|
||||||
|
for (const result of evaluatedResults) {
|
||||||
|
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalWords = evaluatedResults.length;
|
const totalWords = evaluatedResults.length;
|
||||||
const correctWords = evaluatedResults.filter(result => result.isCorrect).length;
|
const correctWords = shouldInvalidateBatch ? 0 : evaluatedResults.filter(result => result.isCorrect).length;
|
||||||
const verifiedStats = {
|
const verifiedStats = {
|
||||||
totalWords,
|
totalWords,
|
||||||
correctWords,
|
correctWords,
|
||||||
@@ -868,24 +1244,31 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
|||||||
attempt: attempt._id,
|
attempt: attempt._id,
|
||||||
testType: attemptAny.testType,
|
testType: attemptAny.testType,
|
||||||
stats: verifiedStats,
|
stats: verifiedStats,
|
||||||
results: evaluatedResults.map(result => ({
|
results: submittedAnswers.map((result: any) => ({
|
||||||
word: result.wordId,
|
word: result.word,
|
||||||
userAnswer: result.userAnswer,
|
userAnswer: result.userAnswer,
|
||||||
correctAnswer: result.correctAnswer,
|
correctAnswer: result.correctAnswer,
|
||||||
isCorrect: result.isCorrect
|
isCorrect: shouldInvalidateBatch ? false : result.isCorrect
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: '测试记录已保存',
|
message: shouldInvalidateBatch ? INVALID_CREDENTIAL_MESSAGE : '测试记录已保存',
|
||||||
attemptId: attempt._id.toString(),
|
attemptId: attempt._id.toString(),
|
||||||
stats: verifiedStats,
|
stats: verifiedStats,
|
||||||
results: evaluatedResults,
|
results: submittedAnswers.map((result: any) => ({
|
||||||
submittedAt: new Date(lastSubmittedAtMs)
|
wordId: getObjectIdString(result.word),
|
||||||
|
word: submittedWordMap.get(getObjectIdString(result.word)) || '',
|
||||||
|
userAnswer: result.userAnswer,
|
||||||
|
correctAnswer: result.correctAnswer,
|
||||||
|
isCorrect: shouldInvalidateBatch ? false : result.isCorrect
|
||||||
|
})),
|
||||||
|
submittedAt: new Date(lastSubmittedAtMs),
|
||||||
|
invalidated: shouldInvalidateBatch
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存测试记录失败:', error);
|
console.error('保存测试记录失败:', error);
|
||||||
res.status(500).json({ message: '保存测试记录失败' });
|
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
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, 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 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';
|
||||||
import type { TabsProps } from 'antd';
|
import type { TabsProps } from 'antd';
|
||||||
import type { RadioChangeEvent } from 'antd/lib/radio';
|
|
||||||
|
|
||||||
interface Word {
|
interface Word {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
@@ -74,13 +73,34 @@ interface PendingAnswer {
|
|||||||
answerProof: string;
|
answerProof: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InteractionEventPayload {
|
||||||
|
type: string;
|
||||||
|
ts: number;
|
||||||
|
key?: string;
|
||||||
|
inputType?: string;
|
||||||
|
value?: string;
|
||||||
|
valueLength?: number;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeInputLikeEvent extends Event {
|
||||||
|
inputType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestOption {
|
||||||
|
text: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TestQuestion {
|
interface TestQuestion {
|
||||||
questionToken: string;
|
questionToken: string;
|
||||||
wordId: string;
|
wordId: string;
|
||||||
word?: string;
|
word?: string;
|
||||||
translation?: string;
|
translation?: string;
|
||||||
pronunciation?: string;
|
pronunciation?: string;
|
||||||
options?: string[];
|
options?: Array<string | TestOption>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SavedVocabularyTestRecordResponse {
|
interface SavedVocabularyTestRecordResponse {
|
||||||
@@ -94,6 +114,7 @@ interface SavedVocabularyTestRecordResponse {
|
|||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
results: TestResult[];
|
results: TestResult[];
|
||||||
|
invalidated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TestAttemptResponse {
|
interface TestAttemptResponse {
|
||||||
@@ -103,7 +124,25 @@ interface TestAttemptResponse {
|
|||||||
questions: TestQuestion[];
|
questions: TestQuestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubmitAnswerResponse {
|
||||||
|
message: string;
|
||||||
|
result: TestResult;
|
||||||
|
progress: {
|
||||||
|
answered: number;
|
||||||
|
total: number;
|
||||||
|
finished: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionTokenResponse {
|
||||||
|
questionToken: string;
|
||||||
|
optionToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ANSWER_PROOF_SALT = 'd1ktsalt';
|
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 VocabularyStudy: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -130,7 +169,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
const [testAttemptId, setTestAttemptId] = useState<string>('');
|
const [testAttemptId, setTestAttemptId] = useState<string>('');
|
||||||
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
|
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
|
||||||
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
|
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
|
||||||
const [options, setOptions] = useState<string[]>([]);
|
const [options, setOptions] = useState<TestOption[]>([]);
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const timeOffsetRef = useRef<number>(0);
|
const timeOffsetRef = useRef<number>(0);
|
||||||
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
|
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
|
||||||
@@ -145,6 +184,8 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
// 使用ref存储当前选中ID,这样可以立即访问
|
// 使用ref存储当前选中ID,这样可以立即访问
|
||||||
const currentWordSetIdRef = useRef<string>('');
|
const currentWordSetIdRef = useRef<string>('');
|
||||||
const answerSubmittingRef = useRef(false);
|
const answerSubmittingRef = useRef(false);
|
||||||
|
const interactionsRef = useRef<InteractionEventPayload[]>([]);
|
||||||
|
const questionShownAtRef = useRef<number>(0);
|
||||||
|
|
||||||
// 测试输入框ref
|
// 测试输入框ref
|
||||||
const inputRef = useRef<any>(null);
|
const inputRef = useRef<any>(null);
|
||||||
@@ -295,7 +336,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
// 处理单词数量变更
|
// 处理单词数量变更
|
||||||
const handleWordCountChange = (value: number | null) => {
|
const handleWordCountChange = (value: number | null) => {
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
setWordCount(value);
|
setWordCount(Math.max(MIN_STUDY_WORD_COUNT, Math.min(value, MAX_STUDY_WORD_COUNT)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -457,7 +498,8 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
setShowAnswer(false);
|
setShowAnswer(false);
|
||||||
setTestResults([]);
|
setTestResults([]);
|
||||||
setPendingAnswers([]);
|
setPendingAnswers([]);
|
||||||
setOptions(questions[0]?.options || []);
|
setOptions(normalizeOptions(questions[0]));
|
||||||
|
resetInteractionTrace();
|
||||||
setIsModalVisible(false);
|
setIsModalVisible(false);
|
||||||
setStudyStats({
|
setStudyStats({
|
||||||
totalWords: 0,
|
totalWords: 0,
|
||||||
@@ -467,11 +509,7 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
duration: 0
|
duration: 0
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError) {
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
message.error(`创建测试会话失败: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
message.error('创建测试会话失败');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setTestAttemptLoading(false);
|
setTestAttemptLoading(false);
|
||||||
}
|
}
|
||||||
@@ -489,6 +527,102 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
|
|
||||||
const getWordId = (word: Word): string => word._id || word.id;
|
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 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<HTMLInputElement>): 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;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildAnswerProof = (
|
const buildAnswerProof = (
|
||||||
attemptId: string,
|
attemptId: string,
|
||||||
questionToken: string,
|
questionToken: string,
|
||||||
@@ -508,6 +642,11 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
answerSubmittingRef.current = false;
|
answerSubmittingRef.current = false;
|
||||||
return;
|
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 submittedAt = Date.now();
|
||||||
const normalizedUserAnswer = userAnswer.trim();
|
const normalizedUserAnswer = userAnswer.trim();
|
||||||
const answerProof = buildAnswerProof(
|
const answerProof = buildAnswerProof(
|
||||||
@@ -517,39 +656,59 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
submittedAt
|
submittedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedPendingAnswers = [
|
try {
|
||||||
...pendingAnswers,
|
const submittedAnswer = {
|
||||||
{
|
|
||||||
questionToken: currentQuestion.questionToken,
|
questionToken: currentQuestion.questionToken,
|
||||||
userAnswer: normalizedUserAnswer,
|
userAnswer: normalizedUserAnswer,
|
||||||
submittedAt,
|
submittedAt,
|
||||||
answerProof
|
answerProof,
|
||||||
}
|
interactions: interactionsRef.current
|
||||||
];
|
};
|
||||||
|
|
||||||
setPendingAnswers(updatedPendingAnswers);
|
const response = await api.post<SubmitAnswerResponse>(API_PATHS.VOCABULARY.TEST_ANSWER, {
|
||||||
setShowAnswer(true);
|
attemptId: testAttemptId,
|
||||||
setUserAnswer('');
|
...submittedAnswer
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
const updatedPendingAnswers = [
|
||||||
if (testIndex < testWords.length - 1) {
|
...pendingAnswers,
|
||||||
const nextIndex = testIndex + 1;
|
{
|
||||||
setTestIndex(nextIndex);
|
questionToken: currentQuestion.questionToken,
|
||||||
setShowAnswer(false);
|
userAnswer: normalizedUserAnswer,
|
||||||
answerSubmittingRef.current = false;
|
submittedAt,
|
||||||
if (testType === 'audio-to-english') {
|
answerProof
|
||||||
setTimeout(() => {
|
|
||||||
playWordSound(testWords[nextIndex]?.word || '');
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
if (testType === 'multiple-choice') {
|
];
|
||||||
setOptions(testWords[nextIndex]?.options || []);
|
|
||||||
|
setPendingAnswers(updatedPendingAnswers);
|
||||||
|
setTestResults(prev => [...prev, response.result]);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
} else {
|
}, 1500);
|
||||||
answerSubmittingRef.current = false;
|
} catch (error) {
|
||||||
finishTestWithResults(updatedPendingAnswers);
|
answerSubmittingRef.current = false;
|
||||||
}
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
}, 1500);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用指定结果完成测试
|
// 使用指定结果完成测试
|
||||||
@@ -563,15 +722,9 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
currentStudyStats: studyStats
|
currentStudyStats: studyStats
|
||||||
});
|
});
|
||||||
|
|
||||||
// 提交测试记录,后端会用数据库答案重新判分
|
// 提交测试记录,后端会用服务端累计的逐题答案重新判分
|
||||||
const savedRecord = await api.post<SavedVocabularyTestRecordResponse>(API_PATHS.VOCABULARY.TEST_RECORD, {
|
const savedRecord = await api.post<SavedVocabularyTestRecordResponse>(API_PATHS.VOCABULARY.TEST_RECORD, {
|
||||||
attemptId: testAttemptId,
|
attemptId: testAttemptId
|
||||||
answers: finalAnswers.map(answer => ({
|
|
||||||
questionToken: answer.questionToken,
|
|
||||||
userAnswer: answer.userAnswer,
|
|
||||||
submittedAt: answer.submittedAt,
|
|
||||||
answerProof: answer.answerProof
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setStudyStats({
|
setStudyStats({
|
||||||
@@ -588,14 +741,14 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
setShowAnswer(false);
|
setShowAnswer(false);
|
||||||
setTestFinished(true);
|
setTestFinished(true);
|
||||||
|
|
||||||
message.success('测试完成,记录已保存');
|
if (savedRecord.invalidated) {
|
||||||
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
|
} else {
|
||||||
|
message.success('测试完成,记录已保存');
|
||||||
|
}
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError) {
|
message.error(INVALID_CREDENTIAL_MESSAGE);
|
||||||
message.error(`保存测试记录失败: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
message.error('保存测试记录失败');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -617,6 +770,8 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
setTestResults([]);
|
setTestResults([]);
|
||||||
setPendingAnswers([]);
|
setPendingAnswers([]);
|
||||||
setOptions([]);
|
setOptions([]);
|
||||||
|
interactionsRef.current = [];
|
||||||
|
questionShownAtRef.current = 0;
|
||||||
answerSubmittingRef.current = false;
|
answerSubmittingRef.current = false;
|
||||||
// 重置统计信息
|
// 重置统计信息
|
||||||
setStudyStats({
|
setStudyStats({
|
||||||
@@ -740,10 +895,16 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
testType === 'multiple-choice' &&
|
testType === 'multiple-choice' &&
|
||||||
testWords.length > 0
|
testWords.length > 0
|
||||||
) {
|
) {
|
||||||
setOptions(testWords[testIndex]?.options || []);
|
setOptions(normalizeOptions(testWords[testIndex]));
|
||||||
}
|
}
|
||||||
}, [testIndex, testType, testStarted, testFinished, testWords]);
|
}, [testIndex, testType, testStarted, testFinished, testWords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (testStarted && !testFinished && testWords.length > 0) {
|
||||||
|
resetInteractionTrace();
|
||||||
|
}
|
||||||
|
}, [testIndex, testStarted, testFinished, testWords.length]);
|
||||||
|
|
||||||
// 自动播放第一个听力单词
|
// 自动播放第一个听力单词
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -830,14 +991,14 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
|
|
||||||
<h4>学习单词数量:</h4>
|
<h4>学习单词数量:</h4>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={5}
|
min={MIN_STUDY_WORD_COUNT}
|
||||||
max={100}
|
max={MAX_STUDY_WORD_COUNT}
|
||||||
value={wordCount}
|
value={wordCount}
|
||||||
onChange={handleWordCountChange}
|
onChange={handleWordCountChange}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
/>
|
/>
|
||||||
<span style={{ marginLeft: 10, color: '#888' }}>
|
<span style={{ marginLeft: 10, color: '#888' }}>
|
||||||
(范围: 5-100个单词)
|
(范围: {MIN_STUDY_WORD_COUNT}-{MAX_STUDY_WORD_COUNT}个单词)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1185,12 +1346,19 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={userAnswer}
|
value={userAnswer}
|
||||||
disabled={showAnswer}
|
disabled={showAnswer}
|
||||||
onChange={e => setUserAnswer(e.target.value)}
|
|
||||||
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
|
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
|
||||||
>
|
>
|
||||||
{options.map(option => (
|
{options.map(option => (
|
||||||
<Radio key={option} value={option} style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}>
|
<Radio
|
||||||
{option}
|
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>
|
||||||
))}
|
))}
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
@@ -1199,16 +1367,58 @@ const VocabularyStudy: React.FC = () => {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder="请输入英文单词"
|
placeholder="请输入英文单词"
|
||||||
value={userAnswer}
|
value={userAnswer}
|
||||||
onChange={e => setUserAnswer(e.target.value)}
|
onChange={e => {
|
||||||
|
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,
|
||||||
|
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();
|
||||||
|
}}
|
||||||
|
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}
|
disabled={showAnswer}
|
||||||
style={{ marginBottom: 15 }}
|
style={{ marginBottom: 15, userSelect: 'none', WebkitUserSelect: 'none' }}
|
||||||
onPressEnter={e => {
|
onPressEnter={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
submitAnswer();
|
submitAnswer();
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
size="large"
|
size="large"
|
||||||
onPaste={e => e.preventDefault()}
|
onPaste={e => {
|
||||||
|
recordInteraction({ type: 'paste', value: userAnswer, valueLength: userAnswer.length });
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const API_PATHS = {
|
|||||||
STUDY_WORDS: '/vocabulary/study-words',
|
STUDY_WORDS: '/vocabulary/study-words',
|
||||||
UPLOAD: '/vocabulary/upload',
|
UPLOAD: '/vocabulary/upload',
|
||||||
TEST_ATTEMPT: '/vocabulary/test-attempt',
|
TEST_ATTEMPT: '/vocabulary/test-attempt',
|
||||||
|
TEST_ANSWER: '/vocabulary/test-answer',
|
||||||
|
TEST_OPTION_TOKEN: '/vocabulary/test-option-token',
|
||||||
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