]> acesimba.cloud Git - words.git/commitdiff
测试模式初版,学习模式问题修复,by codebuddy
authorroot <root@localhost.localdomain>
Sun, 7 Jun 2026 14:37:51 +0000 (22:37 +0800)
committerroot <root@localhost.localdomain>
Sun, 7 Jun 2026 14:37:51 +0000 (22:37 +0800)
backend/main.py
frontend/src/App.js
frontend/src/components/Home.js
frontend/src/components/StudyMode.js
frontend/src/components/TestMode.css [new file with mode: 0644]
frontend/src/components/TestMode.js [new file with mode: 0644]

index d18b5d8a3db6c02cf766e26b9d0e149274a72f8e..e6d47537b490824602a2a6fc4f56ee4f711f070c 100644 (file)
@@ -198,7 +198,7 @@ async def get_picture(pic_id: int):
         raise HTTPException(status_code=500, detail=str(e))
 
 @app.post("/api/progress/update")
-async def update_progress(ProgressUpdate):
+async def update_progress(data: ProgressUpdate):
     try:
         conn = sqlite3.connect(DATABASE_PATH)
         cursor = conn.cursor()
index 9448d701e8ce84441b71c784d6fb8731c52b3ba6..8d74f68c7bdb7036567bf4ef88f1de3dbe1f7708 100644 (file)
@@ -2,6 +2,7 @@ import React from 'react';
 import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
 import Home from './components/Home';
 import StudyMode from './components/StudyMode';
+import TestMode from './components/TestMode';
 
 function App() {
     return (
@@ -9,6 +10,7 @@ function App() {
             <Routes>
                 <Route path="/" element={<Home />} />
                 <Route path="/study/:groupId" element={<StudyMode />} />
+                <Route path="/test/:groupId" element={<TestMode />} />
             </Routes>
         </Router>
     );
index f9bc7fa3106da30b99df2acebf074b7ca4377f4e..2ea0518cdac083b60130c74d9a1ba78e23094cdc 100644 (file)
@@ -46,10 +46,21 @@ function Home() {
 
     const handleCellClick = (item, type) => {
         const groupId = item.group.replace('Group ', '');
-        if (type === 'study' && item.study.includes('请开始学习')) {
+        if (type === 'study' && item.study && item.study.includes('请开始学习')) {
             navigate(`/study/${groupId}`);
         }
-        // 后续可以在这里添加 test 和 review 的跳转逻辑
+        if (type === 'test' && item.test && item.test.includes('请开始测试')) {
+            navigate(`/test/${groupId}`);
+        }
+        if (type === 'review1' && item.review1 && (item.review1.includes('请开始复习') || item.review1.includes('请尽快复习'))) {
+            navigate(`/test/${groupId}`);
+        }
+        if (type === 'review2' && item.review2 && (item.review2.includes('请开始复习') || item.review2.includes('请尽快复习'))) {
+            navigate(`/test/${groupId}`);
+        }
+        if (type === 'review3' && item.review3 && (item.review3.includes('请开始复习') || item.review3.includes('请尽快复习'))) {
+            navigate(`/test/${groupId}`);
+        }
     };
 
     return (
@@ -90,10 +101,34 @@ function Home() {
                                 >
                                     {item.study}
                                 </td>
-                                <td className={getStatusClass(item.test)}>{item.test}</td>
-                                <td className={getStatusClass(item.review1)}>{item.review1}</td>
-                                <td className={getStatusClass(item.review2)}>{item.review2}</td>
-                                <td className={getStatusClass(item.review3)}>{item.review3}</td>
+                                <td
+                                    className={getStatusClass(item.test)}
+                                    onClick={() => handleCellClick(item, 'test')}
+                                    style={{ cursor: item.test && item.test.includes('请开始') ? 'pointer' : 'default' }}
+                                >
+                                    {item.test}
+                                </td>
+                                <td
+                                    className={getStatusClass(item.review1)}
+                                    onClick={() => handleCellClick(item, 'review1')}
+                                    style={{ cursor: item.review1 && (item.review1.includes('请开始') || item.review1.includes('请尽快')) ? 'pointer' : 'default' }}
+                                >
+                                    {item.review1}
+                                </td>
+                                <td
+                                    className={getStatusClass(item.review2)}
+                                    onClick={() => handleCellClick(item, 'review2')}
+                                    style={{ cursor: item.review2 && (item.review2.includes('请开始') || item.review2.includes('请尽快')) ? 'pointer' : 'default' }}
+                                >
+                                    {item.review2}
+                                </td>
+                                <td
+                                    className={getStatusClass(item.review3)}
+                                    onClick={() => handleCellClick(item, 'review3')}
+                                    style={{ cursor: item.review3 && (item.review3.includes('请开始') || item.review3.includes('请尽快')) ? 'pointer' : 'default' }}
+                                >
+                                    {item.review3}
+                                </td>
                             </tr>
                         ))}
                     </tbody>
index 463fab427b4712a1df4ddcecf41bc3885bb56f10..ffc9a14474b8b8f0d49e827e90edfb8029f7d00b 100644 (file)
@@ -13,8 +13,9 @@ function StudyMode() {
     const [phase, setPhase] = useState('learning'); // learning, flashcard, correction
     const [userInput, setUserInput] = useState("");
     const [correctionInput, setCorrectionInput] = useState("");
-    
+
     const hasSpokenRef = useRef(null);
+    const transitioningRef = useRef(false);
     const currentWord = words[currentIndex];
 
     // 朗读逻辑:语速 0.6,间隔 500ms
@@ -55,6 +56,7 @@ function StudyMode() {
     // 全局键盘监听:在学习模式下按回车进入闪记
     useEffect(() => {
         const handleGlobalKeyDown = (event) => {
+            if (transitioningRef.current) return;
             if (phase === 'learning' && event.key === 'Enter') {
                 setPhase('flashcard');
             }
@@ -64,11 +66,13 @@ function StudyMode() {
     }, [phase]);
 
     const handleNext = async () => {
+        transitioningRef.current = true;
         if (currentIndex < words.length - 1) {
             setCurrentIndex(currentIndex + 1);
             setPhase('learning');
             setUserInput("");
             setCorrectionInput("");
+            setTimeout(() => { transitioningRef.current = false; }, 300);
         } else {
             await axios.post(`${API_BASE}/api/progress/update`, {
                 group_id: groupId,
diff --git a/frontend/src/components/TestMode.css b/frontend/src/components/TestMode.css
new file mode 100644 (file)
index 0000000..1802b83
--- /dev/null
@@ -0,0 +1,313 @@
+/* /home/words/frontend/src/components/TestMode.css */
+
+.test-container {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 20px;
+    background-color: #fef9f0;
+    min-height: 100vh;
+}
+
+.test-card {
+    background: white;
+    border-radius: 20px;
+    box-shadow: 0 10px 30px rgba(0,0,0,0.1);
+    width: 95%;
+    max-width: 1100px;
+    min-height: 500px;
+    overflow: hidden;
+}
+
+.test-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 20px 30px;
+    background: #f8f9fa;
+    border-bottom: 1px solid #e9ecef;
+}
+
+.test-status {
+    font-size: 20px;
+    font-weight: bold;
+    color: #495057;
+}
+
+.test-progress {
+    font-size: 18px;
+    color: #868e96;
+    font-weight: 500;
+}
+
+.test-body {
+    padding: 30px 40px;
+}
+
+/* 开始/完成/重考提示卡片 */
+.test-start-card,
+.test-complete-card,
+.test-retest-card {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    min-height: 400px;
+    text-align: center;
+}
+
+.test-start-card h1,
+.test-complete-card h1,
+.test-retest-card h1 {
+    font-size: 36px;
+    color: #2c3e50;
+    margin-bottom: 15px;
+}
+
+.test-info {
+    font-size: 22px;
+    color: #64748b;
+    margin-bottom: 10px;
+}
+
+.test-desc {
+    font-size: 16px;
+    color: #94a3b8;
+    margin-bottom: 30px;
+}
+
+.start-btn {
+    background: #e67e22;
+}
+
+.start-btn:hover {
+    background: #d35400;
+}
+
+/* 答题区域 */
+.testing-area {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 40px 0;
+}
+
+.prompt-info {
+    text-align: center;
+    margin-bottom: 30px;
+}
+
+.prompt-chinese {
+    font-size: 42px;
+    font-weight: bold;
+    color: #1e293b;
+}
+
+.prompt-pos {
+    font-size: 22px;
+    color: #1e40af;
+    margin-top: 8px;
+}
+
+.input-row {
+    display: flex;
+    align-items: center;
+    gap: 15px;
+}
+
+.test-input {
+    width: 400px;
+    padding: 15px;
+    font-size: 24px;
+    border: 2px solid #ddd;
+    border-radius: 10px;
+    outline: none;
+}
+
+.test-input:focus {
+    border-color: #e67e22;
+}
+
+/* 反馈区域 */
+.feedback-wrapper {
+    display: flex;
+    gap: 20px;
+    padding: 10px 0;
+}
+
+.feedback-main {
+    flex: 1;
+    padding: 20px;
+    background: #f8f9fa;
+    border-radius: 12px;
+}
+
+.fb-status {
+    font-size: 22px;
+    font-weight: bold;
+    margin-bottom: 15px;
+}
+
+.fb-status.correct {
+    color: #10b981;
+}
+
+.fb-status.wrong {
+    color: #dc2626;
+}
+
+.fb-word {
+    font-size: 36px;
+    font-weight: bold;
+    color: #1e40af;
+    margin-bottom: 8px;
+}
+
+.fb-chinese {
+    font-size: 20px;
+    color: #374151;
+    margin-bottom: 4px;
+}
+
+.fb-pos {
+    font-size: 18px;
+    color: #374151;
+    margin-bottom: 12px;
+}
+
+.fb-root {
+    font-size: 18px;
+    color: #7c3aed;
+    margin-bottom: 4px;
+}
+
+.fb-affix {
+    font-size: 18px;
+    color: #2563eb;
+    margin-bottom: 4px;
+}
+
+.fb-sentence {
+    font-size: 16px;
+    color: #374151;
+    margin-top: 10px;
+    line-height: 1.6;
+}
+
+.fb-image {
+    width: 300px;
+    display: flex;
+    align-items: flex-start;
+    justify-content: center;
+    background: #fafafa;
+    border-radius: 12px;
+    padding: 10px;
+}
+
+.fb-image img {
+    max-width: 100%;
+    max-height: 300px;
+    object-fit: contain;
+}
+
+.copy-trigger {
+    margin-top: 15px;
+    background: #dc2626;
+}
+
+.copy-trigger:hover {
+    background: #b91c1c;
+}
+
+/* 抄写纠正区域 */
+.copy-area {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 15px 0;
+    margin-top: 15px;
+}
+
+.copy-label {
+    font-size: 20px;
+    color: #dc2626;
+    font-weight: bold;
+}
+
+.copy-input {
+    width: 350px;
+    padding: 12px;
+    font-size: 22px;
+    border: 2px solid #ddd;
+    border-radius: 10px;
+    outline: none;
+}
+
+.copy-input:focus {
+    border-color: #dc2626;
+}
+
+.copy-input.correct {
+    border-color: #10b981;
+    background: #ecfdf5;
+}
+
+.copy-check {
+    font-size: 22px;
+}
+
+.copy-check.ok {
+    color: #10b981;
+}
+
+/* 完成卡片 */
+.test-complete-card h1 {
+    font-size: 40px;
+    color: #10b981;
+}
+
+.test-complete-card p {
+    font-size: 20px;
+    color: #64748b;
+    margin-bottom: 20px;
+}
+
+.test-retest-card h1 {
+    color: #e67e22;
+}
+
+.test-retest-card p {
+    font-size: 20px;
+    color: #64748b;
+    margin-bottom: 20px;
+}
+
+.action-btn {
+    padding: 12px 28px;
+    background: #1e40af;
+    color: white;
+    border: none;
+    border-radius: 10px;
+    font-size: 18px;
+    cursor: pointer;
+    transition: background 0.2s;
+}
+
+.action-btn:hover {
+    background: #1e3a8a;
+}
+
+.back-home {
+    margin-top: 20px;
+    background: none;
+    border: none;
+    color: #64748b;
+    text-decoration: underline;
+    cursor: pointer;
+}
+
+.loading {
+    font-size: 24px;
+    margin-top: 100px;
+    color: #64748b;
+}
diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js
new file mode 100644 (file)
index 0000000..74e4741
--- /dev/null
@@ -0,0 +1,299 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import axios from 'axios';
+import './TestMode.css';
+
+const API_BASE = "http://124.222.157.124:1060";
+
+function TestMode() {
+    const { groupId } = useParams();
+    const navigate = useNavigate();
+    const [words, setWords] = useState([]);
+    const [queue, setQueue] = useState([]);        // 当前测试队列
+    const [wrongQueue, setWrongQueue] = useState([]); // 错词重考队列
+    const [currentIndex, setCurrentIndex] = useState(0);
+    const [totalCount, setTotalCount] = useState(0);
+    const [phase, setPhase] = useState('ready');     // ready, testing, feedback, copy, retest_info, complete
+    const [userInput, setUserInput] = useState("");
+    const [copyInput, setCopyInput] = useState("");
+    const [isCorrect, setIsCorrect] = useState(false);
+    const [isRetesting, setIsRetesting] = useState(false);
+
+    const inputRef = useRef(null);
+    const copyRef = useRef(null);
+    const currentWord = queue[currentIndex];
+
+    // 获取单词数据
+    useEffect(() => {
+        axios.get(`${API_BASE}/api/words/${groupId}`).then(res => {
+            setWords(res.data);
+        });
+    }, [groupId]);
+
+    // 开始测试:随机打乱
+    const startTest = useCallback(() => {
+        const shuffled = [...words].sort(() => Math.random() - 0.5);
+        setQueue(shuffled);
+        setWrongQueue([]);
+        setCurrentIndex(0);
+        setTotalCount(shuffled.length);
+        setPhase('testing');
+        setUserInput("");
+        setIsRetesting(false);
+    }, [words]);
+
+    // 自动聚焦输入框
+    useEffect(() => {
+        if (phase === 'testing' && inputRef.current) {
+            inputRef.current.focus();
+        }
+        if (phase === 'copy' && copyRef.current) {
+            copyRef.current.focus();
+        }
+    }, [phase, currentIndex]);
+
+    // 朗读
+    const speak = useCallback((text) => {
+        window.speechSynthesis.cancel();
+        const utterance = new SpeechSynthesisUtterance(text);
+        utterance.lang = 'en-US';
+        utterance.rate = 0.6;
+        window.speechSynthesis.speak(utterance);
+    }, []);
+
+    // 提交答案
+    const checkAnswer = useCallback(() => {
+        if (!currentWord || !userInput.trim()) return;
+        const correct = currentWord.word.trim().toLowerCase();
+        const answer = userInput.trim().toLowerCase();
+        const ok = answer === correct;
+
+        setIsCorrect(ok);
+        setPhase('feedback');
+        speak(currentWord.word);
+
+        if (!ok) {
+            setWrongQueue(prev => {
+                if (!prev.find(w => w.id === currentWord.id)) {
+                    return [...prev, currentWord];
+                }
+                return prev;
+            });
+        }
+    }, [currentWord, userInput, speak]);
+
+    // 显示抄写区
+    const showCopy = useCallback(() => {
+        setCopyInput("");
+        setPhase('copy');
+    }, []);
+
+    // 下一个词
+    const nextWord = useCallback(() => {
+        if (currentIndex + 1 < queue.length) {
+            setCurrentIndex(prev => prev + 1);
+            setUserInput("");
+            setPhase('testing');
+        } else {
+            // 本轮结束
+            if (wrongQueue.length > 0) {
+                // 有错题,进入重考
+                setPhase('retest_info');
+            } else {
+                // 全部通过
+                setPhase('complete');
+            }
+        }
+    }, [currentIndex, queue.length, wrongQueue.length]);
+
+    // 开始重考
+    const startRetest = useCallback(() => {
+        const retestWords = [...wrongQueue].sort(() => Math.random() - 0.5);
+        setQueue(retestWords);
+        setWrongQueue([]);
+        setCurrentIndex(0);
+        setTotalCount(retestWords.length);
+        setUserInput("");
+        setPhase('testing');
+        setIsRetesting(true);
+    }, [wrongQueue]);
+
+    // 完成测试,记录进度
+    const finishTest = useCallback(async () => {
+        await axios.post(`${API_BASE}/api/progress/update`, {
+            group_id: groupId,
+            op_type: 'test'
+        });
+        navigate('/');
+    }, [groupId, navigate]);
+
+    // 键盘事件
+    const handleKeyDown = useCallback((e) => {
+        if (e.key === 'Enter') {
+            if (phase === 'testing') {
+                checkAnswer();
+            } else if (phase === 'feedback' && isCorrect) {
+                nextWord();
+            } else if (phase === 'copy' && copyInput === currentWord.word) {
+                nextWord();
+            } else if (phase === 'feedback' && !isCorrect) {
+                showCopy();
+            }
+        }
+    }, [phase, isCorrect, checkAnswer, nextWord, showCopy, copyInput, currentWord]);
+
+    useEffect(() => {
+        window.addEventListener('keydown', handleKeyDown);
+        return () => window.removeEventListener('keydown', handleKeyDown);
+    }, [handleKeyDown]);
+
+    // 准备阶段
+    if (words.length === 0) return <div className="loading">加载中...</div>;
+
+    if (phase === 'ready') {
+        return (
+            <div className="test-container">
+                <div className="test-card test-start-card">
+                    <h1>单元测试 - Group {groupId}</h1>
+                    <p className="test-info">共 {words.length} 个单词</p>
+                    <p className="test-desc">根据中文含义拼写英文单词,拼写错误需要抄写纠正</p>
+                    <button className="action-btn start-btn" onClick={startTest}>开始测试</button>
+                    <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+                </div>
+            </div>
+        );
+    }
+
+    if (phase === 'complete') {
+        return (
+            <div className="test-container">
+                <div className="test-card test-complete-card">
+                    <h1>🎉 测试完成!</h1>
+                    <p>Group {groupId} 全部通过!</p>
+                    <button className="action-btn" onClick={finishTest}>完成</button>
+                </div>
+            </div>
+        );
+    }
+
+    if (phase === 'retest_info') {
+        return (
+            <div className="test-container">
+                <div className="test-card test-retest-card">
+                    <h1>📝 开始重考</h1>
+                    <p>还有 {wrongQueue.length} 个错题需要重考</p>
+                    <button className="action-btn" onClick={startRetest}>开始重考</button>
+                </div>
+            </div>
+        );
+    }
+
+    if (!currentWord) return null;
+
+    return (
+        <div className="test-container">
+            <div className="test-card">
+                {/* 状态栏 */}
+                <div className="test-header">
+                    <span className="test-status">
+                        {isRetesting ? '重考' : '测试'} - Group {groupId}
+                    </span>
+                    <span className="test-progress">
+                        {currentIndex + 1} / {totalCount}
+                    </span>
+                </div>
+
+                {/* 答题区域 */}
+                <div className="test-body">
+                    {phase === 'testing' && (
+                        <div className="testing-area">
+                            <div className="prompt-info">
+                                <p className="prompt-chinese">{currentWord.chinese_meaning}</p>
+                                <p className="prompt-pos">({currentWord.part_of_speech})</p>
+                            </div>
+                            <div className="input-row">
+                                <input
+                                    ref={inputRef}
+                                    value={userInput}
+                                    onChange={(e) => setUserInput(e.target.value)}
+                                    placeholder="请输入英文单词..."
+                                    className="test-input"
+                                />
+                                <button className="action-btn" onClick={checkAnswer}>提交</button>
+                            </div>
+                        </div>
+                    )}
+
+                    {/* 反馈区域 */}
+                    {(phase === 'feedback' || phase === 'copy') && (
+                        <div className="feedback-wrapper">
+                            <div className="feedback-main">
+                                <div className={`fb-status ${isCorrect ? 'correct' : 'wrong'}`}>
+                                    {isCorrect ? '✅ 正确!' : `❌ 错误,你的答案: ${userInput}`}
+                                </div>
+
+                                <div className="fb-word">{currentWord.word}</div>
+                                <div className="fb-chinese">中文: {currentWord.chinese_meaning}</div>
+                                <div className="fb-pos">词性: {currentWord.part_of_speech}</div>
+
+                                {currentWord.word_root && (
+                                    <div className="fb-root">词根: {currentWord.word_root}</div>
+                                )}
+                                {currentWord.affix && (
+                                    <div className="fb-affix">组成: {currentWord.affix}</div>
+                                )}
+                                {currentWord.example_sentence && (
+                                    <div className="fb-sentence">例句: {currentWord.example_sentence}</div>
+                                )}
+
+                                {!isCorrect && phase === 'feedback' && (
+                                    <button className="action-btn copy-trigger" onClick={showCopy}>
+                                        抄写纠正 (Enter)
+                                    </button>
+                                )}
+
+                                {isCorrect && (
+                                    <button className="action-btn" onClick={nextWord}>
+                                        下一词 (Enter)
+                                    </button>
+                                )}
+                            </div>
+
+                            {/* 图片 */}
+                            {currentWord.picture_id && (
+                                <div className="fb-image">
+                                    <img src={`${API_BASE}/api/picture/${currentWord.picture_id}`} alt="mindmap" />
+                                </div>
+                            )}
+                        </div>
+                    )}
+
+                    {/* 抄写纠正区域 */}
+                    {phase === 'copy' && (
+                        <div className="copy-area">
+                            <label className="copy-label">抄写: </label>
+                            <input
+                                ref={copyRef}
+                                value={copyInput}
+                                onChange={(e) => setCopyInput(e.target.value)}
+                                className={`copy-input ${copyInput === currentWord.word ? 'correct' : ''}`}
+                                placeholder="请在此抄写正确单词..."
+                            />
+                            <span className={`copy-check ${copyInput === currentWord.word ? 'ok' : ''}`}>
+                                {copyInput === currentWord.word ? '✅' : '❌'}
+                            </span>
+                            {copyInput === currentWord.word && (
+                                <button className="action-btn" onClick={nextWord}>
+                                    下一词 (Enter)
+                                </button>
+                            )}
+                        </div>
+                    )}
+                </div>
+            </div>
+            <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+        </div>
+    );
+}
+
+export default TestMode;