From 11fff5b1fbab0b22c88e865e0ecaa40b38c4cdfc Mon Sep 17 00:00:00 2001 From: root Date: Thu, 13 Aug 2026 17:38:47 +0800 Subject: [PATCH] =?utf8?q?feat:=20PWA=20=E7=A6=BB=E7=BA=BF=E4=BC=98?= =?utf8?q?=E5=85=88=E5=90=8C=E6=AD=A5=E6=96=B9=E6=A1=88=EF=BC=889+11=20?= =?utf8?q?=E8=A7=84=E6=A0=BC=EF=BC=89+=20=E6=97=A5=E5=8E=86=E6=97=A5?= =?utf8?q?=E6=9C=9F=E4=BF=AE=E5=A4=8D=20+=20=E4=BA=91=E7=AB=AF=E6=97=A5?= =?utf8?q?=E5=BF=97=E9=87=8D=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit ## 任务一: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 --- backend/db/bcd.db | Bin 9457664 -> 9461760 bytes backend/main.py | 124 ++++++++++++-- frontend/public/index.html | 4 +- frontend/public/manifest.json | 8 +- frontend/public/sw.js | 80 +++++++++ frontend/src/App.js | 2 + frontend/src/api.js | 28 ++++ frontend/src/components/GroupView.js | 16 +- frontend/src/components/Home.js | 101 ++++++++++-- frontend/src/components/StudyCalendar.js | 28 +++- frontend/src/components/StudyMode.js | 57 +++++-- frontend/src/components/SyncNotifications.css | 70 ++++++++ frontend/src/components/SyncNotifications.js | 107 ++++++++++++ frontend/src/components/TestMode.js | 43 +++-- frontend/src/components/UploadPicture.js | 8 +- frontend/src/db.js | 144 ++++++++++++++++ frontend/src/hooks/useOnlineStatus.js | 38 +++++ frontend/src/index.js | 7 + frontend/src/sync.js | 156 ++++++++++++++++++ 19 files changed, 938 insertions(+), 83 deletions(-) create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/SyncNotifications.css create mode 100644 frontend/src/components/SyncNotifications.js create mode 100644 frontend/src/db.js create mode 100644 frontend/src/hooks/useOnlineStatus.js create mode 100644 frontend/src/sync.js diff --git a/backend/db/bcd.db b/backend/db/bcd.db index 8a40327e441909e8a9060075f4217e7c1e97d254..7ad8e579f9e96bcc4624b4083b08f5a06ea59c57 100644 GIT binary patch delta 8990 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 VPA8_X_PFbl7cm!Z=5+g_{|6e7*)9M8 delta 7335 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 HyN>)DE2p7k diff --git a/backend/main.py b/backend/main.py index 28f4641..c027419 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"} diff --git a/frontend/public/index.html b/frontend/public/index.html index aa069f2..9665d2f 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -4,7 +4,7 @@ - + - React App + 单词学习 diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 080d6c7..6bdc64b 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -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 index 0000000..4888e89 --- /dev/null +++ b/frontend/public/sw.js @@ -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'); + } +} diff --git a/frontend/src/App.js b/frontend/src/App.js index 205187a..9a9dc3b 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 ( + } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..ff5be5e --- /dev/null +++ b/frontend/src/api.js @@ -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 走 /new Audio(),不走 axios;SW 仍能拦截缓存 + pictureUrl: (picId) => `${API_BASE}/api/picture/${picId}`, + ttsUrl: (word) => `${API_BASE}/api/tts/${encodeURIComponent(word)}`, +}; diff --git a/frontend/src/components/GroupView.js b/frontend/src/components/GroupView.js index c0cc8ff..317bcb5 100644 --- a/frontend/src/components/GroupView.js +++ b/frontend/src/components/GroupView.js @@ -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 && (
- mindmap + mindmap
)} diff --git a/frontend/src/components/Home.js b/frontend/src/components/Home.js index afe151a..20ba64d 100644 --- a/frontend/src/components/Home.js +++ b/frontend/src/components/Home.js @@ -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() {

+ {needInit && ( +
+

首次使用需要联网初始化一次,请连接网络后重新打开。

+
+ )} + + {blocking && ( +
同步完成后再开始新的学习
+ )} +
diff --git a/frontend/src/components/SyncNotifications.css b/frontend/src/components/SyncNotifications.css new file mode 100644 index 0000000..03c472b --- /dev/null +++ b/frontend/src/components/SyncNotifications.css @@ -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 index 0000000..c273619 --- /dev/null +++ b/frontend/src/components/SyncNotifications.js @@ -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 = ( + + ); + } 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 ( +
+ {text} + {button} +
+ ); +} diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js index 528e7d1..8f93e94 100644 --- a/frontend/src/components/TestMode.js +++ b/frontend/src/components/TestMode.js @@ -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 && (
- mindmap + mindmap
)}
diff --git a/frontend/src/components/UploadPicture.js b/frontend/src/components/UploadPicture.js index 48cb6f3..c5e46ec 100644 --- a/frontend/src/components/UploadPicture.js +++ b/frontend/src/components/UploadPicture.js @@ -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 index 0000000..9eccef7 --- /dev/null +++ b/frontend/src/db.js @@ -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 index 0000000..3c3b5ff --- /dev/null +++ b/frontend/src/hooks/useOnlineStatus.js @@ -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; +} diff --git a/frontend/src/index.js b/frontend/src/index.js index d563c0f..d3e9525 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -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 index 0000000..31ffcd2 --- /dev/null +++ b/frontend/src/sync.js @@ -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'); +} -- 2.43.0