From: lexicon Date: Thu, 27 Aug 2026 06:12:46 +0000 (+0800) Subject: split: 拆分组 new_group_id 功能(后端单位改为 new_group_id + 继承展开; 前端按 new_group_id 展示/学习/日历双计数... X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=2a625ea2737f0f0c4af6dd469b02c5c321049a48;p=words.git split: 拆分组 new_group_id 功能(后端单位改为 new_group_id + 继承展开; 前端按 new_group_id 展示/学习/日历双计数; 独立 DB 名与 /split 部署配置) --- diff --git a/backend/db/bcd.db b/backend/db/bcd.db index 98963a1..2d26243 100644 Binary files a/backend/db/bcd.db and b/backend/db/bcd.db differ diff --git a/backend/main.py b/backend/main.py index fd91b1f..b166364 100644 --- a/backend/main.py +++ b/backend/main.py @@ -107,22 +107,25 @@ class WordData(BaseModel): example_sentence: Optional[str] = None picture_id: Optional[int] = None groupid: Optional[int] = None + new_group_id: Optional[str] = None class Config: from_attributes = True class ProgressUpdate(BaseModel): - group_id: Union[str, int] + group_id: Optional[Union[str, int]] = None + new_group_id: Optional[str] = None op_type: str duration_sec: int = 0 @field_validator('group_id', mode='before') @classmethod def coerce_group_id(cls, v): - return str(v) + return str(v) if v is not None else None class SyncEvent(BaseModel): - group_id: Union[str, int] + group_id: Optional[Union[str, int]] = None + new_group_id: Optional[str] = None op_type: str completed_at: str duration_sec: int = 0 @@ -130,7 +133,7 @@ class SyncEvent(BaseModel): @field_validator('group_id', mode='before') @classmethod def coerce_group_id(cls, v): - return str(v) + return str(v) if v is not None else None class LoginRequest(BaseModel): @@ -182,6 +185,66 @@ def get_progress_from_db(): print(f"Database error in get_progress_from_db: {e}") return progress +def _split_parent_subunits(conn): + # 返回 {groupid: [new_group_id,...]} 仅含"被拆分的父组"(同一 groupid 对应 >1 个 new_group_id) + cur = conn.cursor() + cur.execute("SELECT groupid, new_group_id FROM words WHERE new_group_id IS NOT NULL") + by_parent = {} + for gid, ng in cur.fetchall(): + by_parent.setdefault(str(gid), []).append(ng) + return {g: subs for g, subs in by_parent.items() if len(set(subs)) > 1} + + +def get_effective_progress(): + # split 视图的"有效完成进度": + # 键 = new_group_id(拆分组); 值 = {op_type: (date, duration_sec)} + # 含"父组完成 → 各子单元继承"展开: 若某 groupid 被拆分, 且其本身已有某 op 完成记录, + # 则每个子单元 new_group_id 也视为完成(用于早期已学完的拆分组 G25/G46/G47 等)。 + if not os.path.exists(DATABASE_PATH): + return {} + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + # 真实记录: 按 new_group_id 聚合 + cur.execute(""" + SELECT new_group_id, op_type, MAX(COALESCE(log_date, created_at)), MAX(duration_sec) + FROM study_logs WHERE status='completed' AND new_group_id IS NOT NULL + GROUP BY new_group_id, op_type + """) + eff = {} + for ng, op, date, dur in cur.fetchall(): + eff.setdefault(ng, {})[op] = (date, dur or 0) + # 父组完成情况: 按 group_id 聚合 + cur.execute(""" + SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)) + FROM study_logs WHERE status='completed' GROUP BY group_id, op_type + """) + parent = {} + for gid, op, date in cur.fetchall(): + parent.setdefault(str(gid), {})[op] = date + subs = _split_parent_subunits(conn) + for gid, sub_list in subs.items(): + ops = parent.get(gid, {}) + if not ops: + continue + for ng in sub_list: + if ng not in eff: + eff[ng] = {} + for op, date in ops.items(): + if op not in eff[ng]: + eff[ng][op] = (date, 0) + conn.close() + return eff + + +def _ng_to_parent(ng): + # '69.1' -> 69 ; '55' -> 55 + try: + return int(str(ng).split('.')[0]) + except Exception: + return None + + # --- 路由实现 --- @app.get("/api/home", response_model=HomeData) @@ -200,41 +263,44 @@ async def get_home_data(): @app.get("/api/home/process", response_model=List[ProgressData]) async def get_home_process(): + # split 视图: 以 new_group_id(拆分组) 为行单位, 含父组→子单元继承展开 try: conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row cursor = conn.cursor() - cursor.execute("SELECT DISTINCT groupid FROM words ORDER BY CAST(groupid AS INTEGER) ASC") - all_groups = [str(row[0]) for row in cursor.fetchall()] + cursor.execute("SELECT DISTINCT new_group_id FROM words WHERE new_group_id IS NOT NULL ORDER BY CAST(new_group_id AS REAL) ASC") + all_ng = [r[0] for r in cursor.fetchall()] conn.close() - log_progress = get_progress_from_db() + eff = get_effective_progress() results = [] first_unstudied = None - for g in all_groups: - if g not in log_progress or not log_progress[g]['study']: - first_unstudied = g + for ng in all_ng: + if not eff.get(ng, {}).get('study'): + first_unstudied = ng break - for g_name in all_groups: - p = log_progress.get(g_name, {'study': None, 'test': None, 'review1': None, 'review2': None, 'review3': None}) - row = {"group": f"Group {g_name}"} - if p['study']: - row['study'] = f"✔️ {p['study'][:10]}" - elif g_name == first_unstudied: + for ng in all_ng: + p = eff.get(ng, {}) # 值为 (date, duration) 元组 + d = lambda v: (v[0][:10] if v and isinstance(v, (tuple, list)) and v[0] else '') + row = {"group": f"Group {ng}"} + if p.get('study'): + row['study'] = f"✔️ {d(p['study'])}" + elif ng == first_unstudied: row['study'] = "✅请开始学习" else: row['study'] = "" - if p['test']: - row['test'] = f"✔️ {p['test'][:10]}" - elif p['study']: + if p.get('test'): + row['test'] = f"✔️ {d(p['test'])}" + elif p.get('study'): row['test'] = "✅请开始测试" else: row['test'] = "" - rk, pk = ['review1', 'review2', 'review3'], ['test', 'review1', 'review2'] - for i, key in enumerate(rk): - prev_key = pk[i] - if p[key]: - row[key] = f"✔️ {p[key][:10]}" - elif p[prev_key]: - hours = calculate_hours_since(p[prev_key]) + prev_map = {'review1': 'test', 'review2': 'review1', 'review3': 'review2'} + for key in ['review1', 'review2', 'review3']: + prev = prev_map[key] + if p.get(key): + row[key] = f"✔️ {d(p[key])}" + elif p.get(prev): + hours = calculate_hours_since(d(p[prev])) row[key] = "⚠️请尽快复习" if hours and hours > 4 else "✅请开始复习" else: row[key] = "" @@ -261,9 +327,10 @@ async def get_all_words(): affix, example_sentence, picture_id, - groupid + groupid, + new_group_id FROM words - ORDER BY CAST(groupid AS INTEGER) ASC, ID ASC + ORDER BY CAST(new_group_id AS REAL) ASC, ID ASC """) rows = cursor.fetchall() conn.close() @@ -273,11 +340,11 @@ async def get_all_words(): @app.get("/api/words/{group_id}", response_model=List[WordData]) async def get_words_by_group(group_id: str): + # split 视图: group_id 参数实际传 new_group_id(如 '69.1'), 按 new_group_id 匹配 try: conn = sqlite3.connect(DATABASE_PATH) conn.row_factory = sqlite3.Row cursor = conn.cursor() - # 核心修复:将数据库的 ID 显式重命名为 id 以匹配 Pydantic 模型 cursor.execute(""" SELECT ID as id, @@ -288,9 +355,10 @@ async def get_words_by_group(group_id: str): affix, example_sentence, picture_id, - groupid + groupid, + new_group_id FROM words - WHERE CAST(groupid AS INTEGER) = ? + WHERE new_group_id = ? ORDER BY ID ASC """, (group_id,)) rows = cursor.fetchall() @@ -432,17 +500,23 @@ async def update_progress(data: ProgressUpdate): """) now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") today_prefix = now[:10] - # 去重: 当天已有同 (group_id, op_type) 的完成记录则跳过,防止前端重复提交产生脏数据 + # split: 以 new_group_id 为单位; 未传则回退到 group_id + ng = data.new_group_id or (str(data.group_id) if data.group_id is not None else None) + if not ng: + conn.close() + raise HTTPException(status_code=400, detail="group_id or new_group_id required") + gid = _ng_to_parent(ng) + # 去重: 当天已有同 (new_group_id, op_type) 的完成记录则跳过,防止前端重复提交产生脏数据 cursor.execute( - "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) + "SELECT id FROM study_logs WHERE new_group_id=? AND op_type=? AND status='completed' AND COALESCE(log_date, substr(created_at,1,10))=?", + (ng, 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, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?)", - (data.group_id, data.op_type, 'completed', now, today_prefix, data.duration_sec or 0) + "INSERT INTO study_logs (group_id, new_group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?, ?)", + (str(gid) if gid is not None else None, ng, data.op_type, 'completed', now, today_prefix, data.duration_sec or 0) ) conn.commit() conn.close() @@ -452,34 +526,53 @@ async def update_progress(data: ProgressUpdate): @app.get("/api/calendar") async def get_calendar(): - # 按日期聚合 study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟) - # 实际学习时长 = 当天各 (group,op) 完成时长(duration_sec)之和, 不含间隔, 即把各次学习时段相加 + # split 视图: 按 new_group_id 聚合并展开"父组完成→子单元继承"。 + # 每个 activity 同时返回 groups(new_group_id) 与 orig_groups(原 group_id), + # 供前端月度"X 个组, 包含 Y 个拆分组"双计数。 try: conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() cursor.execute(""" - SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id + SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, new_group_id, group_id FROM study_logs WHERE status='completed' - ORDER BY day, op_type, CAST(group_id AS INTEGER) + ORDER BY day, op_type """) + base = cursor.fetchall() + subs = _split_parent_subunits(conn) + conn.close() + # 展开: 父组(被拆分)的每条完成记录, 复制给其每个子单元 new_group_id + expanded = [] # (day, op, ng, gid) + for day, op_type, ng, gid in base: + ng = ng if ng else str(gid) + expanded.append((day, op_type, ng, str(gid))) + if str(gid) in subs: + for sng in subs[str(gid)]: + if sng != ng: + expanded.append((day, op_type, sng, str(gid))) day_map = {} - for day, op_type, group_id in cursor.fetchall(): - day_map.setdefault(day, {}).setdefault(op_type, []).append(str(group_id)) - # 每日实际学习时长(秒) = 各 (group,op) 完成时长之和 - cursor.execute(""" + for day, op_type, ng, gid in expanded: + d = day_map.setdefault(day, {}) + a = d.setdefault(op_type, {'groups': [], 'orig': set()}) + if ng not in a['groups']: + a['groups'].append(ng) + if gid not in a['orig']: + a['orig'].add(gid) + # 每日实际学习时长(秒) + conn2 = sqlite3.connect(DATABASE_PATH) + cur2 = conn2.cursor() + cur2.execute(""" SELECT COALESCE(log_date, substr(created_at,1,10)) as day, COALESCE(SUM(duration_sec), 0) as total_sec - FROM study_logs - WHERE status='completed' - GROUP BY day + FROM study_logs WHERE status='completed' GROUP BY day """) - day_total = {day: total for day, total in cursor.fetchall()} - conn.close() + day_total = {day: total for day, total in cur2.fetchall()} + conn2.close() op_order = ['study', 'test', 'review1', 'review2', 'review3'] result = [] for day in sorted(day_map.keys()): acts = day_map[day] - activities = [{"op_type": op, "groups": acts[op]} for op in op_order if op in acts] + activities = [{"op_type": op, "groups": acts[op]['groups'], "orig_groups": sorted(acts[op]['orig'], key=lambda x: int(x) if x.isdigit() else x)} + for op in op_order if op in acts] item = {"date": day, "activities": activities} total = day_total.get(day, 0) if total and total > 0: @@ -493,9 +586,7 @@ 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) 约束兜底并发。 + # split: 以 new_group_id 为单元做布尔并集; 未传 new_group_id 时回退 group_id。 try: conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() @@ -505,9 +596,14 @@ async def sync_push(data: SyncPushRequest): if op not in ALLOWED_OP_TYPES: bad += 1 continue + ng = ev.new_group_id or (str(ev.group_id) if ev.group_id is not None else None) + if not ng: + bad += 1 + continue + gid = _ng_to_parent(ng) cursor.execute( - "SELECT 1 FROM study_logs WHERE group_id=? AND op_type=? AND status='completed' LIMIT 1", - (ev.group_id, op)) + "SELECT 1 FROM study_logs WHERE new_group_id=? AND op_type=? AND status='completed' LIMIT 1", + (ng, op)) if cursor.fetchone(): skipped += 1 continue @@ -515,9 +611,9 @@ async def sync_push(data: SyncPushRequest): log_date = completed_at[:10] duration_sec = ev.duration_sec or 0 cursor.execute( - "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) " - "VALUES (?, ?, 'completed', ?, ?, ?)", - (ev.group_id, op, completed_at, log_date, duration_sec)) + "INSERT OR IGNORE INTO study_logs (group_id, new_group_id, op_type, status, created_at, log_date, duration_sec) " + "VALUES (?, ?, ?, 'completed', ?, ?, ?)", + (str(gid) if gid is not None else None, ng, op, completed_at, log_date, duration_sec)) updated += 1 conn.commit() conn.close() @@ -528,18 +624,20 @@ async def sync_push(data: SyncPushRequest): @app.get("/api/sync/pull") async def sync_pull(): - # 离线版点击"同步"时,拉取服务器端每个 (group, op) 的"最新"完成时间,供本地比对(LWW)。 + # split: 以 new_group_id 为单元拉取有效进度(含父组→子单元继承展开) try: - conn = sqlite3.connect(DATABASE_PATH) - cursor = conn.cursor() - cursor.execute( - "SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)), MAX(duration_sec) " - "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], "duration_sec": r[3] or 0} - for r in rows if r[2]] + eff = get_effective_progress() + events = [] + for ng, ops in eff.items(): + gid = _ng_to_parent(ng) + for op, (date, dur) in ops.items(): + events.append({ + "new_group_id": ng, + "group_id": str(gid) if gid is not None else None, + "op_type": op, + "completed_at": date, + "duration_sec": dur, + }) return {"status": "success", "events": events} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/package.json b/frontend/package.json index 5d481bf..3460d80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,7 @@ "name": "words", "version": "0.1.0", "private": true, + "homepage": "/split", "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 94ba4c5..3a3ab90 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -5,11 +5,14 @@ // - navigation (HTML) → network-first, 失败回退 cached index.html // - 静态资源 (JS/CSS/img/font) → cache-first -const CACHE_VERSION = 'words-pwa-v2'; +// split 分支专用 SW: 作用域 /split, 缓存版本独立于 V1(words-pwa-v*), +// 并处理 /split/api 前缀(否则 split 的 TTS/图片不会被缓存, 离线无声)。 +const CACHE_VERSION = 'words-split-v1'; +const SUB = '/split'; // 本分支部署子路径 self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_VERSION).then((cache) => cache.addAll(['/index.html', '/manifest.json'])) + caches.open(CACHE_VERSION).then((cache) => cache.addAll([SUB + '/index.html', SUB + '/manifest.json'])) ); self.skipWaiting(); }); @@ -29,22 +32,24 @@ self.addEventListener('fetch', (event) => { const url = new URL(req.url); const path = url.pathname; + // 兼容 /split 子路径部署: 取 SUB 前缀下的逻辑路径 + const sub = path.startsWith(SUB + '/') ? SUB : ''; + const apiPrefix = sub + '/api'; // 图片与 TTS 音频: cache-first (含跨域, 后端已开 CORS) - // 注意 TTS 现用查询参数形式 /api/tts?w=,pathname 为 /api/tts(无尾斜杠),故不加尾斜杠匹配 - if (path.startsWith('/api/picture') || path.startsWith('/api/tts')) { + if (path.startsWith(sub + '/api/picture') || path.startsWith(sub + '/api/tts')) { event.respondWith(cacheFirst(req)); return; } // 其他 API JSON: 不拦截, 直走网络 (IndexedDB 兜底) - if (path.startsWith('/api/')) { + if (path.startsWith(apiPrefix + '/')) { return; } // 页面导航: network-first, 离线回退 index.html if (req.mode === 'navigate') { - event.respondWith(networkFirst(req)); + event.respondWith(networkFirst(req, sub)); return; } @@ -68,7 +73,7 @@ async function cacheFirst(req) { } } -async function networkFirst(req) { +async function networkFirst(req, sub = '') { const cache = await caches.open(CACHE_VERSION); try { const res = await fetch(req); @@ -76,6 +81,6 @@ async function networkFirst(req) { return res; } catch (e) { const cached = await cache.match(req); - return cached || cache.match('/index.html'); + return cached || cache.match(sub + '/index.html'); } } diff --git a/frontend/src/App.js b/frontend/src/App.js index d17f71d..0e87872 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -15,7 +15,7 @@ function App() { return (
{loggedIn ? ( - + } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 911d1dd..b0dd44f 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -24,7 +24,7 @@ http.interceptors.response.use( (err) => { if (err.response && err.response.status === 401) { clearToken(); - window.location.href = '/'; + window.location.href = '/split'; } return Promise.reject(err); } @@ -46,7 +46,8 @@ export const api = { 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 }), + // split: 以 new_group_id 为单元上报进度(后端据此推导父组号) + updateProgress: (new_group_id, op_type) => http.post('/api/progress/update', { new_group_id, op_type }), syncPush: (events) => http.post('/api/sync/push', { events }), syncPull: () => http.get('/api/sync/pull'), getCalendar: () => http.get('/api/calendar'), diff --git a/frontend/src/components/Home.css b/frontend/src/components/Home.css index 1a3db68..6beecbd 100644 --- a/frontend/src/components/Home.css +++ b/frontend/src/components/Home.css @@ -206,4 +206,10 @@ } .logout-btn:hover { background: #eef3fb; + } + + /* split 分支: 拆分组(含 . 的子单元, 如 69.1)组名单元格淡紫背景 */ + .group-name-split { + background-color: #f3e8ff; + font-weight: 600; } \ No newline at end of file diff --git a/frontend/src/components/Home.js b/frontend/src/components/Home.js index 01f71f8..3f53998 100644 --- a/frontend/src/components/Home.js +++ b/frontend/src/components/Home.js @@ -16,16 +16,18 @@ function Home() { const isOnline = useOnlineStatus(); // 离线降级: 从 IndexedDB 构造与 /api/home/process 同格式的数据 (emoji 字符串) + // split: 以 new_group_id(拆分组) 为行单位 const buildOfflineData = async () => { const allWords = await getAllWordsFromIDB(); const progress = await getAllProgress(); const completedByGroup = {}; progress.forEach(p => { - const g = String(p.group_id); + const g = String(p.new_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)))) + const allGroups = Array.from(new Set(allWords.map(w => String(w.new_group_id)))) + .filter(Boolean) .sort((a, b) => Number(a) - Number(b)); let firstUnstudied = null; for (const g of allGroups) { @@ -175,10 +177,13 @@ function Home() { - {filteredData.map((item, index) => ( + {filteredData.map((item, index) => { + const ng = item.group.replace('Group ', ''); + const isSplit = ng.includes('.'); + return ( handleCellClick(item, 'group')} style={{ cursor: 'pointer' }} > @@ -220,7 +225,8 @@ function Home() { {item.review3} - ))} + ); + })}
diff --git a/frontend/src/components/StudyCalendar.js b/frontend/src/components/StudyCalendar.js index dceb13d..2b4c7f7 100644 --- a/frontend/src/components/StudyCalendar.js +++ b/frontend/src/components/StudyCalendar.js @@ -34,15 +34,17 @@ function StudyCalendar() { 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); + if (!tmp[day][p.op_type]) tmp[day][p.op_type] = { groups: [], orig: new Set() }; + const ng = String(p.new_group_id != null ? p.new_group_id : p.group_id); + const gid = ng.split('.')[0]; + if (!tmp[day][p.op_type].groups.includes(ng)) tmp[day][p.op_type].groups.push(ng); + if (!tmp[day][p.op_type].orig.has(gid)) tmp[day][p.op_type].orig.add(gid); if (!dayDurSec[day]) dayDurSec[day] = 0; dayDurSec[day] += (p.duration_sec || 0); }); const m = {}; Object.keys(tmp).forEach(day => { - const activities = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] })); + const activities = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op].groups, orig_groups: Array.from(tmp[day][op].orig) })); const totalSec = dayDurSec[day] || 0; const durationMin = totalSec > 0 ? Math.round(totalSec / 60) : 0; m[day] = { activities, durationMin }; @@ -66,17 +68,23 @@ function StudyCalendar() { const firstDay = new Date(y, m - 1, 1).getDay(); // 0=Sun const offset = (firstDay + 6) % 7; // 周一起始的空格数 const daysInMonth = new Date(y, m, 0).getDate(); - // 统计当月学习的 group 数(op_type='study' 的不同 group) - const studiedGroups = new Set(); + // 统计当月学习情况(split): X=不同原 group 数, Y=不同拆分组(new_group_id)数 + // 仅按 op_type='study' 计"完成/学习了几个组" + const origGroups = new Set(); // 原 group_id + const splitGroups = new Set(); // new_group_id Object.keys(dayMap).forEach(dateStr => { if (dateStr.slice(0, 7) === monthStr) { const info = dayMap[dateStr]; if (info && info.activities) info.activities.forEach(a => { - if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g)); + if (a.op_type === 'study') { + (a.orig_groups || []).forEach(g => origGroups.add(g)); + (a.groups || []).forEach(g => splitGroups.add(g)); + } }); } }); - const studiedCount = studiedGroups.size; + const origCount = origGroups.size; + const splitCount = splitGroups.size; const cells = []; for (let i = 0; i < offset; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); @@ -85,7 +93,7 @@ function StudyCalendar() { return (
- {y}年 {m}月 · (学习了{studiedCount}个group) + {y}年 {m}月 · (学习了{origCount}个group,包含{splitCount}个拆分组)
{WEEKDAYS.map(w =>
{w}
)} diff --git a/frontend/src/components/StudyMode.js b/frontend/src/components/StudyMode.js index 52a1de7..3d6397a 100644 --- a/frontend/src/components/StudyMode.js +++ b/frontend/src/components/StudyMode.js @@ -52,7 +52,7 @@ function StudyMode() { useEffect(() => { if (isOnline) return; // 在线时所有组均可学 getLearnableGroups().then(groups => { - if (!groups.includes(Number(groupId))) { + if (!groups.includes(groupId)) { alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)'); navigate('/'); } diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js index f228eba..33e1769 100644 --- a/frontend/src/components/TestMode.js +++ b/frontend/src/components/TestMode.js @@ -35,7 +35,7 @@ function TestMode() { useEffect(() => { if (isOnline) return; getLearnableGroups().then(groups => { - if (!groups.includes(Number(groupId))) { + if (!groups.includes(groupId)) { alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)'); navigate('/'); } diff --git a/frontend/src/db.js b/frontend/src/db.js index a3603ec..321f84d 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -1,11 +1,16 @@ -// IndexedDB 封装 for PWA 离线学习 +// IndexedDB 封装 for PWA 离线学习 (split 分支) // 三个 store: -// - words: 全量词库, keyPath=id, 索引 groupid (供按组查询) -// - progress: 离线进度, keyPath=`${group_id}:${op_type}`, {key, group_id, op_type, completed_at, dirty} +// - words: 全量词库, keyPath=id, 索引 new_group_id (按拆分组查询) +// - progress: 离线进度, keyPath=`${new_group_id}:${op_type}`, {key, new_group_id, group_id, op_type, completed_at, dirty} // - meta: 元数据, keyPath=key (lastSyncAt, learnableGroups[]) +// split 使用独立 DB 名, 与 V1(words-pwa) 在同一浏览器源下互不干扰。 -const DB_NAME = 'words-pwa'; -const DB_VERSION = 1; +const DB_NAME = 'words-split-pwa'; +const DB_VERSION = 2; + +function _parentOf(ng) { + try { return String(ng).split('.')[0]; } catch (e) { return String(ng); } +} let dbPromise = null; @@ -17,7 +22,13 @@ function openDB() { const db = e.target.result; if (!db.objectStoreNames.contains('words')) { db.createObjectStore('words', { keyPath: 'id' }) - .createIndex('groupid', 'groupid', { unique: false }); + .createIndex('new_group_id', 'new_group_id', { unique: false }); + } else { + // 升级: 旧库可能没有 new_group_id 索引 + const ws = e.target.transaction.objectStore('words'); + if (!ws.indexNames.contains('new_group_id')) { + ws.createIndex('new_group_id', 'new_group_id', { unique: false }); + } } if (!db.objectStoreNames.contains('progress')) { db.createObjectStore('progress', { keyPath: 'key' }); @@ -57,9 +68,9 @@ export async function putWords(wordsArr) { } export async function getWordsByGroup(groupId) { - // groupid 在 IDB 是数字, URL 参数是字符串, 统一转 Number 查索引 + // split: groupId 实为 new_group_id(如 '69.1'), 按 new_group_id 索引查询 return singleReq('words', 'readonly', s => - s.index('groupid').getAll(IDBKeyRange.only(Number(groupId))) + s.index('new_group_id').getAll(IDBKeyRange.only(String(groupId))) ); } @@ -73,20 +84,29 @@ export async function getAllWordsFromIDB() { // ---------- progress store ---------- -export async function getProgress(group_id, op_type) { - return singleReq('progress', 'readonly', s => s.get(`${group_id}:${op_type}`)); +export async function getProgress(new_group_id, op_type) { + return singleReq('progress', 'readonly', s => s.get(`${new_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, duration_sec = 0) { - const key = `${group_id}:${op_type}`; +// split: 进度以 new_group_id 为单元; group_id 由 new_group_id 推导(父组号)一并存储 +export async function putProgress(new_group_id, op_type, completed_at, dirty, duration_sec = 0) { + const key = `${new_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, duration_sec: duration_sec || 0 }); + t.objectStore('progress').put({ + key, + new_group_id: String(new_group_id), + group_id: _parentOf(new_group_id), + op_type, + completed_at, + dirty, + duration_sec: duration_sec || 0, + }); t.oncomplete = () => resolve(); t.onerror = () => reject(t.error); }); @@ -97,8 +117,8 @@ export async function getDirtyProgress() { return all.filter(p => p.dirty); } -export async function clearDirty(group_id, op_type) { - const key = `${group_id}:${op_type}`; +export async function clearDirty(new_group_id, op_type) { + const key = `${new_group_id}:${op_type}`; const db = await openDB(); return new Promise((resolve, reject) => { const t = db.transaction('progress', 'readwrite'); @@ -113,8 +133,8 @@ export async function clearDirty(group_id, op_type) { } // 删除某条进度(用于服务端已删除记录回写本地, 实现撤销/未执行) -export async function deleteProgress(group_id, op_type) { - const key = `${group_id}:${op_type}`; +export async function deleteProgress(new_group_id, op_type) { + const key = `${new_group_id}:${op_type}`; return singleReq('progress', 'readwrite', s => s.delete(key)); } diff --git a/frontend/src/index.js b/frontend/src/index.js index 2b8b867..f24def9 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -17,10 +17,10 @@ root.render( // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals(); -// 注册 Service Worker (PWA 离线支持) +// 注册 Service Worker (PWA 离线支持) — split 用相对路径, 确保在 /split 作用域下注册 if ('serviceWorker' in navigator) { window.addEventListener('load', () => { - navigator.serviceWorker.register('/sw.js').catch(err => console.error('SW registration failed:', err)); + navigator.serviceWorker.register('sw.js').catch(err => console.error('SW registration failed:', err)); // SW 接管页面后, 再触发一次同步+预取, 确保预取请求被 SW 拦截并写入缓存 navigator.serviceWorker.ready.then(() => { if (isLoggedIn()) { diff --git a/frontend/src/sync.js b/frontend/src/sync.js index 99df80e..9adb891 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -7,7 +7,7 @@ import { api } from './api'; import { getDirtyProgress, clearDirty, getAllProgress, putProgress, - putWords, setMeta, getMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount, + putWords, setMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount, } from './db'; const OPS = ['study', 'test', 'review1', 'review2', 'review3']; @@ -41,6 +41,7 @@ export async function syncNow() { setSyncStatus('syncing', { phase: 'upload', dirty: dirty.length }); if (dirty.length > 0) { const events = dirty.map(p => ({ + new_group_id: p.new_group_id, group_id: p.group_id, op_type: p.op_type, completed_at: p.completed_at, @@ -49,8 +50,8 @@ export async function syncNow() { 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))); + // 仅清"已确认上传"的 dirty (云端最终存在该 new_group_id/op 即清) + await Promise.all(dirty.map(p => clearDirty(p.new_group_id, p.op_type))); } // 阶段2: 拉云端进度, 布尔并集合并 (只补不覆盖, 不动本地已有) @@ -59,11 +60,11 @@ export async function syncNow() { const serverEvents = pullResp.data.events || []; const localAll = await getAllProgress(); for (const ev of serverEvents) { - const key = `${ev.group_id}:${ev.op_type}`; + const key = `${ev.new_group_id}:${ev.op_type}`; const local = localAll.find(p => p.key === key); if (!local) { // 本地缺失: 直接补入(布尔并集) - await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); + await putProgress(ev.new_group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); result.pulled++; result.pulledEvents.push(ev); } else { @@ -72,7 +73,7 @@ export async function syncNow() { const localDate = (local.completed_at || '').slice(0, 10); const serverDate = (ev.completed_at || '').slice(0, 10); if (serverDate > localDate) { - await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); + await putProgress(ev.new_group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); result.pulled++; result.pulledEvents.push(ev); } @@ -119,18 +120,20 @@ export async function triggerAutoSync() { } } -// 重算可学习范围: 未完全完成的组 + 最后完成组之后的 5 组 +// 重算可学习范围(split: 以 new_group_id 为单元): 未完全完成的拆分组 + 最后完成组之后的 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 allGroups = Array.from(new Set(allWords.map(w => String(w.new_group_id)))) + .filter(Boolean) + .sort((a, b) => Number(a) - Number(b)); const progress = await getAllProgress(); const completedByGroup = {}; progress.forEach(p => { - const g = Number(p.group_id); + const g = String(p.new_group_id); if (!completedByGroup[g]) completedByGroup[g] = new Set(); completedByGroup[g].add(p.op_type); }); @@ -142,19 +145,19 @@ export async function recomputeLearnableGroups() { let maxComplete = -1; allGroups.forEach(g => { if (isComplete(g)) { - if (g > maxComplete) maxComplete = g; + if (Number(g) > maxComplete) maxComplete = Number(g); } else { learnable.add(g); } }); // 最后完成组后的 5 组 if (maxComplete >= 0) { - const idx = allGroups.indexOf(maxComplete); + const idx = allGroups.indexOf(String(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)); + await setLearnableGroups(Array.from(learnable).sort((a, b) => Number(a) - Number(b))); } // ---------- 预取离线资源(思维导图 + 语音)---------- @@ -171,10 +174,12 @@ function prefetchUrl(url) { } function computePrefetchGroups(allWords, progress) { - const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b); + const allGroups = Array.from(new Set(allWords.map(w => String(w.new_group_id)))) + .filter(Boolean) + .sort((a, b) => Number(a) - Number(b)); const opsByGroup = {}; progress.forEach(p => { - const g = Number(p.group_id); + const g = String(p.new_group_id); if (!opsByGroup[g]) opsByGroup[g] = new Set(); opsByGroup[g].add(p.op_type); }); @@ -182,7 +187,7 @@ function computePrefetchGroups(allWords, progress) { const incomplete = allGroups.filter(g => opsByGroup[g] && !isComplete(g)); // 已开始但未完成 const notStarted = allGroups.filter(g => !opsByGroup[g]); // 完全无记录 const firstNotStarted = notStarted.slice(0, PREFETCH_NOT_STARTED_AHEAD); - return Array.from(new Set([...incomplete, ...firstNotStarted])).sort((a, b) => a - b); + return Array.from(new Set([...incomplete, ...firstNotStarted])).sort((a, b) => Number(a) - Number(b)); } export async function prefetchLearnableAssets() { @@ -196,7 +201,7 @@ export async function prefetchLearnableAssets() { const byGroup = {}; allWords.forEach(w => { - const g = Number(w.groupid); + const g = String(w.new_group_id); if (!byGroup[g]) byGroup[g] = []; byGroup[g].push(w); });