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
@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):
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)
@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] = ""
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()
@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,
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()
""")
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()
@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:
@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()
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
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()
@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))
"name": "words",
"version": "0.1.0",
"private": true,
+ "homepage": "/split",
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
// - 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();
});
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;
}
}
}
-async function networkFirst(req) {
+async function networkFirst(req, sub = '') {
const cache = await caches.open(CACHE_VERSION);
try {
const res = await fetch(req);
return res;
} catch (e) {
const cached = await cache.match(req);
- return cached || cache.match('/index.html');
+ return cached || cache.match(sub + '/index.html');
}
}
return (
<div className="app-shell">
{loggedIn ? (
- <Router>
+ <Router basename="/split">
<SyncNotifications />
<Routes>
<Route path="/" element={<Home />} />
(err) => {
if (err.response && err.response.status === 401) {
clearToken();
- window.location.href = '/';
+ window.location.href = '/split';
}
return Promise.reject(err);
}
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'),
}
.logout-btn:hover {
background: #eef3fb;
+ }
+
+ /* split 分支: 拆分组(含 . 的子单元, 如 69.1)组名单元格淡紫背景 */
+ .group-name-split {
+ background-color: #f3e8ff;
+ font-weight: 600;
}
\ No newline at end of file
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) {
</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' }}
>
{item.review3}
</td>
</tr>
- ))}
+ );
+ })}
</tbody>
</table>
</div>
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 };
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);
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>)}
useEffect(() => {
if (isOnline) return; // 在线时所有组均可学
getLearnableGroups().then(groups => {
- if (!groups.includes(Number(groupId))) {
+ if (!groups.includes(groupId)) {
alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
navigate('/');
}
useEffect(() => {
if (isOnline) return;
getLearnableGroups().then(groups => {
- if (!groups.includes(Number(groupId))) {
+ if (!groups.includes(groupId)) {
alert('该组不可离线学习(仅未完成组与最后完成组后的5组可离线学习)');
navigate('/');
}
-// 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;
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' });
}
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)))
);
}
// ---------- 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);
});
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');
}
// 删除某条进度(用于服务端已删除记录回写本地, 实现撤销/未执行)
-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));
}
// 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()) {
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'];
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,
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: 拉云端进度, 布尔并集合并 (只补不覆盖, 不动本地已有)
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 {
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);
}
}
}
-// 重算可学习范围: 未完全完成的组 + 最后完成组之后的 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);
});
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)));
}
// ---------- 预取离线资源(思维导图 + 语音)----------
}
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);
});
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() {
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);
});