/**
* vocab_drill_group:一组词的五阶段训练。
*
- * ① learn 学词(顺序,自动播 单词→例句→搭配)
+ * ① learn 学词(顺序):看词卡 → 点「下一步」进入闪记(给中文+词性写英文)
+ * → 答对进下一词,答错退回词卡重看,再点下一步重新闪记,直到答对
* ② spell 拼写(乱序,听音写词)
* ③ choice 选择(乱序,四选一中文释义)
* ④ dictation 听写(仅 is_key 的 10 词,只放音不显示词)
const [options, setOptions] = useState([])
const [revealed, setRevealed] = useState(false) // learn 阶段是否已看释义
const [looping, setLooping] = useState(false) // 循环播放(听学模式)
+ // 语音开关:默认关闭,打开后才在新词进入时自动播报
+ const [autoPlay, setAutoPlay] = useState(false)
+ // 学词阶段内部子阶段:'learn' 看词卡 / 'flash' 闪记(给中文写英文)
+ const [phase, setPhase] = useState('learn')
+ const [flashInput, setFlashInput] = useState('')
+ const [flashWrong, setFlashWrong] = useState(null) // {input, answer}
const startedAt = useRef(Date.now())
const inputRef = useRef(null)
: stage === 'dictation' ? dictationOrder.length
: pool.length
- // 进入每题时重置输入态
+ // 进入每题时重置输入态(闪记子阶段只切 phase,不触发这里,故其输入单独清空)
useEffect(() => {
setInput('')
setFeedback(null)
setRevealed(false)
+ setPhase('learn')
+ setFlashInput('')
+ setFlashWrong(null)
if (stage === 'choice' && choiceOrder[idx]) {
const target = choiceOrder[idx]
const distractors = shuffle(
}
}, [stage, idx, choiceOrder, words])
- // learn 阶段进入新词自动播放序列
+ // 学词阶段:仅在语音开关打开且处于看词卡子阶段时自动播报
useEffect(() => {
- if (stage === 'learn' && words[idx]) {
+ if (stage === 'learn' && phase === 'learn' && autoPlay && words[idx]) {
playSeq(buildLearnSeq(words[idx]))
}
- }, [stage, idx, words])
+ }, [stage, phase, idx, words, autoPlay])
// dictation 进入即放音(不显示词)
useEffect(() => {
playSeq(stage === 'learn' ? buildLearnSeq(current) : [{ text: current.word, lang: 'en' }])
}
+ /** 学词阶段推进:本词过关则下一个,全部过完进入拼写阶段。 */
+ const advanceLearnWord = () => {
+ setFlashInput('')
+ setFlashWrong(null)
+ setPhase('learn')
+ if (idx + 1 < words.length) {
+ setIdx(idx + 1)
+ } else {
+ nextStage('learn')
+ }
+ }
+
+ /** 闪记提交:答对进下一词,答错退回词卡重看,再点下一步重新闪记。 */
+ const submitFlash = () => {
+ const w = current
+ if (!w || !flashInput.trim()) return
+ if (flashInput.trim().toLowerCase() === w.word.toLowerCase()) {
+ advanceLearnWord()
+ } else {
+ setFlashWrong({ input: flashInput, answer: w.word })
+ setPhase('learn')
+ setFlashInput('')
+ }
+ }
+
if (!words.length) return <div className="empty">本课没有词条。</div>
// ---------- 各阶段渲染 ----------
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--muted)' }}>
错题池 {pool.length}
</span>
+ <button
+ className="btn ghost sm"
+ onClick={() => setAutoPlay((v) => !v)}
+ title="打开后进入新词自动播报;关闭则自己看,需要时点「重听」"
+ >
+ {autoPlay ? '🔊 语音开' : '🔇 语音关'}
+ </button>
<button
className="btn ghost sm"
onClick={() => setShowPhonetic((v) => !v)}
const renderLearn = () => {
const w = current
+
+ // 闪记子阶段:给出中文与词性,要求写英文
+ if (phase === 'flash') {
+ return (
+ <div>
+ <div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 10 }}>
+ 闪记 · 第 {idx + 1} / {words.length} 词 · 看中文写英文
+ </div>
+ <div className="card pad" style={{ textAlign: 'center', background: '#fbfdff' }}>
+ <div style={{ fontSize: 15, color: 'var(--muted)' }}>{w.pos}</div>
+ <div
+ style={{
+ fontSize: 20, color: 'var(--text)', margin: '6px 0 16px', lineHeight: 1.5,
+ }}
+ >
+ {w.meaning}
+ </div>
+ <input
+ autoFocus
+ value={flashInput}
+ // 只保留 ASCII,避免中文输入法混入
+ onChange={(e) => setFlashInput(e.target.value.replace(/[^\x20-\x7E]/g, ''))}
+ onKeyDown={(e) => e.key === 'Enter' && submitFlash()}
+ onPaste={(e) => e.preventDefault()} // 禁止粘贴,防止照抄
+ placeholder="输入英文单词后回车"
+ style={{
+ width: '100%', padding: '12px 14px', fontSize: 16, textAlign: 'center',
+ border: '1px solid var(--line)', borderRadius: 10, fontFamily: 'inherit',
+ }}
+ />
+ </div>
+ </div>
+ )
+ }
+
return (
<div>
<div style={{ fontSize: 12.5, color: 'var(--muted)', marginBottom: 10 }}>
- 第 {idx + 1} / {words.length} 词 · 自动播放 单词 → 例句 → 搭配
+ 第 {idx + 1} / {words.length} 词 ·{' '}
+ {autoPlay ? '自动播放 单词 → 例句 → 搭配' : '语音已关闭,点「重听」可播放'}
</div>
<div className="card pad" style={{ textAlign: 'center', background: '#fbfdff' }}>
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--primary-d)' }}>{w.word}</div>
{showPhonetic && w.phonetic && (
<div style={{ fontSize: 13, color: 'var(--muted)' }}>{w.phonetic}</div>
)}
- <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>
+ <div style={{ fontSize: 15, color: 'var(--muted)', marginTop: 2 }}>
{w.pos} {w.meaning}
</div>
{w.example && (
- <div style={{ fontSize: 13, marginTop: 12, color: '#33465a', lineHeight: 1.8 }}>
+ <div style={{ fontSize: 15, marginTop: 12, color: '#33465a', lineHeight: 1.8 }}>
{w.example}
- <div style={{ fontSize: 12, color: 'var(--muted)' }}>{w.example_zh}</div>
+ <div style={{ fontSize: 14, color: 'var(--muted)' }}>{w.example_zh}</div>
</div>
)}
{w.collocations?.length > 0 && (
- <div style={{ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center' }}>
+ <div
+ style={{
+ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center',
+ }}
+ >
{w.collocations.map((c, i) => (
- <span className="tag" key={i}>
+ <span className="tag" key={i} style={{ fontSize: 13.5 }}>
{c.en} · {c.zh}
</span>
))}
</div>
)}
</div>
+ {flashWrong && (
+ <div
+ style={{
+ marginTop: 10, padding: '10px 14px', borderRadius: 9, fontSize: 13.5,
+ background: 'var(--amber-l)', color: 'var(--amber)',
+ }}
+ >
+ 刚才拼错了,正确拼写是 {flashWrong.answer}
+ <SpellDiff input={flashWrong.input} answer={flashWrong.answer} />
+ </div>
+ )}
</div>
)
}
const isLast = idx + 1 >= total
const canSubmit = !feedback && stage !== 'learn' && stage !== 'error_redo'
-
+ const isFlash = stage === 'learn' && phase === 'flash'
+ const isLearnCard = stage === 'learn' && phase === 'learn'
+
return (
<div>
{renderStageNav()}
{body}
<div style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'center' }}>
- {stage !== 'error_redo' && (
+ {/* 闪记阶段不提供重听,否则播出读音等于直接给答案 */}
+ {stage !== 'error_redo' && !isFlash && (
<button className="btn ghost sm" onClick={replay}>
▶ 重听
</button>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
- {stage === 'error_redo' && pool.length > 0 && (
- <button className="btn" onClick={submitRedo} disabled={!input}>
- 提交
- </button>
- )}
- {canSubmit && (
+ {isLearnCard && (
<button
className="btn"
- onClick={() =>
- stage === 'choice' ? submitChoice(input) : submitSpell(stage === 'dictation' ? 'dictation' : 'spell')
- }
- disabled={!input}
+ onClick={() => {
+ setFlashInput('')
+ setPhase('flash')
+ }}
>
+ 下一步 · 闪记
+ </button>
+ )}
+ {isFlash && (
+ <button className="btn" onClick={submitFlash} disabled={!flashInput.trim()}>
提交
</button>
)}
- <button
- className="btn"
- onClick={advance}
- disabled={stage !== 'learn' && !feedback && stage !== 'error_redo'}
- >
- {isLast && stage !== 'error_redo' ? '下一阶段' : '下一个'}
- </button>
+ {!isLearnCard && !isFlash && (
+ <>
+ {stage === 'error_redo' && pool.length > 0 && (
+ <button className="btn" onClick={submitRedo} disabled={!input}>
+ 提交
+ </button>
+ )}
+ {canSubmit && (
+ <button
+ className="btn"
+ onClick={() =>
+ stage === 'choice'
+ ? submitChoice(input)
+ : submitSpell(stage === 'dictation' ? 'dictation' : 'spell')
+ }
+ disabled={!input}
+ >
+ 提交
+ </button>
+ )}
+ <button
+ className="btn"
+ onClick={advance}
+ disabled={stage !== 'learn' && !feedback && stage !== 'error_redo'}
+ >
+ {isLast && stage !== 'error_redo' ? '下一阶段' : '下一个'}
+ </button>
+ </>
+ )}
</div>
</div>
</div>