]> acesimba.cloud Git - words.git/commitdiff
学习模式雏形已有,可以发音和闪记,但是不显示词根和词缀,闪记模式下还有左侧思维导图
authorroot <root@localhost.localdomain>
Mon, 25 May 2026 15:26:32 +0000 (23:26 +0800)
committerroot <root@localhost.localdomain>
Mon, 25 May 2026 15:26:32 +0000 (23:26 +0800)
backend/main.py
frontend/src/App.js
frontend/src/components/Home.js
frontend/src/components/StudyMode.css [new file with mode: 0644]
frontend/src/components/StudyMode.js [new file with mode: 0644]

index ce7dab375cef0033569f8fc1d24809eaf855ca0f..d18b5d8a3db6c02cf766e26b9d0e149274a72f8e 100644 (file)
@@ -1,4 +1,4 @@
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI, HTTPException, Response
 from fastapi.middleware.cors import CORSMiddleware
 import sqlite3
 import os
@@ -19,10 +19,8 @@ app.add_middleware(
 
 # --- 路径自动修复逻辑 ---
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-# 优先检查 /home/words/backend/a/bcd.db
 DATABASE_PATH = os.path.join(BASE_DIR, "a", "bcd.db")
 if not os.path.exists(DATABASE_PATH):
-    # 备选检查 /home/words/backend/db/bcd.db
     DATABASE_PATH = os.path.join(BASE_DIR, "db", "bcd.db")
 
 # --- 数据模型 ---
@@ -38,13 +36,29 @@ class ProgressData(BaseModel):
     review2: str
     review3: str
 
+class WordData(BaseModel):
+    id: int
+    word: str
+    chinese_meaning: Optional[str] = None
+    part_of_speech: Optional[str] = None
+    word_root: Optional[str] = None
+    affix: Optional[str] = None
+    example_sentence: Optional[str] = None
+    picture_id: Optional[int] = None
+
+    class Config:
+        from_attributes = True
+
+class ProgressUpdate(BaseModel):
+    group_id: str
+    op_type: str
+
 # --- 辅助逻辑函数 ---
 
 def calculate_hours_since(date_str: str) -> Optional[float]:
     if not date_str:
         return None
     try:
-        # 兼容数据库中的 YYYY-MM-DD HH:MM:SS 或 YYYY-MM-DD
         date_part = date_str[:19]
         fmt = "%Y-%m-%d %H:%M:%S" if " " in date_part else "%Y-%m-%d"
         last = datetime.datetime.strptime(date_part, fmt)
@@ -53,17 +67,13 @@ def calculate_hours_since(date_str: str) -> Optional[float]:
         return None
 
 def get_progress_from_db():
-    """解析数据库 study_logs 表获取每个组的完成时间"""
     progress = {} 
     try:
         if not os.path.exists(DATABASE_PATH):
             return progress
-            
         conn = sqlite3.connect(DATABASE_PATH)
         conn.row_factory = sqlite3.Row
         cursor = conn.cursor()
-        
-        # 查询每个组每种类型的最后完成时间
         query = """
             SELECT group_id, op_type, MAX(created_at) as last_date 
             FROM study_logs 
@@ -72,18 +82,14 @@ def get_progress_from_db():
         """
         cursor.execute(query)
         rows = cursor.fetchall()
-        
         for row in rows:
             g_n = str(row['group_id'])
             a_t = row['op_type']
             c_d = row['last_date']
-            
             if g_n not in progress:
                 progress[g_n] = {'study': None, 'test': None, 'review1': None, 'review2': None, 'review3': None}
-            
             if a_t in progress[g_n]:
                 progress[g_n][a_t] = c_d
-                
         conn.close()
     except Exception as e:
         print(f"Database error in get_progress_from_db: {e}")
@@ -103,75 +109,123 @@ async def get_home_data():
         conn.close()
         return HomeData(pictures_count=p_count, words_count=w_count)
     except Exception as e:
-        raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
 
 @app.get("/api/home/process", response_model=List[ProgressData])
 async def get_home_process():
     try:
-        # 1. 获取所有分组
         conn = sqlite3.connect(DATABASE_PATH)
         cursor = conn.cursor()
         cursor.execute("SELECT DISTINCT groupid FROM words ORDER BY CAST(groupid AS INTEGER) ASC")
         all_groups = [str(row[0]) for row in cursor.fetchall()]
         conn.close()
-
-        # 2. 从数据库读取进度
         log_progress = get_progress_from_db()
-
-        # 3. 业务逻辑处理 (完全保留你要求的 UI 逻辑)
         results = []
         first_unstudied = None
         for g in all_groups:
             if g not in log_progress or not log_progress[g]['study']:
                 first_unstudied = g
                 break
-
         for g_name in all_groups:
             p = log_progress.get(g_name, {'study': None, 'test': None, 'review1': None, 'review2': None, 'review3': None})
             row = {"group": f"Group {g_name}"}
-
-            # 基础学习列
             if p['study']:
                 row['study'] = f"✔️ {p['study'][:10]}"
             elif g_name == first_unstudied:
                 row['study'] = "✅请开始学习"
             else:
                 row['study'] = ""
-
-            # 单元测试列
             if p['test']:
                 row['test'] = f"✔️ {p['test'][:10]}"
             elif p['study']:
                 row['test'] = "✅请开始测试"
             else:
                 row['test'] = ""
-
-            # 复习列 (Review1, 2, 3)
-            rk = ['review1', 'review2', 'review3']
-            pk = ['test', 'review1', 'review2']
-            
+            rk, pk = ['review1', 'review2', 'review3'], ['test', 'review1', 'review2']
             for i, key in enumerate(rk):
                 prev_key = pk[i]
                 if p[key]:
                     row[key] = f"✔️ {p[key][:10]}"
                 elif p[prev_key]:
                     hours = calculate_hours_since(p[prev_key])
-                    if hours and hours > 4:
-                        row[key] = "⚠️请尽快复习"
-                    else:
-                        row[key] = "✅请开始复习"
+                    row[key] = "⚠️请尽快复习" if hours and hours > 4 else "✅请开始复习"
                 else:
                     row[key] = ""
-            
             results.append(ProgressData(**row))
-
         return results
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/words/{group_id}", response_model=List[WordData])
+async def get_words_by_group(group_id: str):
+    try:
+        conn = sqlite3.connect(DATABASE_PATH)
+        conn.row_factory = sqlite3.Row
+        cursor = conn.cursor()
+        # 核心修复:将数据库的 ID 显式重命名为 id 以匹配 Pydantic 模型
+        cursor.execute("""
+            SELECT 
+                ID as id, 
+                word, 
+                chinese_meaning, 
+                part_of_speech, 
+                word_root, 
+                affix, 
+                example_sentence, 
+                picture_id 
+            FROM words 
+            WHERE CAST(groupid AS INTEGER) = ? 
+            ORDER BY ID ASC
+        """, (group_id,))
+        rows = cursor.fetchall()
+        conn.close()
+        return [dict(row) for row in rows]
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
 
+@app.get("/api/picture/{pic_id}")
+async def get_picture(pic_id: int):
+    try:
+        conn = sqlite3.connect(DATABASE_PATH)
+        cursor = conn.cursor()
+        cursor.execute("SELECT pictures FROM pictures WHERE ID = ?", (pic_id,))
+        row = cursor.fetchone()
+        conn.close()
+        if row and row[0]:
+            return Response(content=row[0], media_type="image/jpeg")
+        raise HTTPException(status_code=404, detail="Picture not found")
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/progress/update")
+async def update_progress(ProgressUpdate):
+    try:
+        conn = sqlite3.connect(DATABASE_PATH)
+        cursor = conn.cursor()
+        cursor.execute("""
+            CREATE TABLE IF NOT EXISTS study_logs (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                group_id TEXT,
+                op_type TEXT,
+                status TEXT,
+                created_at DATETIME
+            )
+        """)
+        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        cursor.execute(
+            "INSERT INTO study_logs (group_id, op_type, status, created_at) VALUES (?, ?, ?, ?)",
+            (data.group_id, data.op_type, 'completed', now)
+        )
+        conn.commit()
+        conn.close()
+        return {"status": "success", "date": now}
     except Exception as e:
-        import traceback
-        print(traceback.format_exc())
         raise HTTPException(status_code=500, detail=str(e))
 
 @app.get("/")
 async def root():
     return {"message": "English Lexicon API is running"}
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=1060)
index 443473ff598e93be397c0f0da19dfd0ea1c11fdb..9448d701e8ce84441b71c784d6fb8731c52b3ba6 100644 (file)
@@ -1,14 +1,14 @@
 import React from 'react';
 import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
 import Home from './components/Home';
-import UploadPicture from './components/UploadPicture';
+import StudyMode from './components/StudyMode';
 
 function App() {
     return (
         <Router>
             <Routes>
                 <Route path="/" element={<Home />} />
-                <Route path="/upload" element={<UploadPicture />} />
+                <Route path="/study/:groupId" element={<StudyMode />} />
             </Routes>
         </Router>
     );
index 06cdfe51b5e8716fc6a78fdbe49621235045b554..f9bc7fa3106da30b99df2acebf074b7ca4377f4e 100644 (file)
@@ -1,19 +1,22 @@
 import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
 import axios from 'axios';
-import './Home.css'; // 我们稍后创建这个样式文件
+import './Home.css';
+
+const API_BASE = "http://124.222.157.124:1060";
 
 function Home() {
     const [progressData, setProgressData] = useState([]);
     const [showAll, setShowAll] = useState(false);
     const [stats, setStats] = useState({ pictures_count: 0, words_count: 0 });
+    const navigate = useNavigate();
 
-    // 获取统计数据和进度数据
     const fetchData = async () => {
         try {
-            const resStats = await axios.get('http://124.222.157.124:1060/api/home');
+            const resStats = await axios.get(`${API_BASE}/api/home`);
             setStats(resStats.data);
 
-            const resProcess = await axios.get('http://124.222.157.124:1060/api/home/process');
+            const resProcess = await axios.get(`${API_BASE}/api/home/process`);
             setProgressData(resProcess.data);
         } catch (error) {
             console.error("获取数据失败:", error);
@@ -22,20 +25,17 @@ function Home() {
 
     useEffect(() => {
         fetchData();
-        const timer = setInterval(fetchData, 30000); // 30秒自动刷新
+        const timer = setInterval(fetchData, 30000);
         return () => clearInterval(timer);
     }, []);
 
-    // 过滤逻辑:模拟 Python 中的 toggle_show_all_groups
     const filteredData = progressData.filter(item => {
         if (showAll) return true;
-        // 检查是否所有环节都已完成(包含 ✔️)
         const isDone = [item.study, item.test, item.review1, item.review2, item.review3]
             .every(val => val && val.includes('✔️'));
         return !isDone;
     });
 
-    // 根据后端返回的文本内容决定样式类
     const getStatusClass = (text) => {
         if (!text) return '';
         if (text.includes('✔️')) return 'status-done';
@@ -44,6 +44,14 @@ function Home() {
         return '';
     };
 
+    const handleCellClick = (item, type) => {
+        const groupId = item.group.replace('Group ', '');
+        if (type === 'study' && item.study.includes('请开始学习')) {
+            navigate(`/study/${groupId}`);
+        }
+        // 后续可以在这里添加 test 和 review 的跳转逻辑
+    };
+
     return (
         <div className="main-app">
             <header className="header">
@@ -75,7 +83,13 @@ function Home() {
                         {filteredData.map((item, index) => (
                             <tr key={index}>
                                 <td className="group-name">{item.group}</td>
-                                <td className={getStatusClass(item.study)}>{item.study}</td>
+                                <td 
+                                    className={getStatusClass(item.study)}
+                                    onClick={() => handleCellClick(item, 'study')}
+                                    style={{ cursor: item.study.includes('请开始学习') ? 'pointer' : 'default' }}
+                                >
+                                    {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>
diff --git a/frontend/src/components/StudyMode.css b/frontend/src/components/StudyMode.css
new file mode 100644 (file)
index 0000000..76ad199
--- /dev/null
@@ -0,0 +1,100 @@
+.study-container {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 20px;
+    background-color: #f0f7ff;
+    min-height: 100vh;
+}
+
+.study-card {
+    display: flex;
+    background: white;
+    border-radius: 20px;
+    box-shadow: 0 10px 30px rgba(0,0,0,0.1);
+    width: 95%;
+    max-width: 1100px;
+    min-height: 600px;
+    overflow: hidden;
+}
+
+.image-section {
+    flex: 1;
+    background: #fafafa;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-right: 1px solid #eee;
+}
+
+.image-section img {
+    max-width: 100%;
+    max-height: 550px;
+    object-fit: contain;
+}
+
+.content-section {
+    flex: 1;
+    padding: 40px;
+    display: flex;
+    flex-direction: column;
+}
+
+.word-text {
+    font-size: 64px;
+    color: #dc2626;
+    margin-bottom: 10px;
+}
+
+.chinese {
+    font-size: 28px;
+    font-weight: 500;
+}
+
+.pos {
+    font-size: 20px;
+    color: #1e40af;
+    margin-left: 15px;
+}
+
+.details-anim {
+    margin-top: 30px;
+    animation: fadeIn 0.5s ease-in;
+}
+
+@keyframes fadeIn {
+    from { opacity: 0; transform: translateY(10px); }
+    to { opacity: 1; transform: translateY(0); }
+}
+
+.action-btn {
+    margin-top: 40px;
+    padding: 15px 30px;
+    background: #1e40af;
+    color: white;
+    border: none;
+    border-radius: 10px;
+    font-size: 18px;
+    cursor: pointer;
+}
+
+input {
+    width: 100%;
+    padding: 15px;
+    font-size: 24px;
+    border: 2px solid #ddd;
+    border-radius: 10px;
+    margin-top: 20px;
+}
+
+.correction-area .error-tip { color: #dc2626; }
+.correction-area input.correct { border-color: #10b981; background: #ecfdf5; }
+
+.back-home {
+    margin-top: 20px;
+    background: none;
+    border: none;
+    color: #64748b;
+    text-decoration: underline;
+    cursor: pointer;
+}
diff --git a/frontend/src/components/StudyMode.js b/frontend/src/components/StudyMode.js
new file mode 100644 (file)
index 0000000..a410ccf
--- /dev/null
@@ -0,0 +1,131 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import axios from 'axios';
+import './StudyMode.css';
+
+const API_BASE = "http://124.222.157.124:1060";
+
+function StudyMode() {
+    const { groupId } = useParams();
+    const navigate = useNavigate();
+    const [words, setWords] = useState([]);
+    const [currentIndex, setCurrentIndex] = useState(0);
+    const [phase, setPhase] = useState('learning'); // learning, flashcard, correction
+    const [userInput, setUserInput] = useState("");
+    const [correctionInput, setCorrectionInput] = useState("");
+    const [showDetails, setShowDetails] = useState(false);
+
+    const currentWord = words[currentIndex];
+
+    const speak = useCallback((text) => {
+        const utterance = new SpeechSynthesisUtterance(text);
+        utterance.lang = 'en-US';
+        utterance.rate = 0.8;
+        window.speechSynthesis.speak(utterance);
+    }, []);
+
+    useEffect(() => {
+        axios.get(`${API_BASE}/api/words/${groupId}`).then(res => setWords(res.data));
+    }, [groupId]);
+
+    useEffect(() => {
+        if (currentWord && phase === 'learning') {
+            setShowDetails(false);
+            speak(currentWord.word);
+            setTimeout(() => speak(currentWord.word), 1500);
+            setTimeout(() => {
+                setShowDetails(true);
+                speak(currentWord.word);
+            }, 3000);
+        }
+    }, [currentIndex, currentWord, phase, speak]);
+
+    const handleNext = async () => {
+        if (currentIndex < words.length - 1) {
+            setCurrentIndex(currentIndex + 1);
+            setPhase('learning');
+            setUserInput("");
+        } else {
+            await axios.post(`${API_BASE}/api/progress/update`, {
+                group_id: groupId,
+                op_type: 'study'
+            });
+            alert("恭喜完成本组学习!");
+            navigate('/');
+        }
+    };
+
+    const checkFlashcard = () => {
+        if (userInput.toLowerCase().trim() === currentWord.word.toLowerCase().trim()) {
+            handleNext();
+        } else {
+            setPhase('correction');
+            setCorrectionInput("");
+        }
+    };
+
+    if (!currentWord) return <div className="loading">加载中...</div>;
+
+    return (
+        <div className="study-container">
+            <div className="study-card">
+                <div className="image-section">
+                    {currentWord.picture_id && (
+                        <img src={`${API_BASE}/api/picture/${currentWord.picture_id}`} alt="mindmap" />
+                    )}
+                </div>
+
+                <div className="content-section">
+                    <div className="progress-tag">Group {groupId}: {currentIndex + 1} / {words.length}</div>
+                    
+                    {phase === 'learning' ? (
+                        <div className="info-area">
+                            <h1 className="word-text">{currentWord.word}</h1>
+                            <div className="meaning-row">
+                                <span className="chinese">{currentWord.chinese_meaning}</span>
+                                <span className="pos">({currentWord.part_of_speech})</span>
+                            </div>
+                            {showDetails && (
+                                <div className="details-anim">
+                                    {currentWord.word_root && <p className="root">词根: {currentWord.word_root}</p>}
+                                    {currentWord.affix && <p className="affix">组成: {currentWord.affix}</p>}
+                                    <button className="action-btn" onClick={() => setPhase('flashcard')}>进入闪记模式</button>
+                                </div>
+                            )}
+                        </div>
+                    ) : phase === 'flashcard' ? (
+                        <div className="flashcard-area">
+                            <h2>请拼写单词</h2>
+                            <input 
+                                autoFocus
+                                value={userInput}
+                                onChange={(e) => setUserInput(e.target.value)}
+                                onKeyDown={(e) => e.key === 'Enter' && checkFlashcard()}
+                                placeholder="输入英文..."
+                            />
+                            <button onClick={checkFlashcard}>检查</button>
+                        </div>
+                    ) : (
+                        <div className="correction-area">
+                            <h2 className="error-tip">回答错误!正确答案:{currentWord.word}</h2>
+                            <p>请抄写一遍以解锁:</p>
+                            <input 
+                                autoFocus
+                                className={correctionInput === currentWord.word ? "correct" : "typing"}
+                                value={correctionInput}
+                                onChange={(e) => setCorrectionInput(e.target.value)}
+                                placeholder="请在此抄写..."
+                            />
+                            {correctionInput === currentWord.word && (
+                                <button className="action-btn" onClick={() => setPhase('flashcard')}>重新尝试</button>
+                            )}
+                        </div>
+                    )}
+                </div>
+            </div>
+            <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+        </div>
+    );
+}
+
+export default StudyMode;