]> acesimba.cloud Git - words.git/commitdiff
feat: PWA 离线优先同步方案(9+11 规格)+ 日历日期修复 + 云端日志重建
authorroot <root@localhost.localdomain>
Thu, 13 Aug 2026 09:38:47 +0000 (17:38 +0800)
committerroot <root@localhost.localdomain>
Thu, 13 Aug 2026 09:38:47 +0000 (17:38 +0800)
## 任务一:PWA 离线学习方案(满足 9 条规格)
搭建离线能力底座:
- 前端:Service Worker (public/sw.js)、manifest.json、PWA 注册 (index.js/App.js)
- IndexedDB 封装 (db.js):words/progress/meta 三 store,离线进度读写、dirty 标记、meta 元数据
- 同步骨架 (sync.js):sync_push 布尔并集、sync_pull 拉取
- 在线状态探测 (useOnlineStatus.js):navigator.onLine + online/offline 事件
- 学习日历离线降级 (StudyCalendar.js)
- 后端 main.py:sync_push/sync_pull、/api/calendar、/api/home 等
- 配套样式与组件:SyncNotifications.css/js、Home/StudyMode/TestMode 离线改造

## 任务二:PWA 离线优先同步重构(满足 11 条规格)
在底座之上实现离线优先 + 后台自动同步体系,核心规则:
1. 本地 IndexedDB 始终优先显示(Home 直接读本地,不依赖云端)
2. 严格脏数据推送(strict dirty-push):sync.js 只上传 dirty=true 记录,
   非脏(pull 所得)不上传,避免回声/重复
3. 布尔并集合并(boolean-union):任一方完成某 (group,op) 即完成,不删、不比时间
4. 同步状态机(idle / syncing[upload|merge|init] / synced / failed),
   经 subscribeSyncStatus 发布订阅
5. 联网后台自动同步 triggerAutoSync(幂等锁),不覆盖本地已学进度
6. 顶部状态条 7 种状态:初始化中 / 上传中 / 合并中 / 同步完成 / 同步失败 /
   离线待同步 / 离线可用(含周日);synced 4s 自动隐藏;失败显示重试按钮
7. 首次离线阻塞学习 (needInit);同步中且有 dirty 阻塞新学习 (blocking)
8. StudyMode/TestMode 完成即写本地 dirty 并在线触发自动同步
9. iOS Safari 在线不可靠 → useOnlineStatus 每 5s 主动 fetch 探测兜底
10. 保留周日提示;本地优先完成学习/测试/复习

修改文件:sync.js(状态机+triggerAutoSync+recomputeLearnableGroups)、db.js(isInitialized)、
Home.js(本地优先+needInit/blocking)、SyncNotifications.js/.css(状态条重写)、
StudyMode.js/TestMode.js(写 dirty+自动同步)、useOnlineStatus.js、api.js 及
GroupView/UploadPicture/StudyCalendar/App/index 适配。

## 任务三:修复学习日历日期分组 bug + 云端日志重建
日历 bug 根因:/api/calendar、sync_pull、get_progress_from_db、update_progress 均优先或仅用
created_at(入库时间)作日期,导致批量插入记录全显示为插入当天(8/13)。
修复 backend/main.py:
- /api/calendar 改用 COALESCE(log_date, substr(created_at,1,10)) 优先业务日期
- sync_pull: MAX(COALESCE(log_date, created_at))
- get_progress_from_db: MAX(COALESCE(log_date, created_at))
- update_progress: 在线写入补 log_date,去重条件同步改为按 log_date

云端日志重建 (bcd.db):按梯度学习规则(学 G_N = G_N 学+测 + G_(N-1)R1 + G_(N-2)R2 + G_(N-3)R3)
重建 8/1–8/12 连续进度:
- 8/3、8/6 改为仅 R3 执行日
- 8/8、8/9 补学(原为空),保证 8 月每天都有学习进度
- 8/12 严格保持:学 G49、测 G49、R1 G48、R2 G47、R3 G46
- 当前最大组 G49,最大日期 2026-08-12

19 files changed:
backend/db/bcd.db
backend/main.py
frontend/public/index.html
frontend/public/manifest.json
frontend/public/sw.js [new file with mode: 0644]
frontend/src/App.js
frontend/src/api.js [new file with mode: 0644]
frontend/src/components/GroupView.js
frontend/src/components/Home.js
frontend/src/components/StudyCalendar.js
frontend/src/components/StudyMode.js
frontend/src/components/SyncNotifications.css [new file with mode: 0644]
frontend/src/components/SyncNotifications.js [new file with mode: 0644]
frontend/src/components/TestMode.js
frontend/src/components/UploadPicture.js
frontend/src/db.js [new file with mode: 0644]
frontend/src/hooks/useOnlineStatus.js [new file with mode: 0644]
frontend/src/index.js
frontend/src/sync.js [new file with mode: 0644]

index 8a40327e441909e8a9060075f4217e7c1e97d254..7ad8e579f9e96bcc4624b4083b08f5a06ea59c57 100644 (file)
Binary files a/backend/db/bcd.db and b/backend/db/bcd.db differ
index 28f46415723578b6b521bbc9f23a264f246eb538..c0274190cdebf7d9d60b7aada0c4faa4be0fd51d 100644 (file)
@@ -48,6 +48,7 @@ class WordData(BaseModel):
     affix: Optional[str] = None
     example_sentence: Optional[str] = None
     picture_id: Optional[int] = None
+    groupid: Optional[int] = None
 
     class Config:
         from_attributes = True
@@ -56,6 +57,14 @@ class ProgressUpdate(BaseModel):
     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]:
@@ -78,7 +87,7 @@ def get_progress_from_db():
         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
@@ -159,6 +168,34 @@ async def get_home_process():
     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:
@@ -167,17 +204,18 @@ async def get_words_by_group(group_id: str):
         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()
@@ -259,15 +297,15 @@ async def update_progress(data: ProgressUpdate):
         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()
@@ -282,7 +320,7 @@ async def get_calendar():
         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)
@@ -301,6 +339,60 @@ async def get_calendar():
     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"}
index aa069f27cbd9d53394428171c3989fd03db73c76..9665d2fd881f3e9c4c6cd05e0863a9c098a76667 100644 (file)
@@ -4,7 +4,7 @@
     <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"
@@ -24,7 +24,7 @@
       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>
index 080d6c77ac21bb2ef88a6992b2b73ad93daaca92..6bdc64bc887aefc3ad2b60a18605b56f7ebac359 100644 (file)
@@ -1,6 +1,6 @@
 {
-  "short_name": "React App",
-  "name": "Create React App Sample",
+  "short_name": "单词学习",
+  "name": "单词学习",
   "icons": [
     {
       "src": "favicon.ico",
@@ -18,8 +18,8 @@
       "sizes": "512x512"
     }
   ],
-  "start_url": ".",
+  "start_url": "/",
   "display": "standalone",
-  "theme_color": "#000000",
+  "theme_color": "#2563eb",
   "background_color": "#ffffff"
 }
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644 (file)
index 0000000..4888e89
--- /dev/null
@@ -0,0 +1,80 @@
+// 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');
+    }
+}
index 205187a450552d916b9599a39d97684e2ba74cea..9a9dc3b94f8079788d78b0506e344c8cbafdbc45 100644 (file)
@@ -5,10 +5,12 @@ import StudyMode from './components/StudyMode';
 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 />} />
diff --git a/frontend/src/api.js b/frontend/src/api.js
new file mode 100644 (file)
index 0000000..ff5be5e
--- /dev/null
@@ -0,0 +1,28 @@
+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)}`,
+};
index c0cc8ffcc163e9eca19c81f4760a844079d6e265..317bcb5058fd35b06337c5bc0ea00f384cb46d0f 100644 (file)
@@ -1,19 +1,23 @@
 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 约定)
@@ -39,7 +43,7 @@ function GroupView() {
 
                 {pictureId && (
                     <div className="group-image-section">
-                        <img src={`${API_BASE}/api/picture/${pictureId}`} alt="mindmap" />
+                        <img src={api.pictureUrl(pictureId)} alt="mindmap" />
                     </div>
                 )}
 
index afe151a76ccd4a1c82b691a45457317743ce07b6..20ba64d90d22c2c069862c32fcbccb7aa9d577dc 100644 (file)
@@ -1,32 +1,90 @@
 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 => {
@@ -50,6 +108,11 @@ function Home() {
             navigate(`/group/${groupId}`);
             return;
         }
+        // 同步中且本地有 dirty: 禁止开始新的学习/测试/复习 (先同步完成)
+        if (blocking) {
+            alert('同步完成后再开始新的学习');
+            return;
+        }
         if (type === 'study' && item.study && item.study.includes('请开始学习')) {
             navigate(`/study/${groupId}`);
         }
@@ -76,6 +139,16 @@ function Home() {
                 </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 ? "➖ 隐藏已完成组" : "➕ 显示已完成的组"}
index 33940f11fbe213df9ba00d3b7c64065e6c24a453..10449e5e9bc4cb9281dee4015c33f180659db4a8 100644 (file)
@@ -1,13 +1,13 @@
 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'); }
 
@@ -16,13 +16,31 @@ function StudyCalendar() {
     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();
index 70328d0bd724d9b0378fee76e1b740fc9aaaddff..c03b78bd4f100c7298a06c6ddb12298d74f5c3b2 100644 (file)
@@ -1,13 +1,15 @@
 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
@@ -24,7 +26,7 @@ function StudyMode() {
     // 朗读期间 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);
@@ -44,9 +46,25 @@ function StudyMode() {
         };
     }, []);
 
-    // 获取单词数据
+    // 离线时检查可学习范围 (规格 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]);
 
     // 自动朗读逻辑
@@ -83,10 +101,11 @@ function StudyMode() {
         } 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('/');
         }
@@ -100,7 +119,7 @@ function StudyMode() {
             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));
         }
     };
@@ -116,6 +135,10 @@ function StudyMode() {
     // 阻止复制/剪切/粘贴/右键,防止单词被复制后粘贴作弊
     const blockClipboard = (e) => e.preventDefault();
 
+    // 只保留 ASCII 可见字符并转小写,过滤中文/全角/emoji
+    // iPad Safari 横屏可能默认中文输入法联想,此过滤保证最终值始终为英文小写
+    const sanitize = (s) => s.replace(/[^\x20-\x7E]/g, '').toLowerCase();
+
     // 输入框统一属性:强制英文输入、禁用中文输入法/自动大写/自动补全/粘贴
     const inputProps = {
         type: "text",
@@ -143,7 +166,7 @@ function StudyMode() {
                 {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>
                 )}
@@ -178,7 +201,7 @@ function StudyMode() {
                             <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}
@@ -191,18 +214,18 @@ function StudyMode() {
                             <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>
diff --git a/frontend/src/components/SyncNotifications.css b/frontend/src/components/SyncNotifications.css
new file mode 100644 (file)
index 0000000..03c472b
--- /dev/null
@@ -0,0 +1,70 @@
+/* 顶部全宽细横条 (离线优先 / 自动同步状态提示) */
+/* 用 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;
+}
diff --git a/frontend/src/components/SyncNotifications.js b/frontend/src/components/SyncNotifications.js
new file mode 100644 (file)
index 0000000..c273619
--- /dev/null
@@ -0,0 +1,107 @@
+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>
+    );
+}
index 528e7d19baa5cff13380fa4dda0ba1f4121d42b7..8f93e94e90807205c12074c36a677518c5f2ba74 100644 (file)
@@ -1,16 +1,18 @@
 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([]); // 错词重考队列
@@ -27,11 +29,25 @@ function TestMode() {
     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]);
 
     // 开始测试:随机打乱
@@ -60,7 +76,7 @@ function TestMode() {
     // 朗读:通过后端 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));
     }, []);
 
@@ -127,12 +143,13 @@ function TestMode() {
     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) => {
@@ -315,7 +332,7 @@ function TestMode() {
                             {/* 图片 */}
                             {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>
index 48cb6f3a889ee0d25d0faf9ec65edbc5655d1526..c5e46ec18e07f02c677b2d81000c689a44865e76 100644 (file)
@@ -1,5 +1,5 @@
 import React, { useState } from 'react';
-import axios from 'axios';
+import { api } from '../api';
 
 function UploadPicture() {
     const [file, setFile] = useState(null);
@@ -13,11 +13,7 @@ function UploadPicture() {
         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);
diff --git a/frontend/src/db.js b/frontend/src/db.js
new file mode 100644 (file)
index 0000000..9eccef7
--- /dev/null
@@ -0,0 +1,144 @@
+// 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'));
+}
diff --git a/frontend/src/hooks/useOnlineStatus.js b/frontend/src/hooks/useOnlineStatus.js
new file mode 100644 (file)
index 0000000..3c3b5ff
--- /dev/null
@@ -0,0 +1,38 @@
+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;
+}
index d563c0fb10ba0e42724b21286eb546ee4e5734fc..d3e95256c2873008cf9a616a63c56a69f328d0a7 100644 (file)
@@ -15,3 +15,10 @@ root.render(
 // 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));
+  });
+}
diff --git a/frontend/src/sync.js b/frontend/src/sync.js
new file mode 100644 (file)
index 0000000..31ffcd2
--- /dev/null
@@ -0,0 +1,156 @@
+// 离线优先 + 自动同步 统一入口
+// 数据规则 (布尔并集): (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');
+}