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 && (
-

+
)}
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/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