]> acesimba.cloud Git - study-mountion.git/commitdiff
学词阶段:语音开关 + 闪记模式 + 字号调整
authorstudyhill <studyhill@local>
Sat, 29 Aug 2026 11:36:53 +0000 (19:36 +0800)
committerstudyhill <studyhill@local>
Sat, 29 Aug 2026 11:36:53 +0000 (19:36 +0800)
- 语音开关默认关闭,打开后才在新词进入时自动播报;关闭时纯自己看,
  需要发音随时点「重听」
- 新增闪记子阶段:看词卡 → 点「下一步 · 闪记」→ 给词性与中文写英文,
  答对进下一词,答错退回词卡重看并逐字母标红,再点下一步重新闪记
- 闪记输入框禁粘贴、只接受 ASCII(参考 words 工程),防照抄与中文输入法混入
- 闪记阶段隐藏「重听」,否则播出读音等于直接给答案
- 中文释义 13→15、例句 13→15、例句中文 12→14、搭配 11.5→13.5

frontend/src/renderers/VocabDrill.jsx

index 73ee170177241ed5bd7b30381e08aa353423fdc3..a425a8ebcbcac22f275bfd500575bf370afb250c 100644 (file)
@@ -5,7 +5,8 @@ import LoopPlayer from '../components/LoopPlayer'
 /**
  * vocab_drill_group:一组词的五阶段训练。
  *
- * ① learn 学词(顺序,自动播 单词→例句→搭配)
+ * ① learn 学词(顺序):看词卡 → 点「下一步」进入闪记(给中文+词性写英文)
+ *      → 答对进下一词,答错退回词卡重看,再点下一步重新闪记,直到答对
  * ② spell 拼写(乱序,听音写词)
  * ③ choice 选择(乱序,四选一中文释义)
  * ④ dictation 听写(仅 is_key 的 10 词,只放音不显示词)
@@ -84,6 +85,12 @@ export default function VocabDrill({ payload, taskType = 'new', onReadyChange })
   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)
 
@@ -106,11 +113,14 @@ export default function VocabDrill({ payload, taskType = 'new', onReadyChange })
     : 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(
@@ -121,12 +131,12 @@ export default function VocabDrill({ payload, taskType = 'new', onReadyChange })
     }
   }, [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(() => {
@@ -231,6 +241,31 @@ export default function VocabDrill({ payload, taskType = 'new', onReadyChange })
     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>
 
   // ---------- 各阶段渲染 ----------
@@ -253,6 +288,13 @@ export default function VocabDrill({ payload, taskType = 'new', onReadyChange })
       <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)}
@@ -268,35 +310,86 @@ export default function VocabDrill({ payload, taskType = 'new', onReadyChange })
 
   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>
     )
   }
@@ -463,42 +556,67 @@ export default function VocabDrill({ payload, taskType = 'new', onReadyChange })
 
   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>