--- /dev/null
+# Words HTTPS 反向代理(方案 B:仅监听 8443,绝不占用 :55)
+# / -> 同源前端 build-https (:8444)
+# /api/ -> 后端 FastAPI (:1060)
+server {
+ listen 8443 ssl;
+ server_name acesimba.cloud;
+
+ ssl_certificate /etc/letsencrypt/live/acesimba.cloud/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/acesimba.cloud/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+ ssl_session_cache shared:SSL:10m;
+ ssl_session_timeout 10m;
+
+ # 后端 API:同源,浏览器看到的都是 acesimba.cloud:8443,无需 CORS
+ location /api/ {
+ proxy_pass http://127.0.0.1:1060;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # 前端静态资源(含 /sw.js、/manifest.json),SPA 回退由 serve -s 处理
+ location / {
+ proxy_pass http://127.0.0.1:8444;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
import { api } from './api';
import {
getDirtyProgress, clearDirty, getAllProgress, putProgress,
- putWords, setMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount,
+ putWords, setMeta, getMeta, setLearnableGroups, getAllWordsFromIDB, getWordsCount,
} from './db';
const OPS = ['study', 'test', 'review1', 'review2', 'review3'];
await setMeta('lastSyncAt', new Date().toISOString());
await recomputeLearnableGroups();
+ // 阶段5: 后台预取离线资源(思维导图+语音), 不阻塞同步完成
+ prefetchLearnableAssets().catch(() => {});
+
setSyncStatus('synced', { pushed: result.pushed, pulled: result.pulled });
return result;
}
await setLearnableGroups(Array.from(learnable).sort((a, b) => a - b));
}
+// ---------- 预取离线资源(思维导图 + 语音)----------
+// 范围: 未完成组(有记录但未集齐5项) ∪ 未开始组中最前面10组
+// 仅在线执行, 后台非阻塞; 去重直接查 SW 缓存 (caches.match), 不依赖 meta 标记,
+// 避免"SW 未接管时预取未真正落盘却被误标为已完成"导致后续跳过。
+const PREFETCH_CONCURRENCY = 6;
+const PREFETCH_NOT_STARTED_AHEAD = 10;
+const PREFETCH_OPS = ['study', 'test', 'review1', 'review2', 'review3'];
+
+// mode:'no-cors' 使请求键与 <img>/<Audio> 完全一致, 确保 SW cache-first 命中
+function prefetchUrl(url) {
+ return fetch(url, { mode: 'no-cors' }).catch(() => {});
+}
+
+function computePrefetchGroups(allWords, progress) {
+ const allGroups = Array.from(new Set(allWords.map(w => Number(w.groupid)))).sort((a, b) => a - b);
+ const opsByGroup = {};
+ progress.forEach(p => {
+ const g = Number(p.group_id);
+ if (!opsByGroup[g]) opsByGroup[g] = new Set();
+ opsByGroup[g].add(p.op_type);
+ });
+ const isComplete = (g) => !!opsByGroup[g] && PREFETCH_OPS.every(op => opsByGroup[g].has(op));
+ 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);
+}
+
+export async function prefetchLearnableAssets() {
+ if (typeof navigator === 'undefined' || !navigator.onLine) return;
+ if (!('caches' in window)) return;
+ const allWords = await getAllWordsFromIDB();
+ if (!allWords || allWords.length === 0) return;
+ const progress = await getAllProgress();
+ const target = computePrefetchGroups(allWords, progress);
+ if (target.length === 0) return;
+
+ const byGroup = {};
+ allWords.forEach(w => {
+ const g = Number(w.groupid);
+ if (!byGroup[g]) byGroup[g] = [];
+ byGroup[g].push(w);
+ });
+
+ // 收集需预取的 URL: 每组首词思维导图 + 每个词语音
+ const urls = [];
+ for (const g of target) {
+ const words = byGroup[g] || [];
+ if (words.length === 0) continue;
+ const picId = words[0].picture_id;
+ if (picId) urls.push(api.pictureUrl(picId));
+ for (const w of words) {
+ if (w.word) urls.push(api.ttsUrl(w.word));
+ }
+ }
+
+ // 准确去重: 只预取 SW 缓存里尚不存在的 URL (避免 meta 误标导致永久跳过)
+ const todo = [];
+ for (const u of urls) {
+ const hit = await caches.match(u).catch(() => undefined);
+ if (!hit) todo.push(u);
+ }
+ if (todo.length === 0) return;
+
+ // 分片并发, 限制同时请求数, 单条失败忽略
+ for (let i = 0; i < todo.length; i += PREFETCH_CONCURRENCY) {
+ const batch = todo.slice(i, i + PREFETCH_CONCURRENCY).map(u => prefetchUrl(u));
+ await Promise.allSettled(batch);
+ }
+}
+
const OP_LABEL = { study: '学习', test: '测试', review1: '复习I', review2: '复习II', review3: '复习III' };
// 格式化同步结果为可读提示 (保留, 用于失败时/手动诊断)