From: CodeBuddy Date: Thu, 20 Aug 2026 23:19:45 +0000 (+0800) Subject: refactor(v2): 测试/复习改为三阶段顺序(拼写→选词性→句子填空) X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=be2525fb311015a516a101203fda071ae68d5ee2;p=words.git refactor(v2): 测试/复习改为三阶段顺序(拼写→选词性→句子填空) - 重点/拼写词整体在最前:阶段①拼写默写 → 阶段②识别词选词性 → 阶段③句子填空 - 句子挖空词取自重点+非重点全部词(现有 example_sentence),干扰项取自同组词 - 各阶段选错均重排并换掉错误混淆项,直到全对才记录完成 - 第2条(中文词义混合词性选择)、第3条(ChatGPT 例句)本次暂不实现 --- diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js index 5864b51..d9f8fda 100644 --- a/frontend/src/components/TestMode.js +++ b/frontend/src/components/TestMode.js @@ -22,18 +22,17 @@ function escapeRegExp(s) { 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, '______'); } -// 为某词生成 4 个词性选项(含正确项)。avoidSet 中的错误项将被替换掉(需求 5:换掉错误混淆项并重新排序) +// 为某词生成 4 个词性选项(含正确项)。avoidSet 中的错误项将被替换掉(选错换项并重新排序) 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) { @@ -44,7 +43,7 @@ function buildPosOptions(word, posPool, avoidSet) { return shuffle([correct, ...distractors]); } -// 为句子填空生成选项:正确词 + 同组非背诵干扰词。avoidSet 中的错误项将被替换 +// 为句子填空生成选项:正确词 + 同组干扰词。avoidSet 中的错误项将被替换 function buildSentenceOptions(target, candidateWords, avoidSet) { const correct = target.word; let distractors = shuffle( @@ -67,51 +66,44 @@ function normZh(s) { 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(''); @@ -119,14 +111,14 @@ function TestMode() { 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(() => { @@ -139,7 +131,7 @@ function TestMode() { }); }, [isOnline, groupId, navigate]); - // 获取单词数据 (在线走网络, 离线降级读 IndexedDB) + // 获取单词数据 useEffect(() => { api.getWords(groupId) .then(res => setWords(res.data)) @@ -149,7 +141,7 @@ function TestMode() { }); }, [groupId]); - // 清空所有交互输入 + // 清空交互输入 const resetInputs = useCallback(() => { setUserInput(''); setCopyInput(''); @@ -160,59 +152,57 @@ function TestMode() { 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)); @@ -226,10 +216,10 @@ function TestMode() { 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]); @@ -240,11 +230,11 @@ function TestMode() { 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]); @@ -256,42 +246,11 @@ function TestMode() { 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; @@ -300,35 +259,84 @@ function TestMode() { 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; @@ -342,33 +350,29 @@ function TestMode() { // 键盘事件 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", @@ -382,22 +386,19 @@ function TestMode() { onCompositionStart: blockClipboard, }; - // 准备阶段 if (words.length === 0) return
加载中...
; 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 (

单元测试 - Group {groupId}

共 {words.length} 个单词

- 拼写/需背诵词({dictCount})默写;其余识别词({posCount})选词性并自测中文; - 最后 {sentCount} 道句子填空选择题。全部答对才算完成。 + 顺序:① 拼写默写({spell.length} 个重点词)→ ② 选择词性({recognize.length} 个识别词)→ ③ 句子填空({sentCount} 道)。全部答对才算完成。

@@ -427,7 +428,7 @@ function TestMode() {
- 句子填空 - Group {groupId} + ③ 句子填空 - Group {groupId} {sentenceIndex + 1} / {sentenceQueue.length}
@@ -464,21 +465,25 @@ function TestMode() { ); } - // 单词环节(默写 / 选词性) + // 单词环节(阶段①拼写 / 阶段②选词性) 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 (
- - {isPos ? '选词性' : '默写'} - Group {groupId} - - {wordIndex + 1} / {wordQueue.length} + {headerLabel} - Group {groupId} + {progressText}
- {/* 答题中 */} - {phase === 'word' && !isPos && ( + {/* 阶段①:默写 */} + {phase === 'spell' && isSpell && (

{current.chinese_meaning}

@@ -498,7 +503,8 @@ function TestMode() {
)} - {phase === 'word' && isPos && ( + {/* 阶段②:选词性 */} + {phase === 'spell' && isPos && (

{current.word}

@@ -518,7 +524,7 @@ function TestMode() { )} {/* 反馈区 */} - {(phase === 'word_fb' || phase === 'copy') && ( + {(phase === 'spell_fb' || phase === 'copy') && (
@@ -533,7 +539,6 @@ function TestMode() { {current.affix && (
组成: {current.affix}
)} {current.example_sentence && (
例句: {current.example_sentence}
)} - {/* 选词性环节:自测回忆中文意思(仅自测,不阻塞) */} {isPos && (

自己回忆的中文意思是否一致?(仅自测)

@@ -557,12 +562,12 @@ function TestMode() {
)} - {!isCorrect && phase === 'word_fb' && !isPos && ( + {!isCorrect && phase === 'spell_fb' && isSpell && ( )} {(isCorrect || isPos) && ( - + )}
@@ -590,7 +595,7 @@ function TestMode() { {copyInput.toLowerCase() === (current.word || '').toLowerCase() ? '✅' : '❌'} {copyInput.toLowerCase() === (current.word || '').toLowerCase() && ( - + )}
)}