From: root Date: Mon, 25 May 2026 15:26:32 +0000 (+0800) Subject: 学习模式雏形已有,可以发音和闪记,但是不显示词根和词缀,闪记模式下还有左侧思维导图 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=f47ef4263414aa51ad962598edce296d760d81ac;p=words.git 学习模式雏形已有,可以发音和闪记,但是不显示词根和词缀,闪记模式下还有左侧思维导图 --- diff --git a/backend/main.py b/backend/main.py index ce7dab3..d18b5d8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/frontend/src/App.js b/frontend/src/App.js index 443473f..9448d70 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 ( } /> - } /> + } /> ); diff --git a/frontend/src/components/Home.js b/frontend/src/components/Home.js index 06cdfe5..f9bc7fa 100644 --- a/frontend/src/components/Home.js +++ b/frontend/src/components/Home.js @@ -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 (
@@ -75,7 +83,13 @@ function Home() { {filteredData.map((item, index) => ( {item.group} - {item.study} + handleCellClick(item, 'study')} + style={{ cursor: item.study.includes('请开始学习') ? 'pointer' : 'default' }} + > + {item.study} + {item.test} {item.review1} {item.review2} diff --git a/frontend/src/components/StudyMode.css b/frontend/src/components/StudyMode.css new file mode 100644 index 0000000..76ad199 --- /dev/null +++ b/frontend/src/components/StudyMode.css @@ -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 index 0000000..a410ccf --- /dev/null +++ b/frontend/src/components/StudyMode.js @@ -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
加载中...
; + + return ( +
+
+
+ {currentWord.picture_id && ( + mindmap + )} +
+ +
+
Group {groupId}: {currentIndex + 1} / {words.length}
+ + {phase === 'learning' ? ( +
+

{currentWord.word}

+
+ {currentWord.chinese_meaning} + ({currentWord.part_of_speech}) +
+ {showDetails && ( +
+ {currentWord.word_root &&

词根: {currentWord.word_root}

} + {currentWord.affix &&

组成: {currentWord.affix}

} + +
+ )} +
+ ) : phase === 'flashcard' ? ( +
+

请拼写单词

+ setUserInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && checkFlashcard()} + placeholder="输入英文..." + /> + +
+ ) : ( +
+

回答错误!正确答案:{currentWord.word}

+

请抄写一遍以解锁:

+ setCorrectionInput(e.target.value)} + placeholder="请在此抄写..." + /> + {correctionInput === currentWord.word && ( + + )} +
+ )} +
+
+ +
+ ); +} + +export default StudyMode;