-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
import sqlite3
import os
# --- 路径自动修复逻辑 ---
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")
# --- 数据模型 ---
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)
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
"""
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}")
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)
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>
);
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);
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';
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">
{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>
--- /dev/null
+.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;
+}
--- /dev/null
+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;