return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
-// 在例句中把目标词替换为空格(用于句子填空选择题)。找不到目标词则返回原句。
+// 在例句中把目标词替换为空格(用于句子填空选择题)
function blankWordInSentence(sentence, word) {
if (!sentence || !word) return sentence || '';
const re = new RegExp('\\b' + escapeRegExp(word) + '\\b', 'ig');
return sentence.replace(re, '______');
}
-// 为æ\9f\90è¯\8dç\94\9fæ\88\90 4 个è¯\8dæ\80§é\80\89项ï¼\88å\90«æ£ç¡®é¡¹ï¼\89ã\80\82avoidSet ä¸ç\9a\84é\94\99误项å°\86被æ\9b¿æ\8d¢æ\8e\89ï¼\88é\9c\80æ±\82 5ï¼\9aæ\8d¢æ\8e\89é\94\99误混æ·\86项并重新排序)
+// 为æ\9f\90è¯\8dç\94\9fæ\88\90 4 个è¯\8dæ\80§é\80\89项ï¼\88å\90«æ£ç¡®é¡¹ï¼\89ã\80\82avoidSet ä¸ç\9a\84é\94\99误项å°\86被æ\9b¿æ\8d¢æ\8e\89ï¼\88é\80\89é\94\99æ\8d¢项并重新排序)
function buildPosOptions(word, posPool, avoidSet) {
const correct = (word.part_of_speech || '').trim();
let distractors = shuffle(posPool.filter(p => p && p !== correct && !avoidSet.has(p))).slice(0, 3);
- // 干扰项不足 3 个时,放宽到全池补充(不含正确项、不重复)
if (distractors.length < 3) {
const backup = shuffle(posPool.filter(p => p && p !== correct && !distractors.includes(p)));
for (const p of backup) {
return shuffle([correct, ...distractors]);
}
-// 为句子填空生成选项:正确词 + 同组非背诵干扰词。avoidSet 中的错误项将被替换
+// 为句子填空生成选项:正确词 + 同组干扰词。avoidSet 中的错误项将被替换
function buildSentenceOptions(target, candidateWords, avoidSet) {
const correct = target.word;
let distractors = shuffle(
return (s || '').toLowerCase().replace(/[\s,。、;:?!.,;:?!'"()()]/g, '');
}
-function getCategories(words) {
- const spell = words.filter(w => (w.memory_type || 'spell') === 'spell');
- const recognize = words.filter(w => (w.memory_type || '') === 'recognize');
- const recite = recognize.filter(w => w.need_recite === 1); // 非重点但需背诵 → 默写
- const other = recognize.filter(w => w.need_recite !== 1 && (w.part_of_speech || '').trim()); // 其余 → 选词性+回忆中文
- return { spell, recognize, recite, other };
-}
-
-// 句子填空候选:非背诵且例句包含该词(最多 5 个,best-effort 待校对)
-function buildSentenceCandidates(other) {
- const candidates = other.filter(w => {
+// 例句可作为句子填空候选:例句包含该词(重点/非重点都算,GPT 例句后续替换)
+function sentenceCandidates(words) {
+ const cands = words.filter(w => {
const s = (w.example_sentence || '');
return s && (w.word || '') && s.toLowerCase().includes((w.word || '').toLowerCase());
});
- return shuffle(candidates).slice(0, 5);
+ return shuffle(cands).slice(0, 5);
}
function TestMode() {
const { groupId } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
- // 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test)
+ // 活动类型:test / review1 / review2 / review3
const activityType = searchParams.get('type') || 'test';
const isOnline = useOnlineStatus();
- // 测试/复习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长
const sessionStartRef = useRef(Date.now());
const [words, setWords] = useState([]);
- const [phase, setPhase] = useState('ready'); // ready, word, word_fb, copy, sentence, sentence_fb, complete
+ // 三阶段:spell(默写) → pos(选词性) → sentence(句子选词)
+ const [phase, setPhase] = useState('ready');
- // 单词环节队列(默写 / 选词性)
- const [wordQueue, setWordQueue] = useState([]);
- const [wordIndex, setWordIndex] = useState(0);
- const [wrongWords, setWrongWords] = useState([]);
+ const [spellQueue, setSpellQueue] = useState([]);
+ const [spellIndex, setSpellIndex] = useState(0);
+ const [wrongSpell, setWrongSpell] = useState([]);
+
+ const [posQueue, setPosQueue] = useState([]);
+ const [posIndex, setPosIndex] = useState(0);
+ const [wrongPos, setWrongPos] = useState([]);
- // 句子填空环节队列
const [sentenceQueue, setSentenceQueue] = useState([]);
const [sentenceIndex, setSentenceIndex] = useState(0);
const [wrongSentences, setWrongSentences] = useState([]);
- // 交互状态
const [userInput, setUserInput] = useState('');
const [copyInput, setCopyInput] = useState('');
const [posChoice, setPosChoice] = useState('');
const [chineseRecall, setChineseRecall] = useState('');
- const [compareResult, setCompareResult] = useState(null); // true/false/null
+ const [compareResult, setCompareResult] = useState(null);
const [isCorrect, setIsCorrect] = useState(false);
const [sentenceChoice, setSentenceChoice] = useState('');
const copyRef = useRef(null);
const chineseRef = useRef(null);
const submittedRef = useRef(false);
-
- // 构建环节所需的派生数据(存 ref,供重考时重新生成选项)
const posPoolRef = useRef([]);
- const otherRecognizeRef = useRef([]);
- const current = phase === 'sentence' || phase === 'sentence_fb'
- ? sentenceQueue[sentenceIndex]
- : wordQueue[wordIndex];
+ const current = (() => {
+ if (phase === 'spell' || phase === 'spell_fb' || phase === 'copy') return spellQueue[spellIndex];
+ if (phase === 'pos' || phase === 'pos_fb') return posQueue[posIndex];
+ if (phase === 'sentence' || phase === 'sentence_fb') return sentenceQueue[sentenceIndex];
+ return null;
+ })();
// 离线时检查可学习范围
useEffect(() => {
});
}, [isOnline, groupId, navigate]);
- // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
+ // 获取单词数据
useEffect(() => {
api.getWords(groupId)
.then(res => setWords(res.data))
});
}, [groupId]);
- // 清空所有交互输入
+ // 清空交互输入
const resetInputs = useCallback(() => {
setUserInput('');
setCopyInput('');
setSentenceChoice('');
}, []);
- // 开始测试:构建三类环节
+ // 开始:构建三阶段队列(重点词拼写最先,其次识别词选词性,最后句子填空)
const startTest = useCallback(() => {
- const { spell, recite, other } = getCategories(words);
+ const spell = words.filter(w => (w.memory_type || 'spell') === 'spell');
+ const recognize = words.filter(w => (w.memory_type || '') === 'recognize');
const posPool = Array.from(new Set([
...words.map(w => (w.part_of_speech || '').trim()).filter(Boolean),
...UNIVERSAL_POS,
]));
posPoolRef.current = posPool;
- otherRecognizeRef.current = other;
- const dictationItems = [...spell, ...recite].map(w => ({ ...w, mode: 'dictation' }));
- const posItems = other.map(w => ({
+ const spellItems = shuffle(spell.map(w => ({ ...w, mode: 'dictation' })));
+ const posItems = shuffle(recognize.map(w => ({
...w,
mode: 'pos',
posOptions: buildPosOptions(w, posPool, new Set()),
avoidPos: new Set(),
- }));
- const wordQ = shuffle([...dictationItems, ...posItems]);
+ })));
- const sentCands = buildSentenceCandidates(other);
+ const sentCands = sentenceCandidates(words);
const sentQ = sentCands.map(w => ({
id: w.id,
target: w,
blanked: blankWordInSentence(w.example_sentence, w.word),
answer: w.word,
- options: buildSentenceOptions(w, other, new Set()),
+ options: buildSentenceOptions(w, words, new Set()),
avoidWord: new Set(),
})).filter(s => s.options.length >= 2);
- setWordQueue(wordQ);
- setWrongWords([]);
- setWordIndex(0);
+ setSpellQueue(spellItems);
+ setWrongSpell([]);
+ setSpellIndex(0);
+ setPosQueue(posItems);
+ setWrongPos([]);
+ setPosIndex(0);
setSentenceQueue(sentQ);
setWrongSentences([]);
setSentenceIndex(0);
resetInputs();
- setPhase('word');
+ setPhase('spell');
}, [words, resetInputs]);
// 自动聚焦
useEffect(() => {
- if (phase === 'word' && current && current.mode === 'dictation' && inputRef.current) {
+ if (phase === 'spell' && current && current.mode === 'dictation' && inputRef.current) {
inputRef.current.focus();
}
if (phase === 'copy' && copyRef.current) {
copyRef.current.focus();
}
- if (phase === 'word' && current && current.mode === 'pos' && chineseRef.current) {
- // pos 选择后才有中文输入框,这里暂不聚焦
- }
}, [phase, current]);
- // 朗读:通过后端 TTS 代理播放
const speak = useCallback((text) => {
if (!text) return;
const audio = new Audio(api.ttsUrl(text));
const answer = userInput.trim().toLowerCase();
const ok = answer === correct;
setIsCorrect(ok);
- setPhase('word_fb');
+ setPhase('spell_fb');
speak(current.word);
if (!ok) {
- setWrongWords(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+ setWrongSpell(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
}
}, [current, userInput, speak]);
const ok = opt === correct;
setPosChoice(opt);
setIsCorrect(ok);
- setPhase('word_fb');
+ setPhase('pos_fb');
speak(current.word);
if (!ok) {
- current.avoidPos.add(opt); // 记录错误混淆项,重考时换掉(需求 5)
- setWrongWords(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
+ current.avoidPos.add(opt);
+ setWrongPos(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
}
}, [current, speak]);
setCompareResult(!!(user && (correct.includes(user) || user.includes(correct))));
}, [current, chineseRecall]);
- // 显示抄写区(默写错误后纠正)
const showCopy = useCallback(() => {
setCopyInput('');
setPhase('copy');
}, []);
- // 进入下一词
- const nextWord = useCallback(() => {
- if (wordIndex + 1 < wordQueue.length) {
- setWordIndex(prev => prev + 1);
- resetInputs();
- setPhase('word');
- } else {
- // 本轮结束
- if (wrongWords.length > 0) {
- const rq = shuffle(wrongWords.map(w => w.mode === 'pos'
- ? { ...w, posOptions: buildPosOptions(w, posPoolRef.current, w.avoidPos) }
- : { ...w }));
- setWordQueue(rq);
- setWrongWords([]);
- setWordIndex(0);
- resetInputs();
- setPhase('word');
- } else {
- // 单词环节全部通过 → 进入句子填空环节
- if (sentenceQueue.length > 0) {
- setSentenceIndex(0);
- resetInputs();
- setPhase('sentence');
- } else {
- setPhase('complete');
- }
- }
- }
- }, [wordIndex, wordQueue.length, wrongWords, sentenceQueue, resetInputs]);
-
// 句子填空选择
const handleSentenceSelect = useCallback((opt) => {
if (!current) return;
setIsCorrect(ok);
setPhase('sentence_fb');
if (!ok) {
- current.avoidWord.add(opt); // 记录错误混淆项,重考时换掉(需求 5)
+ current.avoidWord.add(opt);
setWrongSentences(prev => prev.find(w => w.id === current.id) ? prev : [...prev, current]);
}
}, [current]);
- // 句子环节进入下一题
+ // 阶段①推进:拼写全部通过 → 进入阶段②选词性
+ const nextSpell = useCallback(() => {
+ if (spellIndex + 1 < spellQueue.length) {
+ setSpellIndex(prev => prev + 1);
+ resetInputs();
+ setPhase('spell');
+ } else if (wrongSpell.length > 0) {
+ const rq = shuffle(wrongSpell.map(w => ({ ...w })));
+ setSpellQueue(rq);
+ setWrongSpell([]);
+ setSpellIndex(0);
+ resetInputs();
+ setPhase('spell');
+ } else if (posQueue.length > 0) {
+ setPosIndex(0);
+ resetInputs();
+ setPhase('pos');
+ } else if (sentenceQueue.length > 0) {
+ setSentenceIndex(0);
+ resetInputs();
+ setPhase('sentence');
+ } else {
+ setPhase('complete');
+ }
+ }, [spellIndex, spellQueue.length, wrongSpell, posQueue.length, sentenceQueue.length, resetInputs]);
+
+ // 阶段②推进:选词性全部通过 → 进入阶段③句子填空
+ const nextPos = useCallback(() => {
+ if (posIndex + 1 < posQueue.length) {
+ setPosIndex(prev => prev + 1);
+ resetInputs();
+ setPhase('pos');
+ } else if (wrongPos.length > 0) {
+ const rq = shuffle(wrongPos.map(w => ({
+ ...w,
+ posOptions: buildPosOptions(w, posPoolRef.current, w.avoidPos),
+ })));
+ setPosQueue(rq);
+ setWrongPos([]);
+ setPosIndex(0);
+ resetInputs();
+ setPhase('pos');
+ } else if (sentenceQueue.length > 0) {
+ setSentenceIndex(0);
+ resetInputs();
+ setPhase('sentence');
+ } else {
+ setPhase('complete');
+ }
+ }, [posIndex, posQueue.length, wrongPos, sentenceQueue.length, resetInputs]);
+
+ // 阶段③推进:句子填空全部通过 → 完成
const nextSentence = useCallback(() => {
if (sentenceIndex + 1 < sentenceQueue.length) {
setSentenceIndex(prev => prev + 1);
resetInputs();
setPhase('sentence');
+ } else if (wrongSentences.length > 0) {
+ const rq = shuffle(wrongSentences.map(s => ({
+ ...s,
+ options: buildSentenceOptions(s.target, words, s.avoidWord),
+ })));
+ setSentenceQueue(rq);
+ setWrongSentences([]);
+ setSentenceIndex(0);
+ resetInputs();
+ setPhase('sentence');
} else {
- if (wrongSentences.length > 0) {
- const rq = shuffle(wrongSentences.map(s => ({
- ...s,
- options: buildSentenceOptions(s.target, otherRecognizeRef.current, s.avoidWord),
- })));
- setSentenceQueue(rq);
- setWrongSentences([]);
- setSentenceIndex(0);
- resetInputs();
- setPhase('sentence');
- } else {
- setPhase('complete');
- }
+ setPhase('complete');
}
- }, [sentenceIndex, sentenceQueue.length, wrongSentences, resetInputs]);
+ }, [sentenceIndex, sentenceQueue.length, wrongSentences, words, resetInputs]);
- // 完成:全部环节全对才记录进度(需求 8)
+ // 完成:全部阶段全对才记录进度
const finishTest = useCallback(async () => {
if (submittedRef.current) return;
submittedRef.current = true;
// 键盘事件
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter') {
- if (phase === 'word' && current && current.mode === 'dictation') {
+ if (phase === 'spell' && current && current.mode === 'dictation') {
checkAnswer();
- } else if (phase === 'word_fb' && isCorrect) {
- nextWord();
- } else if (phase === 'word_fb' && current && current.mode === 'dictation' && !isCorrect) {
+ } else if (phase === 'spell_fb' && isCorrect) {
+ nextSpell();
+ } else if (phase === 'spell_fb' && current && current.mode === 'dictation' && !isCorrect) {
showCopy();
} else if (phase === 'copy' && copyInput.toLowerCase() === (current ? (current.word || '').toLowerCase() : '')) {
- nextWord();
- } else if (phase === 'word_fb' && current && current.mode === 'pos') {
- nextWord();
+ nextSpell();
+ } else if (phase === 'pos_fb') {
+ nextPos();
} else if (phase === 'sentence_fb') {
nextSentence();
}
}
- }, [phase, isCorrect, current, checkAnswer, nextWord, showCopy, copyInput, nextSentence]);
+ }, [phase, isCorrect, current, checkAnswer, nextSpell, showCopy, copyInput, nextPos, nextSentence]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
- // 阻止复制/剪切/粘贴/右键,防止作弊
const blockClipboard = (e) => e.preventDefault();
-
- // 默写输入:仅保留 ASCII 可见字符并转小写(屏蔽中文/全角/大写)
const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
-
const inputProps = {
type: "text",
lang: "en",
onCompositionStart: blockClipboard,
};
- // 准备阶段
if (words.length === 0) return <div className="loading">加载中...</div>;
if (phase === 'ready') {
- const { spell, recite, other } = getCategories(words);
- const dictCount = spell.length + recite.length;
- const posCount = other.length;
- const sentCount = buildSentenceCandidates(other).length;
+ const spell = words.filter(w => (w.memory_type || 'spell') === 'spell');
+ const recognize = words.filter(w => (w.memory_type || '') === 'recognize');
+ const sentCount = sentenceCandidates(words).length;
return (
<div className="test-container" onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
<div className="test-card test-start-card">
<h1>单元测试 - Group {groupId}</h1>
<p className="test-info">共 {words.length} 个单词</p>
<p className="test-desc">
- 拼写/需背诵词({dictCount})默写;其余识别词({posCount})选词性并自测中文;
- 最后 {sentCount} 道句子填空选择题。全部答对才算完成。
+ 顺序:① 拼写默写({spell.length} 个重点词)→ ② 选择词性({recognize.length} 个识别词)→ ③ 句子填空({sentCount} 道)。全部答对才算完成。
</p>
<button className="action-btn start-btn" onClick={startTest}>开始测试</button>
<button className="back-home" onClick={() => navigate('/')}>返回首页</button>
<div className="test-container" onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
<div className="test-card">
<div className="test-header">
- <span className="test-status">句子填空 - Group {groupId}</span>
+ <span className="test-status">③ 句子填空 - Group {groupId}</span>
<span className="test-progress">{sentenceIndex + 1} / {sentenceQueue.length}</span>
</div>
<div className="test-body">
);
}
- // å\8d\95è¯\8dç\8e¯è\8a\82ï¼\88é»\98å\86\99 / 选词性)
+ // å\8d\95è¯\8dç\8e¯è\8a\82ï¼\88é\98¶æ®µâ\91 æ\8b¼å\86\99 / é\98¶æ®µâ\91¡选词性)
const isPos = current.mode === 'pos';
+ const isSpell = current.mode === 'dictation';
+ const headerLabel = isPos ? '② 选择词性' : '① 拼写默写';
+ const progressText = isPos
+ ? `${posIndex + 1} / ${posQueue.length}`
+ : `${spellIndex + 1} / ${spellQueue.length}`;
+
return (
<div className="test-container" onCopy={blockClipboard} onCut={blockClipboard} onPaste={blockClipboard} onContextMenu={blockClipboard}>
<div className="test-card">
<div className="test-header">
- <span className="test-status">
- {isPos ? '选词性' : '默写'} - Group {groupId}
- </span>
- <span className="test-progress">{wordIndex + 1} / {wordQueue.length}</span>
+ <span className="test-status">{headerLabel} - Group {groupId}</span>
+ <span className="test-progress">{progressText}</span>
</div>
<div className="test-body">
- {/* 答题中 */}
- {phase === 'word' && !isPos && (
+ {/* 阶段①:默写 */}
+ {phase === 'spell' && isSpell && (
<div className="testing-area">
<div className="prompt-info">
<p className="prompt-chinese">{current.chinese_meaning}</p>
</div>
)}
- {phase === 'word' && isPos && (
+ {/* 阶段②:选词性 */}
+ {phase === 'spell' && isPos && (
<div className="testing-area">
<div className="prompt-info">
<p className="prompt-word-en">{current.word}</p>
)}
{/* 反馈区 */}
- {(phase === 'word_fb' || phase === 'copy') && (
+ {(phase === 'spell_fb' || phase === 'copy') && (
<div className="feedback-wrapper">
<div className="feedback-main">
<div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
{current.affix && (<div className="fb-affix">组成: {current.affix}</div>)}
{current.example_sentence && (<div className="fb-sentence">例句: {current.example_sentence}</div>)}
- {/* 选词性环节:自测回忆中文意思(仅自测,不阻塞) */}
{isPos && (
<div className="chinese-recall">
<p className="recall-tip">自己回忆的中文意思是否一致?(仅自测)</p>
</div>
)}
- {!isCorrect && phase === 'word_fb' && !isPos && (
+ {!isCorrect && phase === 'spell_fb' && isSpell && (
<button className="action-btn copy-trigger" onClick={showCopy}>抄写纠正 (Enter)</button>
)}
{(isCorrect || isPos) && (
- <button className="action-btn" onClick={nextWord}>下一词 (Enter)</button>
+ <button className="action-btn" onClick={isPos ? nextPos : nextSpell}>下一词 (Enter)</button>
)}
</div>
{copyInput.toLowerCase() === (current.word || '').toLowerCase() ? '✅' : '❌'}
</span>
{copyInput.toLowerCase() === (current.word || '').toLowerCase() && (
- <button className="action-btn" onClick={nextWord}>下一词 (Enter)</button>
+ <button className="action-btn" onClick={nextSpell}>下一词 (Enter)</button>
)}
</div>
)}