affix: Optional[str] = None
example_sentence: Optional[str] = None
picture_id: Optional[int] = None
+ groupid: Optional[int] = None
class Config:
from_attributes = True
group_id: str
op_type: str
+class SyncEvent(BaseModel):
+ group_id: str
+ op_type: str
+ completed_at: str
+
+class SyncPushRequest(BaseModel):
+ events: List[SyncEvent]
+
# --- 辅助逻辑函数 ---
def calculate_hours_since(date_str: str) -> Optional[float]:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = """
- SELECT group_id, op_type, MAX(created_at) as last_date
+ SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)) as last_date
FROM study_logs
WHERE status = 'completed'
GROUP BY group_id, op_type
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+@app.get("/api/words/all", response_model=List[WordData])
+async def get_all_words():
+ # 返回全部词库,供 PWA 同步后一次性灌入 IndexedDB 离线缓存。
+ # 注意: 此路由必须定义在 /api/words/{group_id} 之前,否则 "all" 会被 {group_id} 捕获。
+ try:
+ conn = sqlite3.connect(DATABASE_PATH)
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT
+ ID as id,
+ word,
+ chinese_meaning,
+ part_of_speech,
+ word_root,
+ affix,
+ example_sentence,
+ picture_id,
+ groupid
+ FROM words
+ ORDER BY CAST(groupid AS INTEGER) ASC, ID ASC
+ """)
+ 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/words/{group_id}", response_model=List[WordData])
async def get_words_by_group(group_id: str):
try:
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) = ?
+ SELECT
+ ID as id,
+ word,
+ chinese_meaning,
+ part_of_speech,
+ word_root,
+ affix,
+ example_sentence,
+ picture_id,
+ groupid
+ FROM words
+ WHERE CAST(groupid AS INTEGER) = ?
ORDER BY ID ASC
""", (group_id,))
rows = cursor.fetchall()
today_prefix = now[:10]
# 去重: 当天已有同 (group_id, op_type) 的完成记录则跳过,防止前端重复提交产生脏数据
cursor.execute(
- "SELECT id FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' AND substr(created_at,1,10)=?",
+ "SELECT id FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' AND COALESCE(log_date, substr(created_at,1,10))=?",
(data.group_id, data.op_type, today_prefix)
)
if cursor.fetchone():
conn.close()
return {"status": "skipped", "date": now, "reason": "duplicate same-day record"}
cursor.execute(
- "INSERT INTO study_logs (group_id, op_type, status, created_at) VALUES (?, ?, ?, ?)",
- (data.group_id, data.op_type, 'completed', now)
+ "INSERT INTO study_logs (group_id, op_type, status, created_at, log_date) VALUES (?, ?, ?, ?, ?)",
+ (data.group_id, data.op_type, 'completed', now, today_prefix)
)
conn.commit()
conn.close()
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
cursor.execute("""
- SELECT substr(created_at,1,10) as day, op_type, group_id
+ SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id
FROM study_logs
WHERE status='completed'
ORDER BY day, op_type, CAST(group_id AS INTEGER)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+ALLOWED_OP_TYPES = {'study', 'test', 'review1', 'review2', 'review3'}
+
+@app.post("/api/sync/push")
+async def sync_push(data: SyncPushRequest):
+ # 布尔并集: 每个 (group_id, op_type) 格子,任一方完成即完成。
+ # 服务器已有该 (group, op) 的 completed 记录则跳过;否则补写一条。
+ # 不删除、不比较时间戳。依赖 UNIQUE(group_id, op_type, log_date) 约束兜底并发。
+ try:
+ conn = sqlite3.connect(DATABASE_PATH)
+ cursor = conn.cursor()
+ updated = skipped = bad = 0
+ for ev in data.events:
+ op = ev.op_type
+ if op not in ALLOWED_OP_TYPES:
+ bad += 1
+ continue
+ cursor.execute(
+ "SELECT 1 FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' LIMIT 1",
+ (ev.group_id, op))
+ if cursor.fetchone():
+ skipped += 1
+ continue
+ completed_at = ev.completed_at or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ log_date = completed_at[:10]
+ cursor.execute(
+ "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date) "
+ "VALUES (?, ?, 'completed', ?, ?)",
+ (ev.group_id, op, completed_at, log_date))
+ updated += 1
+ conn.commit()
+ conn.close()
+ return {"status": "success", "received": len(data.events),
+ "updated": updated, "skipped": skipped, "bad": bad}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/sync/pull")
+async def sync_pull():
+ # 离线版点击"同步"时,拉取服务器端每个 (group, op) 的"最新"完成时间,供本地比对(LWW)。
+ try:
+ conn = sqlite3.connect(DATABASE_PATH)
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)) "
+ "FROM study_logs WHERE status='completed' GROUP BY group_id, op_type"
+ )
+ rows = cursor.fetchall()
+ conn.close()
+ events = [{"group_id": str(r[0]), "op_type": r[1], "completed_at": r[2]}
+ for r in rows if r[2]]
+ return {"status": "success", "events": events}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
@app.get("/")
async def root():
return {"message": "English Lexicon API is running"}
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
- <meta name="theme-color" content="#000000" />
+ <meta name="theme-color" content="#2563eb" />
<meta
name="description"
content="Web site created using create-react-app"
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
- <title>React App</title>
+ <title>单词学习</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
{
- "short_name": "React App",
- "name": "Create React App Sample",
+ "short_name": "单词学习",
+ "name": "单词学习",
"icons": [
{
"src": "favicon.ico",
"sizes": "512x512"
}
],
- "start_url": ".",
+ "start_url": "/",
"display": "standalone",
- "theme_color": "#000000",
+ "theme_color": "#2563eb",
"background_color": "#ffffff"
}
--- /dev/null
+// PWA Service Worker for words 离线学习
+// 缓存策略:
+// - /api/picture/* /api/tts/* → cache-first (大 blob 按需缓存)
+// - 其他 /api/* JSON → 直走网络不缓存 (IndexedDB 兜底)
+// - navigation (HTML) → network-first, 失败回退 cached index.html
+// - 静态资源 (JS/CSS/img/font) → cache-first
+
+const CACHE_VERSION = 'words-pwa-v1';
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_VERSION).then((cache) => cache.addAll(['/index.html', '/manifest.json']))
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) => Promise.all(
+ keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))
+ ))
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (event) => {
+ const req = event.request;
+ if (req.method !== 'GET') return;
+
+ const url = new URL(req.url);
+ const path = url.pathname;
+
+ // 图片与 TTS 音频: cache-first (含跨域, 后端已开 CORS)
+ if (path.startsWith('/api/picture/') || path.startsWith('/api/tts/')) {
+ event.respondWith(cacheFirst(req));
+ return;
+ }
+
+ // 其他 API JSON: 不拦截, 直走网络 (IndexedDB 兜底)
+ if (path.startsWith('/api/')) {
+ return;
+ }
+
+ // 页面导航: network-first, 离线回退 index.html
+ if (req.mode === 'navigate') {
+ event.respondWith(networkFirst(req));
+ return;
+ }
+
+ // 静态资源: cache-first
+ if (['style', 'script', 'image', 'font'].includes(req.destination)) {
+ event.respondWith(cacheFirst(req));
+ return;
+ }
+});
+
+async function cacheFirst(req) {
+ const cache = await caches.open(CACHE_VERSION);
+ const cached = await cache.match(req);
+ if (cached) return cached;
+ try {
+ const res = await fetch(req);
+ if (res.ok || res.type === 'opaque') cache.put(req, res.clone());
+ return res;
+ } catch (e) {
+ return cached || Response.error();
+ }
+}
+
+async function networkFirst(req) {
+ const cache = await caches.open(CACHE_VERSION);
+ try {
+ const res = await fetch(req);
+ if (res.ok) cache.put(req, res.clone());
+ return res;
+ } catch (e) {
+ const cached = await cache.match(req);
+ return cached || cache.match('/index.html');
+ }
+}
import TestMode from './components/TestMode';
import GroupView from './components/GroupView';
import StudyCalendar from './components/StudyCalendar';
+import SyncNotifications from './components/SyncNotifications';
function App() {
return (
<Router>
+ <SyncNotifications />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/study/:groupId" element={<StudyMode />} />
--- /dev/null
+import axios from 'axios';
+
+// 后端 API 基址。PWA 离线方案中所有组件统一走此出口,便于 Service Worker 拦截与未来切换。
+export const API_BASE = "http://124.222.157.124:1060";
+
+// 统一 axios 实例:超时 8s,避免离线时网络请求长时间挂起
+export const http = axios.create({
+ baseURL: API_BASE,
+ timeout: 8000,
+});
+
+// 统一 API 出口:所有组件应通过 api.xxx 调用,不再各自硬编码 API_BASE 或裸用 axios
+export const api = {
+ getHome: () => http.get('/api/home'),
+ getHomeProcess: () => http.get('/api/home/process'),
+ getWords: (groupId) => http.get(`/api/words/${groupId}`),
+ getAllWords: () => http.get('/api/words/all'),
+ updateProgress: (group_id, op_type) => http.post('/api/progress/update', { group_id, op_type }),
+ syncPush: (events) => http.post('/api/sync/push', { events }),
+ syncPull: () => http.get('/api/sync/pull'),
+ getCalendar: () => http.get('/api/calendar'),
+ uploadPicture: (formData) => http.post('/api/picture/', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ }),
+ // 图片与 TTS 走 <img src>/new Audio(),不走 axios;SW 仍能拦截缓存
+ pictureUrl: (picId) => `${API_BASE}/api/picture/${picId}`,
+ ttsUrl: (word) => `${API_BASE}/api/tts/${encodeURIComponent(word)}`,
+};
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import axios from 'axios';
+import { api } from '../api';
+import { getWordsByGroup } from '../db';
import './GroupView.css';
-const API_BASE = "http://124.222.157.124:1060";
-
function GroupView() {
const { groupId } = useParams();
const navigate = useNavigate();
const [words, setWords] = useState([]);
useEffect(() => {
- axios.get(`${API_BASE}/api/words/${groupId}`)
+ api.getWords(groupId)
.then(res => setWords(res.data))
- .catch(err => console.error("获取词汇失败:", err));
+ .catch(async (err) => {
+ // 离线或后端不可达: 降级读 IndexedDB (规格 9: 所有组均可离线查看)
+ console.error("获取词汇失败, 降级读离线缓存:", err);
+ const local = await getWordsByGroup(groupId);
+ setWords(local);
+ });
}, [groupId]);
// 组内所有单词共享同一张思维导图,取首词的 picture_id(兼容 picture_id==groupid 约定)
{pictureId && (
<div className="group-image-section">
- <img src={`${API_BASE}/api/picture/${pictureId}`} alt="mindmap" />
+ <img src={api.pictureUrl(pictureId)} alt="mindmap" />
</div>
)}
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import axios from 'axios';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db';
+import { triggerAutoSync, subscribeSyncStatus } from '../sync';
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 [needInit, setNeedInit] = useState(false); // 首次离线: 需联网初始化
+ const [blocking, setBlocking] = useState(false); // 同步中且本地有 dirty: 禁用学习入口
const navigate = useNavigate();
+ const isOnline = useOnlineStatus();
- const fetchData = async () => {
- try {
- const resStats = await axios.get(`${API_BASE}/api/home`);
- setStats(resStats.data);
-
- const resProcess = await axios.get(`${API_BASE}/api/home/process`);
- setProgressData(resProcess.data);
- } catch (error) {
- console.error("获取数据失败:", error);
+ // 离线降级: 从 IndexedDB 构造与 /api/home/process 同格式的数据 (emoji 字符串)
+ const buildOfflineData = async () => {
+ const allWords = await getAllWordsFromIDB();
+ const progress = await getAllProgress();
+ const completedByGroup = {};
+ progress.forEach(p => {
+ const g = String(p.group_id);
+ if (!completedByGroup[g]) completedByGroup[g] = {};
+ completedByGroup[g][p.op_type] = p.completed_at;
+ });
+ const allGroups = Array.from(new Set(allWords.map(w => String(w.groupid))))
+ .sort((a, b) => Number(a) - Number(b));
+ let firstUnstudied = null;
+ for (const g of allGroups) {
+ if (!completedByGroup[g] || !completedByGroup[g]['study']) { firstUnstudied = g; break; }
}
+ const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
+ const PREV = { test: 'study', review1: 'test', review2: 'review1', review3: 'review2' };
+ const progressData = allGroups.map(g => {
+ const p = completedByGroup[g] || {};
+ const row = { group: `Group ${g}` };
+ for (const op of OPS) {
+ if (p[op]) { row[op] = `✔️ ${p[op].slice(0, 10)}`; continue; }
+ if (op === 'study') { row[op] = (g === firstUnstudied) ? '✅请开始学习' : ''; continue; }
+ if (op === 'test') { row[op] = p['study'] ? '✅请开始测试' : ''; continue; }
+ row[op] = p[PREV[op]] ? '✅请开始复习' : '';
+ }
+ return row;
+ });
+ return {
+ stats: { pictures_count: 0, words_count: allWords.length },
+ progressData,
+ };
+ };
+
+ // 从本地 IndexedDB 渲染首页 (永远本地优先, 不读云端进度)
+ const renderLocal = async () => {
+ const d = await buildOfflineData();
+ setProgressData(d.progressData);
+ setStats(d.stats);
+ return d.progressData.length;
};
useEffect(() => {
- fetchData();
- const timer = setInterval(fetchData, 30000);
+ (async () => {
+ const init = await isInitialized();
+ // 首次离线: 不能进入学习, 提示需联网
+ if (!isOnline && !init) {
+ setNeedInit(true);
+ setProgressData([]);
+ return;
+ }
+ setNeedInit(false);
+ await renderLocal();
+ // 在线: 后台自动同步 (首次在线会 warmup 词库+进度; 已初始化则合并云端)
+ if (isOnline) triggerAutoSync();
+ })();
+ if (!isOnline) return;
+ // 在线时定时自动合并其他设备的进度
+ const timer = setInterval(() => triggerAutoSync(), 30000);
return () => clearInterval(timer);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOnline]);
+
+ // 订阅同步状态: 同步完成重渲染本地; 同步中且有 dirty 时禁用学习入口
+ useEffect(() => {
+ return subscribeSyncStatus((s) => {
+ if (s.state === 'synced') renderLocal();
+ setBlocking(s.state === 'syncing' && s.detail && s.detail.dirty > 0);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredData = progressData.filter(item => {
navigate(`/group/${groupId}`);
return;
}
+ // 同步中且本地有 dirty: 禁止开始新的学习/测试/复习 (先同步完成)
+ if (blocking) {
+ alert('同步完成后再开始新的学习');
+ return;
+ }
if (type === 'study' && item.study && item.study.includes('请开始学习')) {
navigate(`/study/${groupId}`);
}
</p>
</header>
+ {needInit && (
+ <div className="init-block">
+ <p>首次使用需要联网初始化一次,请连接网络后重新打开。</p>
+ </div>
+ )}
+
+ {blocking && (
+ <div className="sync-block-tip">同步完成后再开始新的学习</div>
+ )}
+
<div className="action-bar">
<button className="toggle-btn" onClick={() => setShowAll(!showAll)}>
{showAll ? "➖ 隐藏已完成组" : "➕ 显示已完成的组"}
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import axios from 'axios';
+import { api } from '../api';
+import { getAllProgress } from '../db';
import './StudyCalendar.css';
-const API_BASE = "http://124.222.157.124:1060";
-
const OP_LABEL = { study: '学', test: '测', review1: 'R1', review2: 'R2', review3: 'R3' };
const OP_COLOR = { study: '#ea580c', test: '#f97316', review1: '#5b7c99', review2: '#6b8e7a', review3: '#8a9ba8' };
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
+const OP_ORDER = ['study', 'test', 'review1', 'review2', 'review3'];
function pad(n) { return String(n).padStart(2, '0'); }
const [dayMap, setDayMap] = useState({});
useEffect(() => {
- axios.get(`${API_BASE}/api/calendar`)
+ api.getCalendar()
.then(res => {
const m = {};
res.data.forEach(item => { m[item.date] = item.activities; });
setDayMap(m);
})
- .catch(err => console.error("获取日历失败:", err));
+ .catch(async (err) => {
+ // 离线降级: 从 IndexedDB progress 本地按日聚合 (复用后端 calendar 逻辑)
+ console.error("获取日历失败, 降级读离线缓存:", err);
+ const allProgress = await getAllProgress();
+ const tmp = {};
+ allProgress.forEach(p => {
+ const day = (p.completed_at || '').slice(0, 10);
+ if (!day) return;
+ if (!tmp[day]) tmp[day] = {};
+ if (!tmp[day][p.op_type]) tmp[day][p.op_type] = [];
+ const g = String(p.group_id);
+ if (!tmp[day][p.op_type].includes(g)) tmp[day][p.op_type].push(g);
+ });
+ const m = {};
+ Object.keys(tmp).forEach(day => {
+ m[day] = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] }));
+ });
+ setDayMap(m);
+ });
}, []);
const today = new Date();
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import axios from 'axios';
+import { api } from '../api';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+import { getWordsByGroup, putProgress, getLearnableGroups } from '../db';
+import { triggerAutoSync } from '../sync';
import './StudyMode.css';
-const API_BASE = "http://124.222.157.124:1060";
-
function StudyMode() {
const { groupId } = useParams();
const navigate = useNavigate();
+ const isOnline = useOnlineStatus();
const [words, setWords] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [phase, setPhase] = useState('learning'); // learning, flashcard, correction
// 朗读期间 isSpeaking=true,阻止进入闪记模式
const speak = useCallback((text) => {
if (!text) return;
- const url = `${API_BASE}/api/tts/${encodeURIComponent(text)}`;
+ const url = api.ttsUrl(text);
setIsSpeaking(true);
const finish = () => setIsSpeaking(false);
const audio1 = new Audio(url);
};
}, []);
- // 获取单词数据
+ // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习)
+ useEffect(() => {
+ if (isOnline) return; // 在线时所有组均可学
+ getLearnableGroups().then(groups => {
+ if (!groups.includes(Number(groupId))) {
+ alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
+ navigate('/');
+ }
+ });
+ }, [isOnline, groupId, navigate]);
+
+ // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
useEffect(() => {
- axios.get(`${API_BASE}/api/words/${groupId}`).then(res => setWords(res.data));
+ api.getWords(groupId)
+ .then(res => setWords(res.data))
+ .catch(async () => {
+ const local = await getWordsByGroup(groupId);
+ setWords(local);
+ });
}, [groupId]);
// 自动朗读逻辑
} else {
if (submittedRef.current) return; // 防重入(按住Enter等场景)
submittedRef.current = true;
- await axios.post(`${API_BASE}/api/progress/update`, {
- group_id: groupId,
- op_type: 'study'
- });
+ const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
+ // 本地优先: 先写本地 dirty (必定保存, 不丢记录)
+ await putProgress(groupId, 'study', now, true);
+ // 在线则后台自动同步 (成功清 dirty, 失败保留)
+ if (isOnline) triggerAutoSync();
alert("恭喜完成本组学习!");
navigate('/');
}
setPhase('correction');
setCorrectionInput("");
// 错误时读一遍正确发音提醒
- const audio = new Audio(`${API_BASE}/api/tts/${encodeURIComponent(currentWord.word)}`);
+ const audio = new Audio(api.ttsUrl(currentWord.word));
audio.play().catch(err => console.error("TTS play failed:", err));
}
};
// 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊
const blockClipboard = (e) => e.preventDefault();
+ // 只保留 ASCII 可见字符并转小写,过滤中文/全角/emoji
+ // iPad Safari 横屏可能默认中文输入法联想,此过滤保证最终值始终为英文小写
+ const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
+
// 输入框统一属性:强制英文输入、禁用中文输入法/自动大写/自动补全/粘贴
const inputProps = {
type: "text",
{phase !== 'flashcard' && (
<div className="image-section">
{currentWord.picture_id && (
- <img src={`${API_BASE}/api/picture/${currentWord.picture_id}`} alt="mindmap" />
+ <img src={api.pictureUrl(currentWord.picture_id)} alt="mindmap" />
)}
</div>
)}
<input
autoFocus
value={userInput}
- onChange={(e) => setUserInput(e.target.value)}
+ onChange={(e) => setUserInput(sanitize(e.target.value))}
onKeyDown={(e) => e.key === 'Enter' && checkFlashcard()}
placeholder="输入英文单词..."
{...inputProps}
<p>请抄写一遍以解锁:</p>
<input
autoFocus
- className={correctionInput === currentWord.word ? "correct" : "typing"}
+ className={correctionInput.toLowerCase() === currentWord.word.toLowerCase() ? "correct" : "typing"}
value={correctionInput}
- onChange={(e) => setCorrectionInput(e.target.value)}
+ onChange={(e) => setCorrectionInput(sanitize(e.target.value))}
onKeyDown={(e) => {
- if (e.key === 'Enter' && correctionInput === currentWord.word) {
+ if (e.key === 'Enter' && correctionInput.toLowerCase() === currentWord.word.toLowerCase()) {
retryFlashcard();
}
}}
placeholder="请在此抄写..."
{...inputProps}
/>
- {correctionInput === currentWord.word && (
+ {correctionInput.toLowerCase() === currentWord.word.toLowerCase() && (
<button className="action-btn" onClick={retryFlashcard}>
重新尝试 (Enter)
</button>
--- /dev/null
+/* 顶部全宽细横条 (离线优先 / 自动同步状态提示) */
+/* 用 sticky 让横条在文档流顶部, 自动把主体内容往下推, 不遮挡 */
+.sync-bar {
+ position: sticky;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 8px 16px;
+ font-size: 14px;
+ font-weight: 600;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
+ flex-wrap: wrap;
+}
+
+.sync-text {
+ text-align: center;
+}
+
+/* 离线无新进度: 浅蓝灰背景, 深灰文字 */
+.sync-state-offline {
+ background: #eef2f7;
+ color: #475569;
+}
+
+/* 离线有新进度: 浅黄/浅橙背景, 深橙文字 */
+.sync-state-offline-dirty {
+ background: #fef3c7;
+ color: #b45309;
+}
+
+/* 正在上传 / 合并 / 初始化: 浅蓝背景, 深蓝文字 */
+.sync-state-upload,
+.sync-state-merge,
+.sync-state-init {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+/* 同步完成: 浅绿背景, 深绿文字 */
+.sync-state-synced {
+ background: #dcfce7;
+ color: #15803d;
+}
+
+/* 同步失败: 浅红背景, 深红文字 */
+.sync-state-failed {
+ background: #fee2e2;
+ color: #b91c1c;
+}
+
+/* 重试 / 重新初始化 按钮 */
+.sync-retry-btn {
+ background: #b91c1c;
+ color: #fff;
+ border: none;
+ padding: 4px 14px;
+ border-radius: 4px;
+ font-weight: 700;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.sync-retry-btn:active {
+ opacity: 0.85;
+}
--- /dev/null
+import React, { useState, useEffect, useRef } from 'react';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+import { getDirtyProgress, getMeta } from '../db';
+import { triggerAutoSync, subscribeSyncStatus, getSyncStatus } from '../sync';
+import './SyncNotifications.css';
+
+// 顶部全宽细横条: 离线优先 + 自动同步的状态提示
+// 状态: 离线无新进度 / 离线有新进度 / 正在上传 / 正在合并 / 同步完成 / 同步失败 / 首次初始化
+// 默认自动同步, 不弹窗; 仅同步失败显示"重试同步", 首次初始化失败显示"重新初始化"
+export default function SyncNotifications() {
+ const isOnline = useOnlineStatus();
+ const [dirtyCount, setDirtyCount] = useState(0);
+ const [initialized, setInitialized] = useState(true);
+ const [status, setStatus] = useState(getSyncStatus());
+ const [showSynced, setShowSynced] = useState(false);
+ const hideTimer = useRef(null);
+ const wasOffline = useRef(!navigator.onLine);
+
+ // 轮询 dirty / 初始化状态
+ useEffect(() => {
+ const check = async () => {
+ setDirtyCount((await getDirtyProgress()).length);
+ setInitialized(!!(await getMeta('lastSyncAt')));
+ };
+ check();
+ const timer = setInterval(check, 5000);
+ return () => clearInterval(timer);
+ }, []);
+
+ // 订阅同步状态机
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return subscribeSyncStatus((s) => {
+ setStatus(s);
+ if (s.state === 'synced') {
+ setShowSynced(true);
+ if (hideTimer.current) clearTimeout(hideTimer.current);
+ hideTimer.current = setTimeout(() => setShowSynced(false), 4000);
+ } else {
+ setShowSynced(false);
+ }
+ });
+ }, []);
+
+ // 离线→联网恢复: 立即自动同步 (优先上传本地 dirty, 再合并云端)
+ useEffect(() => {
+ if (!isOnline) { wasOffline.current = true; return; }
+ if (wasOffline.current) triggerAutoSync();
+ wasOffline.current = false;
+ }, [isOnline]);
+
+ const isSunday = new Date().getDay() === 0;
+ const retry = () => triggerAutoSync();
+
+ // ---- 决定当前显示哪条横条 (优先级从高到低) ----
+ let cls = ''; // 颜色类
+ let text = null;
+ let button = null;
+
+ if (status.state === 'syncing') {
+ if (!initialized) {
+ cls = 'sync-state-init';
+ text = '正在初始化离线学习数据,请稍候。';
+ } else if (status.detail && status.detail.phase === 'upload') {
+ cls = 'sync-state-upload';
+ text = `正在同步离线进度:本地 ${status.detail.dirty} 条待上传,请不要关闭页面。`;
+ } else {
+ cls = 'sync-state-merge';
+ text = '正在合并云端最新进度。';
+ }
+ } else if (status.state === 'failed') {
+ cls = 'sync-state-failed';
+ text = '同步失败,本地进度已保存,请稍后重试。';
+ button = (
+ <button className="sync-retry-btn" onClick={retry}>
+ {initialized ? '重试同步' : '重新初始化'}
+ </button>
+ );
+ } else if (showSynced) {
+ cls = 'sync-state-synced';
+ const d = status.detail || {};
+ const extra = (d.pushed || 0) + (d.pulled || 0) > 0
+ ? `(推送 ${d.pushed || 0} 条,合并 ${d.pulled || 0} 条)`
+ : '';
+ text = `同步完成,已合并最新进度${extra}。`;
+ } else if (!isOnline) {
+ if (dirtyCount > 0) {
+ cls = 'sync-state-offline-dirty';
+ text = `离线学习中,已有 ${dirtyCount} 条进度待同步,联网后会自动同步。`;
+ } else {
+ cls = 'sync-state-offline';
+ text = '当前为离线模式,可使用已缓存课程。';
+ }
+ } else if (isSunday) {
+ cls = 'sync-state-offline';
+ text = '📅 今日周日,建议连接网络同步进度。';
+ }
+
+ if (!text) return null;
+
+ return (
+ <div className={`sync-bar ${cls}`}>
+ <span className="sync-text">{text}</span>
+ {button}
+ </div>
+ );
+}
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
-import axios from 'axios';
+import { api } from '../api';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+import { getWordsByGroup, putProgress, getLearnableGroups } from '../db';
+import { triggerAutoSync } from '../sync';
import './TestMode.css';
-const API_BASE = "http://124.222.157.124:1060";
-
function TestMode() {
const { groupId } = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test)
const activityType = searchParams.get('type') || 'test';
+ const isOnline = useOnlineStatus();
const [words, setWords] = useState([]);
const [queue, setQueue] = useState([]); // 当前测试队列
const [wrongQueue, setWrongQueue] = useState([]); // 错词重考队列
const submittedRef = useRef(false); // 防止完成时重复提交进度
const currentWord = queue[currentIndex];
- // 获取单词数据
+ // 离线时检查可学习范围 (规格 7: 仅未完成组 + 最后完成组后5组可离线学习)
useEffect(() => {
- axios.get(`${API_BASE}/api/words/${groupId}`).then(res => {
- setWords(res.data);
+ if (isOnline) return;
+ getLearnableGroups().then(groups => {
+ if (!groups.includes(Number(groupId))) {
+ alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
+ navigate('/');
+ }
});
+ }, [isOnline, groupId, navigate]);
+
+ // 获取单词数据 (在线走网络, 离线降级读 IndexedDB)
+ useEffect(() => {
+ api.getWords(groupId)
+ .then(res => setWords(res.data))
+ .catch(async () => {
+ const local = await getWordsByGroup(groupId);
+ setWords(local);
+ });
}, [groupId]);
// 开始测试:随机打乱
// 朗读:通过后端 TTS 代理(有道美式发音)播放一遍
const speak = useCallback((text) => {
if (!text) return;
- const audio = new Audio(`${API_BASE}/api/tts/${encodeURIComponent(text)}`);
+ const audio = new Audio(api.ttsUrl(text));
audio.play().catch(err => console.error("TTS play failed:", err));
}, []);
const finishTest = useCallback(async () => {
if (submittedRef.current) return; // 防重入(按住Enter等场景)
submittedRef.current = true;
- await axios.post(`${API_BASE}/api/progress/update`, {
- group_id: groupId,
- op_type: activityType
- });
+ const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
+ // 本地优先: 先写本地 dirty (必定保存, 不丢记录)
+ await putProgress(groupId, activityType, now, true);
+ // 在线则后台自动同步 (成功清 dirty, 失败保留)
+ if (isOnline) triggerAutoSync();
navigate('/');
- }, [groupId, navigate, activityType]);
+ }, [groupId, navigate, activityType, isOnline]);
// 键盘事件
const handleKeyDown = useCallback((e) => {
{/* 图片 */}
{currentWord.picture_id && (
<div className="fb-image">
- <img src={`${API_BASE}/api/picture/${currentWord.picture_id}`} alt="mindmap" />
+ <img src={api.pictureUrl(currentWord.picture_id)} alt="mindmap" />
</div>
)}
</div>
import React, { useState } from 'react';
-import axios from 'axios';
+import { api } from '../api';
function UploadPicture() {
const [file, setFile] = useState(null);
formData.append('file', file);
try {
- await axios.post('http://127.0.0.1:1060/api/picture/', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
- });
+ await api.uploadPicture(formData);
alert('Picture uploaded successfully!');
} catch (error) {
alert('Error uploading picture: ' + error.message);
--- /dev/null
+// IndexedDB 封装 for PWA 离线学习
+// 三个 store:
+// - words: 全量词库, keyPath=id, 索引 groupid (供按组查询)
+// - progress: 离线进度, keyPath=`${group_id}:${op_type}`, {key, group_id, op_type, completed_at, dirty}
+// - meta: 元数据, keyPath=key (lastSyncAt, learnableGroups[])
+
+const DB_NAME = 'words-pwa';
+const DB_VERSION = 1;
+
+let dbPromise = null;
+
+function openDB() {
+ if (dbPromise) return dbPromise;
+ dbPromise = new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
+ req.onupgradeneeded = (e) => {
+ const db = e.target.result;
+ if (!db.objectStoreNames.contains('words')) {
+ db.createObjectStore('words', { keyPath: 'id' })
+ .createIndex('groupid', 'groupid', { unique: false });
+ }
+ if (!db.objectStoreNames.contains('progress')) {
+ db.createObjectStore('progress', { keyPath: 'key' });
+ }
+ if (!db.objectStoreNames.contains('meta')) {
+ db.createObjectStore('meta', { keyPath: 'key' });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+ return dbPromise;
+}
+
+// 通用: 单 store 事务执行, 返回主请求结果
+async function singleReq(store, mode, fn) {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const t = db.transaction(store, mode);
+ const req = fn(t.objectStore(store));
+ t.oncomplete = () => resolve(req.result);
+ t.onerror = () => reject(t.error);
+ });
+}
+
+// ---------- words store ----------
+
+export async function putWords(wordsArr) {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const t = db.transaction('words', 'readwrite');
+ const s = t.objectStore('words');
+ wordsArr.forEach(w => s.put(w));
+ t.oncomplete = () => resolve();
+ t.onerror = () => reject(t.error);
+ });
+}
+
+export async function getWordsByGroup(groupId) {
+ // groupid 在 IDB 是数字, URL 参数是字符串, 统一转 Number 查索引
+ return singleReq('words', 'readonly', s =>
+ s.index('groupid').getAll(IDBKeyRange.only(Number(groupId)))
+ );
+}
+
+export async function getWordsCount() {
+ return singleReq('words', 'readonly', s => s.count());
+}
+
+export async function getAllWordsFromIDB() {
+ return singleReq('words', 'readonly', s => s.getAll());
+}
+
+// ---------- progress store ----------
+
+export async function getProgress(group_id, op_type) {
+ return singleReq('progress', 'readonly', s => s.get(`${group_id}:${op_type}`));
+}
+
+export async function getAllProgress() {
+ return singleReq('progress', 'readonly', s => s.getAll());
+}
+
+export async function putProgress(group_id, op_type, completed_at, dirty) {
+ const key = `${group_id}:${op_type}`;
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const t = db.transaction('progress', 'readwrite');
+ t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty });
+ t.oncomplete = () => resolve();
+ t.onerror = () => reject(t.error);
+ });
+}
+
+export async function getDirtyProgress() {
+ const all = await getAllProgress();
+ return all.filter(p => p.dirty);
+}
+
+export async function clearDirty(group_id, op_type) {
+ const key = `${group_id}:${op_type}`;
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const t = db.transaction('progress', 'readwrite');
+ const s = t.objectStore('progress');
+ const req = s.get(key);
+ req.onsuccess = () => {
+ if (req.result) { req.result.dirty = false; s.put(req.result); }
+ };
+ t.oncomplete = () => resolve();
+ t.onerror = () => reject(t.error);
+ });
+}
+
+// ---------- meta store ----------
+
+export async function getMeta(key) {
+ const r = await singleReq('meta', 'readonly', s => s.get(key));
+ return r ? r.value : undefined;
+}
+
+export async function setMeta(key, value) {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const t = db.transaction('meta', 'readwrite');
+ t.objectStore('meta').put({ key, value });
+ t.oncomplete = () => resolve();
+ t.onerror = () => reject(t.error);
+ });
+}
+
+export async function getLearnableGroups() {
+ return (await getMeta('learnableGroups')) || [];
+}
+
+export async function setLearnableGroups(groups) {
+ return setMeta('learnableGroups', groups);
+}
+
+// 已初始化 = 本地已完成过一次云端初始化 (lastSyncAt 存在)
+// 首页/组件用它判断, 而不是"能不能构造出首页表格"
+export async function isInitialized() {
+ return !!(await getMeta('lastSyncAt'));
+}
--- /dev/null
+import { useState, useEffect } from 'react';
+import { API_BASE } from '../api';
+
+// 返回当前是否联网
+// navigator.onLine + online/offline 事件在 iOS Safari 不可靠 (飞行模式切换不触发)
+// 因此额外每 5s 主动探测一次网络, fetch 失败则判定离线
+export function useOnlineStatus() {
+ const [isOnline, setIsOnline] = useState(navigator.onLine);
+
+ useEffect(() => {
+ const on = () => setIsOnline(true);
+ const off = () => setIsOnline(false);
+ window.addEventListener('online', on);
+ window.addEventListener('offline', off);
+
+ // 主动探测: navigator.onLine 在 iOS Safari 误判时兜底
+ const probe = async () => {
+ try {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), 2500);
+ const r = await fetch(`${API_BASE}/api/home`, { signal: ctrl.signal });
+ clearTimeout(t);
+ setIsOnline(r.ok);
+ } catch (e) {
+ setIsOnline(false);
+ }
+ };
+ probe();
+ const timer = setInterval(probe, 5000);
+ return () => {
+ window.removeEventListener('online', on);
+ window.removeEventListener('offline', off);
+ clearInterval(timer);
+ };
+ }, []);
+
+ return isOnline;
+}
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+
+// 注册 Service Worker (PWA 离线支持)
+if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').catch(err => console.error('SW registration failed:', err));
+ });
+}
--- /dev/null
+// 离线优先 + 自动同步 统一入口
+// 数据规则 (布尔并集): (group,op) 任一方完成即完成; 不删本地、不比时间
+// dirty = "这条本地完成记录尚未确认上传到云端"
+// syncNow: 上传本地 dirty → 拉云端并集合并 → 更新 lastSyncAt → 重算 learnable
+// triggerAutoSync: 幂等并发锁, 供首页/联网恢复/完成学习 调用, 失败时置 failed
+
+import { api } from './api';
+import {
+ getDirtyProgress, clearDirty, getAllProgress, putProgress,
+ putWords, setMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount,
+} from './db';
+
+const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
+
+// ---------- 同步状态机 (供 Home / SyncNotifications 共享) ----------
+// state: 'idle' | 'syncing' | 'synced' | 'failed'
+// detail: { phase: 'upload' | 'merge', dirty, message, pushed, pulled }
+const listeners = new Set();
+let syncStatus = { state: 'idle', detail: null };
+
+export function getSyncStatus() {
+ return syncStatus;
+}
+
+export function subscribeSyncStatus(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+}
+
+function setSyncStatus(state, detail) {
+ syncStatus = { state, detail: detail || null };
+ listeners.forEach(fn => { try { fn(syncStatus); } catch (e) {} });
+}
+
+// ---------- 核心同步 ----------
+export async function syncNow() {
+ const result = { pushed: 0, pulled: 0, warmed: 0, pushedEvents: [], pulledEvents: [] };
+
+ // 阶段1: 上传本地 dirty (严格按 dirty 推送, 非 dirty 视为已确认)
+ const dirty = await getDirtyProgress();
+ setSyncStatus('syncing', { phase: 'upload', dirty: dirty.length });
+ if (dirty.length > 0) {
+ const events = dirty.map(p => ({
+ group_id: p.group_id,
+ op_type: p.op_type,
+ completed_at: p.completed_at,
+ }));
+ const r = await api.syncPush(events);
+ result.pushed = r.data.updated || events.length;
+ result.pushedEvents = events;
+ // 仅清"已确认上传"的 dirty (云端最终存在该 group/op 即清)
+ await Promise.all(dirty.map(p => clearDirty(p.group_id, p.op_type)));
+ }
+
+ // 阶段2: 拉云端进度, 布尔并集合并 (只补不覆盖, 不动本地已有)
+ setSyncStatus('syncing', { phase: 'merge', dirty: 0 });
+ const pullResp = await api.syncPull();
+ const serverEvents = pullResp.data.events || [];
+ const localAll = await getAllProgress();
+ const localKeys = new Set(localAll.map(p => p.key));
+ for (const ev of serverEvents) {
+ const key = `${ev.group_id}:${ev.op_type}`;
+ if (!localKeys.has(key)) {
+ await putProgress(ev.group_id, ev.op_type, ev.completed_at, false);
+ result.pulled++;
+ result.pulledEvents.push(ev);
+ }
+ }
+
+ // 阶段3: 首次 warmup 词库 (仅 IDB 空时, 避免每次重拉 1852 词)
+ if ((await getWordsCount()) === 0) {
+ const allWords = await api.getAllWords();
+ await putWords(allWords.data);
+ result.warmed = allWords.data.length;
+ }
+
+ // 阶段4: 更新 lastSyncAt + 重算可学习范围
+ await setMeta('lastSyncAt', new Date().toISOString());
+ await recomputeLearnableGroups();
+
+ setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
+ return result;
+}
+
+let autoSyncRunning = false;
+
+// 幂等自动同步: 并发调用只会跑一次; 失败置 failed (本地进度仍保留)
+export async function triggerAutoSync() {
+ if (autoSyncRunning) return;
+ autoSyncRunning = true;
+ try {
+ await syncNow();
+ } catch (e) {
+ setSyncStatus('failed', { message: (e && e.message) || String(e) });
+ } finally {
+ autoSyncRunning = false;
+ }
+}
+
+// 重算可学习范围: 未完全完成的组 + 最后完成组之后的 5 组
+export async function recomputeLearnableGroups() {
+ const allWords = await getAllWordsFromIDB();
+ if (!allWords || allWords.length === 0) {
+ await setLearnableGroups([]);
+ return;
+ }
+ const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
+ const progress = await getAllProgress();
+ const completedByGroup = {};
+ progress.forEach(p => {
+ const g = Number(p.group_id);
+ if (!completedByGroup[g]) completedByGroup[g] = new Set();
+ completedByGroup[g].add(p.op_type);
+ });
+ const isComplete = (g) => {
+ const ops = completedByGroup[g];
+ return !!ops && OPS.every(op => ops.has(op));
+ };
+ const learnable = new Set();
+ let maxComplete = -1;
+ allGroups.forEach(g => {
+ if (isComplete(g)) {
+ if (g > maxComplete) maxComplete = g;
+ } else {
+ learnable.add(g);
+ }
+ });
+ // 最后完成组后的 5 组
+ if (maxComplete >= 0) {
+ const idx = allGroups.indexOf(maxComplete);
+ for (let i = 1; i <= 5; i++) {
+ if (idx + i < allGroups.length) learnable.add(allGroups[idx + i]);
+ }
+ }
+ await setLearnableGroups(Array.from(learnable).sort((a, b) => a - b));
+}
+
+const OP_LABEL = { study: '学习', test: '测试', review1: '复习I', review2: '复习II', review3: '复习III' };
+
+// 格式化同步结果为可读提示 (保留, 用于失败时/手动诊断)
+export function formatSyncResult(r) {
+ const fmt = (e) => `${(e.completed_at || '').slice(0, 10)} Group${e.group_id} ${OP_LABEL[e.op_type] || e.op_type}`;
+ const lines = [];
+ if (r.pushed > 0) {
+ lines.push(`推送 ${r.pushed} 条进度:`);
+ r.pushedEvents.forEach(e => lines.push(' ' + fmt(e)));
+ }
+ if (r.pulled > 0) {
+ lines.push(`拉取 ${r.pulled} 条进度:`);
+ r.pulledEvents.forEach(e => lines.push(' ' + fmt(e)));
+ }
+ if (r.pushed === 0 && r.pulled === 0) {
+ lines.push('无新进度,已是最新');
+ }
+ return '同步完成:\n' + lines.join('\n');
+}