]> acesimba.cloud Git - words.git/commitdiff
split: 拆分组 new_group_id 功能(后端单位改为 new_group_id + 继承展开; 前端按 new_group_id 展示/学习/日历双计数...
authorlexicon <lexicon@local>
Thu, 27 Aug 2026 06:12:46 +0000 (14:12 +0800)
committerlexicon <lexicon@local>
Thu, 27 Aug 2026 06:12:46 +0000 (14:12 +0800)
14 files changed:
backend/db/bcd.db
backend/main.py
frontend/package.json
frontend/public/sw.js
frontend/src/App.js
frontend/src/api.js
frontend/src/components/Home.css
frontend/src/components/Home.js
frontend/src/components/StudyCalendar.js
frontend/src/components/StudyMode.js
frontend/src/components/TestMode.js
frontend/src/db.js
frontend/src/index.js
frontend/src/sync.js

index 98963a14009a54751bc95db574fd2a0ce6f18eb8..2d262433b68a43bce2ed0e3cf958ffea4eafacb6 100644 (file)
Binary files a/backend/db/bcd.db and b/backend/db/bcd.db differ
index fd91b1fc9a7341e6226d1ebebbb4432ae5042e5a..b16636429eb07a63f2cfbe743ee1816467e56187 100644 (file)
@@ -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))
index 5d481bfad778cdca3dbfc0038670bad95ec69f5d..3460d805f24368849d0c344d0e7a960a4915ef0e 100644 (file)
@@ -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",
index 94ba4c563e3d73f41c7d8137e7ec35a0e4c3210c..3a3ab9013eb661cf09cc1f472d41050788341e7b 100644 (file)
@@ -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');
     }
 }
index d17f71d61c8d51e393c2b1111f8c5f9b452c15bd..0e878728adbb19bde76224208c23ba828be1f13e 100644 (file)
@@ -15,7 +15,7 @@ function App() {
     return (
         <div className="app-shell">
             {loggedIn ? (
-                <Router>
+                <Router basename="/split">
                     <SyncNotifications />
                     <Routes>
                         <Route path="/" element={<Home />} />
index 911d1dd67fa11fce7c082f3d33617d835af073de..b0dd44f64b23ddfdf9d9184d6fec6819fa5c6d1c 100644 (file)
@@ -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'),
index 1a3db682d9a2c73d35ad2f2b2137576faf274abb..6beecbde279743ea2cc0e11728b81e3f21add538 100644 (file)
   }
   .logout-btn:hover {
     background: #eef3fb;
+  }
+
+  /* split 分支: 拆分组(含 . 的子单元, 如 69.1)组名单元格淡紫背景 */
+  .group-name-split {
+    background-color: #f3e8ff;
+    font-weight: 600;
   }
\ No newline at end of file
index 01f71f8990f43263cf2f9e455282c207886ef239..3f53998d1f81471ad12a4baf2a1a99b091dc16a6 100644 (file)
@@ -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() {
                         </tr>
                     </thead>
                     <tbody>
-                        {filteredData.map((item, index) => (
+                        {filteredData.map((item, index) => {
+                            const ng = item.group.replace('Group ', '');
+                            const isSplit = ng.includes('.');
+                            return (
                             <tr key={index}>
                                 <td
-                                    className="group-name"
+                                    className={`group-name${isSplit ? ' group-name-split' : ''}`}
                                     onClick={() => handleCellClick(item, 'group')}
                                     style={{ cursor: 'pointer' }}
                                 >
@@ -220,7 +225,8 @@ function Home() {
                                     {item.review3}
                                 </td>
                             </tr>
-                        ))}
+                            );
+                        })}
                     </tbody>
                 </table>
             </div>
index dceb13dba2a2ec04a3dbc3bdf05ea4e9b3a5ba3b..2b4c7f7e820287e162999b0256a52e27cc16ea16 100644 (file)
@@ -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 (
             <div className="cal-month" key={monthStr}>
                 <div className="cal-month-title">
-                    {y}年 {m}月 · <span className="cal-month-count">(学习了{studiedCount}个group)</span>
+                    {y}年 {m}月 · <span className="cal-month-count">(学习了{origCount}个group,包含{splitCount}个拆分组)</span>
                 </div>
                 <div className="cal-grid">
                     {WEEKDAYS.map(w => <div className="cal-weekday" key={w}>{w}</div>)}
index 52a1de74c9ecd6e2661982c420b7b215907e7eeb..3d6397ae1e53e3809dc9c2ef32dba0ef0aef04fc 100644 (file)
@@ -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('/');
             }
index f228eba1a75b602c49757850f723866368ea6726..33e1769c4cf2c46aade65b001cd1948944ccfebb 100644 (file)
@@ -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('/');
             }
index a3603ecf9d085ed913f8f6622d5024899098fba9..321f84d871c46f76db2abf788cdf6be83cda0065 100644 (file)
@@ -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));
 }
 
index 2b8b867011101e8e0e8d7430702c49f28db9010b..f24def92a5c2f8195229c2428343ab17145cbd41 100644 (file)
@@ -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()) {
index 99df80e91e1218ad467c22f721d61110e987cafe..9adb891974b2d939ef9027b24c4f1ab6d79933ec 100644 (file)
@@ -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);
     });