diff --git a/README.md b/README.md index 41a51a4..8513caa 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,8 @@ Linux 服务器可使用 `deploy.sh` 一键下载并部署 `main` 分支的最 bash <(curl -fsSL https://gitea.tohub.top/Share/AI_English/raw/branch/main/deploy.sh) deploy ``` -脚本会将 `src/index.html`、`src/js/` 和 `src/styles/` 部署到 `/root/data/docker_data/Nginx/html/english/ai/`,清理目标目录中的其他内容,但保留已有的 `data/` 目录。运行前请确认目标路径符合服务器配置;脚本需要 root 权限以及 `curl`、`tar`、`find` 命令。 +脚本会将 `src/index.html`、`src/sw.js`、`src/js/` 和 `src/styles/` 部署到 `/root/data/docker_data/Nginx/html/english/ai/`,清理目标目录中的其他内容,但保留已有的 `data/` 目录。运行前请确认目标路径符合服务器配置;脚本需要 root 权限以及 `curl`、`tar`、`find` 命令。 -`src/index.html` 通过 CSS 和 JavaScript URL 的 `v` 查询参数控制静态资源缓存。每次发布修改了 `src/styles.css` 或 `src/script.js`,请同步递增对应版本号。服务器应优先为 `index.html` 设置 `Cache-Control: no-cache`;带版本号的静态资源可长期缓存。 +缓存策略分两层:`src/index.html` 通过 CSS 和入口 JavaScript URL 的 `v` 查询参数(`ASSET_VERSION`)控制静态资源缓存,每次发布请同步递增该版本号;`src/sw.js`(Service Worker)对同源请求采用「网络优先、离线回退缓存」——未带版本号的请求以 `cache: 'no-cache'` 强制向服务器做条件校验,绕过浏览器启发式缓存(iOS Safari 卡旧版本的根因),带 `?v=` 的资源则走默认 HTTP 缓存;对 CDN 上带版本号的静态库(Font Awesome、Chart.js)采用「缓存优先」,首次取回后离线也能显示图标与图表,其余跨域请求(AI / TTS 接口)不拦截。服务器应优先为 `index.html` 与 `sw.js` 设置 `Cache-Control: no-cache`;带版本号的静态资源可长期缓存。 -设置页的“获取最新版本”仅清理应用资源缓存,不应改为清除 `localStorage`,以免丢失学习数据和 AI 配置。 +设置页的“获取最新版本”会检查并更新 Service Worker,再通过带时间戳的 URL 重新加载;重新取得的资源会更新对应 URL 的缓存,带版本号的资源会保留独立条目以避免并发页面互相覆盖,且不会清除 `localStorage`,以免丢失学习数据和 AI 配置。 \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index dee503a..8e50bb6 100644 --- a/deploy.sh +++ b/deploy.sh @@ -85,18 +85,18 @@ deploy() ( fi source_dir=$(find "$temp_dir" -mindepth 2 -maxdepth 2 -type d -name src -print -quit) - if [[ -z "$source_dir" || ! -f "$source_dir/index.html" || ! -d "$source_dir/js" || ! -d "$source_dir/styles" ]]; then - print_error "仓库中缺少 src/index.html、src/js 或 src/styles,已取消部署" + if [[ -z "$source_dir" || ! -f "$source_dir/index.html" || ! -f "$source_dir/sw.js" || ! -d "$source_dir/js" || ! -d "$source_dir/styles" ]]; then + print_error "仓库中缺少 src/index.html、src/sw.js、src/js 或 src/styles,已取消部署" return 1 fi clear_target print_info "正在拷贝网站文件到 $TARGET_DIR ..." - cp -a "$source_dir/index.html" "$source_dir/js" "$source_dir/styles" "$TARGET_DIR/" + cp -a "$source_dir/index.html" "$source_dir/sw.js" "$source_dir/js" "$source_dir/styles" "$TARGET_DIR/" print_success "部署完成" - print_info "已部署: index.html、js/、styles/" + print_info "已部署: index.html、sw.js、js/、styles/" print_info "已保留: $TARGET_DIR/$PRESERVED_NAME/" ) diff --git a/docs/smoke-test.md b/docs/smoke-test.md index 28503f0..7376071 100644 --- a/docs/smoke-test.md +++ b/docs/smoke-test.md @@ -46,4 +46,4 @@ - [ ] AI、Azure TTS 配置保存与连接测试正常;Azure 失败后语音回退正常。 - [ ] 内置词库切换、自动加载、手动加载、过滤词增删/搜索/复制正常。 - [ ] 清除收藏、清空数据的双重确认及保留收藏单词行为与原版一致。 -- [ ] “获取最新版本”只清应用缓存并刷新,不清 localStorage。 +- [ ] “获取最新版本”会检查更新并刷新,更新当前版本的应用缓存且不清 localStorage。 \ No newline at end of file diff --git a/src/index.html b/src/index.html index c32c847..3a11862 100644 --- a/src/index.html +++ b/src/index.html @@ -15,7 +15,7 @@ integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" referrerpolicy="no-referrer"> + diff --git a/src/js/pages/settings.js b/src/js/pages/settings.js index e894191..e086548 100644 --- a/src/js/pages/settings.js +++ b/src/js/pages/settings.js @@ -242,19 +242,41 @@ export async function forceRefreshBrowserCache() { button.innerHTML = ' 正在获取最新版本...'; } + // 使用由 Service Worker 明确放行的 GET 探测服务器,兼容不支持 HEAD 的服务器, + // 同时避免离线缓存回退把断网误判为在线。 try { - // 当前无 Service Worker,Cache Storage 通常为空;此处为将来引入 SW 时的前瞻兼容。 - // 真正的缓存绕过依赖下方的时间戳 URL + 静态资源 ?v= 版本号。 - if ('caches' in window) { - const cacheNames = await caches.keys(); - await Promise.all(cacheNames.map(cacheName => caches.delete(cacheName))); - } + const probeUrl = new URL('index.html', window.location.href); + probeUrl.searchParams.set('_network_probe', Date.now().toString()); + const response = await fetch(probeUrl, { cache: 'no-store' }); + if (!response.ok) throw new Error(`服务器返回 HTTP ${response.status}`); } catch (error) { - console.warn('清理应用缓存失败,将继续重新加载:', error); + console.warn('检查应用更新失败:', error); + if (button) { + button.disabled = false; + button.innerHTML = ' 获取最新版本'; + } + alert('当前无法连接应用服务器,无法获取最新版本,请稍后再试。'); + return; } const url = new URL(window.location.href); url.searchParams.set('_app_update', Date.now().toString()); + + // iOS Safari 上 Service Worker 更新偶尔会一直处于 pending 状态, + // 用 1.5 秒超时兜底,不让更新检查阻塞最终的重新加载。 + const withTimeout = (promise) => Promise.race([ + Promise.resolve(promise).catch(error => console.warn('刷新前更新出错,已继续重新加载:', error)), + new Promise(resolve => setTimeout(resolve, 1500)) + ]); + + if ('serviceWorker' in navigator) { + // 不删除整库:重新加载会更新当前 URL 的缓存条目,避免超时后的后台删除误删新缓存。 + await withTimeout( + navigator.serviceWorker.getRegistration() + .then(registration => registration?.update()) + ); + } + window.location.replace(url.toString()); } diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 0000000..9874a9a --- /dev/null +++ b/src/sw.js @@ -0,0 +1,164 @@ +// AI 英语助教 Service Worker +// 策略: +// - 同源 GET 一律「网络优先」,成功则顺带写入缓存;网络失败时回退到缓存(离线可用)。 +// - CDN 静态库(Font Awesome / Chart.js,URL 带版本号、内容不可变)「缓存优先」, +// 首次取回后离线也能显示图标与图表;其余跨域请求(AI / TTS 等接口)一概不拦截。 +// 目的:彻底根治「浏览器/iOS Safari 卡在旧版本」的问题 —— 只要在线,每次加载拿到的都是最新文件, +// 整个 ES 模块图(含 main.js 未加 ?v= 的子模块)都被覆盖,无需逐文件维护版本号。 + +// 运行时缓存使用稳定名称、不随发版变化:网络优先策略本身会逐路径覆盖旧内容, +// 无需在 SW 更新时清空缓存。若激活时整库删除,而新缓存只预缓存了 HTML 入口, +// 已被控制的页面不会重新请求本轮已加载的 JS/CSS,此时离线刷新会因资源缺失而白屏。 +// Cache Storage 按源(origin)共享;`ai-english-` 前缀用于把本应用的缓存与同源 +// 其他应用区分开。 +const CACHE_NAME = 'ai-english-runtime'; +const MAX_VERSIONED_ENTRIES_PER_PATH = 2; + +// 仅接管这些 CDN 域名(页面以 crossorigin="anonymous" 引入的带版本号静态库)。 +const CDN_ORIGINS = ['https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net']; + +self.addEventListener('install', (event) => { + event.waitUntil((async () => { + // 轻量预缓存文档入口(目录与 index.html 两种访问形式),缩短「首次访问后 + // 离线不可用」的窗口;子资源仍靠运行时缓存补齐。失败不阻塞安装。 + const cache = await caches.open(CACHE_NAME); + await Promise.all(['./', 'index.html'].map(path => + cache.add(new Request(path, { cache: 'no-cache' })).catch(() => {}) + )); + // 新 SW 安装后立即接管,配合「获取最新版本」按钮实现一次刷新即生效。 + await self.skipWaiting(); + })()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + // 一次性迁移:把历史版本命名缓存(ai-english-v1/v2/v3)的条目并入稳定运行时 + // 缓存后再删除,避免迁移瞬间丢弃已缓存的 JS/CSS/词库导致离线刷新白屏。 + // 只动本应用前缀的缓存,勿动同源其他应用创建的缓存。迁移失败不阻塞接管页面。 + await migrateLegacyCaches().catch(() => {}); + await self.clients.claim(); + })()); +}); + +async function migrateLegacyCaches() { + const names = await caches.keys(); + const legacyNames = names.filter(name => name.startsWith('ai-english-') && name !== CACHE_NAME); + if (legacyNames.length === 0) return; + const cache = await caches.open(CACHE_NAME); + for (const name of legacyNames) { + const legacy = await caches.open(name); + const requests = await legacy.keys(); + await Promise.all(requests.map(async (request) => { + // 运行时缓存里已有的条目更新,不用旧内容覆盖。 + if (await cache.match(request)) return; + const response = await legacy.match(request); + if (response) await cache.put(request, response); + })); + await caches.delete(name); + } +} + +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin === self.location.origin && url.searchParams.has('_network_probe')) return; + + if (url.origin === self.location.origin) { + event.respondWith(handleSameOrigin(event, request, url)); + } else if (CDN_ORIGINS.includes(url.origin)) { + event.respondWith(handleCdn(event, request)); + } + // 其余跨域请求(AI / TTS 等接口)不接管 +}); + +async function handleSameOrigin(event, request, url) { + try { + // 带 ?v= 的资源随版本号变更 URL、内容不可变,走默认 HTTP 缓存即可; + // 其余请求(HTML、未带版本号的子模块)用 'no-cache' 强制带 ETag/Last-Modified + // 向服务器做条件校验,绕过启发式 HTTP 缓存(iOS Safari 卡旧版本的根因), + // 文件未变化时服务器仅返回 304,代价只是一次轻量往返。 + const response = await fetch( + request, + url.searchParams.has('v') ? undefined : { cache: 'no-cache' } + ); + if (!response.ok) return response; + + // 只缓存成功的基础响应(避免缓存重定向/错误页/opaque 响应)。 + if (response.type === 'basic') { + const copy = response.clone(); + // 强制刷新导航(?_app_update=时间戳)归一化为无参 URL 再入缓存: + // 不缓存一次性 URL,同时保证刷新后离线副本仍包含稳定的 HTML 入口。 + let key = request; + if (url.searchParams.has('_app_update')) { + const cleanUrl = new URL(url); + cleanUrl.searchParams.delete('_app_update'); + key = new Request(cleanUrl.toString()); + } + // 保留带版本号资源的完整 URL,避免不同页面并发请求时旧版本覆盖新版本。 + // 相同 URL 的 cache.put 是原子替换,无需先删除;离线时优先精确匹配当前页面引用的版本。 + event.waitUntil((async () => { + const cache = await caches.open(CACHE_NAME); + await cache.put(key, copy); + if (url.searchParams.has('v')) { + await trimVersionedEntries(cache, url); + } + })().catch(() => { /* 写缓存失败(如配额不足)不影响本次正常响应 */ })); + } + return response; + } catch (error) { + const cached = await matchSameOriginCache(request); + if (cached) return cached; + throw error; + } +} + +async function trimVersionedEntries(cache, currentUrl) { + const requests = await cache.keys(); + const samePathVersions = requests.filter((request) => { + const cachedUrl = new URL(request.url); + return cachedUrl.origin === currentUrl.origin + && cachedUrl.pathname === currentUrl.pathname + && cachedUrl.searchParams.has('v'); + }); + + const stale = samePathVersions + .filter(request => request.url !== currentUrl.toString()) + .slice(0, Math.max(0, samePathVersions.length - MAX_VERSIONED_ENTRIES_PER_PATH)); + await Promise.all(stale.map(request => cache.delete(request))); +} + +async function matchSameOriginCache(request) { + // 资源请求只允许精确匹配,避免缺少当前 ?v= 条目时混用旧版本 JS/CSS。 + const exact = await caches.match(request); + if (exact || request.mode !== 'navigate') return exact; + + // 仅导航请求可忽略查询串,使 ?_app_update=X 等一次性页面 URL 命中稳定离线入口。 + return caches.match(request, { ignoreSearch: true }); +} + +async function handleCdn(event, request) { + // CDN URL 自带版本号、内容不可变,缓存优先即可,无需回源校验。 + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + // 只缓存成功的 CORS 响应(crossorigin="anonymous" 下正常即为 'cors', + // 含 Font Awesome CSS 内部引用的 webfonts);opaque 响应无法校验状态码,不入缓存。 + if (response && response.ok && response.type === 'cors') { + const copy = response.clone(); + event.waitUntil((async () => { + const cache = await caches.open(CACHE_NAME); + await cache.put(request, copy); + })().catch(() => { /* 写缓存失败不影响本次正常响应 */ })); + } + return response; + } catch (error) { + // 网络请求期间可能有另一个并发请求完成写缓存,此时仍可离线回退。 + const fallback = await caches.match(request); + if (fallback) return fallback; + throw error; + } +}