diff --git a/App.vue b/App.vue index 77e5faf..95ae2ba 100644 --- a/App.vue +++ b/App.vue @@ -115,7 +115,27 @@ uni.$on && uni.$on('uniacid:changed', () => { try { ThemeOverride.onUniacidChange(); } catch (e) {} }); - // lang:fallback / lang:remote-status / theme:fallback / theme:remote-status 不在 App.vue 监听,留给业务按需订阅 + // 监听国际化远端拉取失败: 给用户英文 toast 提示 + // 触发时机: lang.js setRemoteStatus('fail', ...) 时 + // 设计: 用 setRemoteStatus 内部归一化后的 reason 决定文案 + // - 'timeout' / 'network' / 'http' / 'parse' / 'exception' 都弹同一句英文说明 + // - 'unknown' 不弹(可能是业务主动设置,不是真失败) + // 防刷: 同一进程内只弹一次(避免重试时反复刷屏) + if (!globalThis.__i18nFallbackToastShown) { + uni.$on && uni.$on('lang:fallback', (detail) => { + const r = detail && detail.reason; + if (r === 'unknown' || r === undefined || r === null) return; + if (globalThis.__i18nFallbackToastShown) return; + globalThis.__i18nFallbackToastShown = true; + try { + uni.showToast({ + title: 'Failed to load i18n. Please check your network and try again.', + icon: 'none', + duration: 4000, + }); + } catch (e) {} + }); + } uni.hideTabBar(); // #ifdef MP diff --git a/common/js/lang-loader.js b/common/js/lang-loader.js index 3838d9d..7d7316e 100644 --- a/common/js/lang-loader.js +++ b/common/js/lang-loader.js @@ -368,6 +368,47 @@ export function getCachedBundle(options = {}) { return null; } +/** + * 获取任意已缓存的 bundle(不检查 TTL),仅用于 boot 失败兜底 + * + * 与 getCachedBundle 的区别: + * - getCachedBundle: 受 BUNDLE_CACHE_TTL 限制,过期返回 null(用于日常 $lang 渲染) + * - getCachedBundleAny: 不检查 TTL,只要内存或 storage 中有就返回(用于失败兜底) + * + * 设计意图: + * boot() 强制重试失败时,如果用户本地还有哪怕过期的 bundle, + * 也比"$lang() 全返回空串"用户体验更好 — 至少能展示残缺文案。 + * 这是显式的"宁可用过期缓存,也不空白"的取舍。 + * + * 优先返回内存缓存(更快),其次返回 storage 缓存。 + * + * @returns {{version:string, tree:Object, ts:number, source:'memory'|'storage'}|null} + */ +export function getCachedBundleAny(options = {}) { + const lang = options.lang || getCurrentLocale(); + const appType = options.appType || getCurrentAppType(); + const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid(); + const mk = getBundleMemKey(uniacid, appType, lang); + const sk = getBundleStorageKey(uniacid, appType, lang); + + // 1) 内存缓存优先(任意 TTL,过期也返回) + if (bundleCache[mk] && bundleCache[mk].tree) { + return { ...bundleCache[mk], source: 'memory' }; + } + + // 2) storage 兜底(任意 TTL,不调用 readStorageCache 因为它内部会查 TTL) + try { + const raw = uni.getStorageSync(sk); + if (raw && raw.tree && typeof raw.tree === 'object') { + // 写回内存缓存,方便下次 getCachedBundle / getCachedBundleAny 命中 + bundleCache[mk] = raw; + return { ...raw, source: 'storage' }; + } + } catch (e) { /* 忽略 */ } + + return null; +} + /** * 清除 bundle 缓存 */ diff --git a/common/js/lang.js b/common/js/lang.js index 6cdfe64..d302b1f 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -2,7 +2,7 @@ import { langConfig } from './config-external.js'; import Config from './config.js'; import http from './http.js'; import { Platform } from './platform.js'; -import { loadBundleTree, getCachedBundle, clearBundleCache } from './lang-loader.js'; +import { loadBundleTree, getCachedBundle, getCachedBundleAny, clearBundleCache } from './lang-loader.js'; // 缓存已加载的语言包 const loadedLangPacks = {}; @@ -969,14 +969,47 @@ export default { emitUpdated(lang); return { ok: true, source, status: 'ok', version, lang }; } catch (e) { - // 完整性校验失败 / force 重试失败 / 任何异常 - // 关键修复: 不再回退到 bundleCache - // 旧逻辑: catch → getCachedBundle → 用旧 cache(可能也是不完整 bundle) - // 新逻辑: catch → 不写 remoteTreeCache → $lang() 返回空 → privacy-popup 文案可能为空 - // 但**永远不会展示"残缺文案"**, 也不会"用本地 lang 掩盖远端" const _errMsg = (e && e.message) || String(e) || 'unknown'; + // 归一化 reason: + // - 'timeout' : fetchBundle 因超时抛出 + // - 'http' : 远端返回 code != 0 + // - 'parse' : 签名校验失败等 + // - 'exception' : 其他 JS 异常 + const _reason = _errMsg === 'timeout' ? 'timeout' + : _errMsg === 'bundle sign invalid' ? 'parse' + : 'exception'; console.warn('[lang.boot] remote-only bundle load failed:', _errMsg); - setRemoteStatus('fail', { reason: 'exception', lang }); + + // 强制重试失败 / 远端拉取异常时,用任意缓存兜底(不检查 TTL) + // - loadBundleTree 内部 force=true 时直接抛错(不回退),由这里兜底 + // - 比"bootCompleted=true + remoteTreeCache 为空 → $lang() 全返回空串"体验更好 + // - 至少能展示过期文案,UI 不至于全空 + // - 仅当内存+storage 都没缓存时,才走真正的失败路径 + const cached = getCachedBundleAny({ lang, appType, uniacid }); + if (cached && cached.tree) { + console.warn('[lang.boot] falling back to cached bundle:', cached.source); + remoteTreeCache[_preloadMk] = { + version: cached.version || '', + tree: cached.tree, + ts: Date.now(), + source: 'cache-fallback', + }; + setRemoteStatus('fail', { reason: _reason, lang }); + bootCompleted = true; + emitUpdated(lang); + return { + ok: true, + source: 'cache', + status: 'fail', + reason: _errMsg || 'fallback to cache', + lang, + cacheAge: cached.ts ? Date.now() - cached.ts : 0, + }; + } + + // 没有任何缓存(首次安装 + 远端挂) → 真正的失败 + // $lang() 在 bootCompleted=true + remoteTreeCache 为空时返回空串(UI 降级显示空文案) + setRemoteStatus('fail', { reason: _reason, lang }); bootCompleted = true; emitUpdated(lang); return { ok: false, source: 'remote-failed', status: 'fail', reason: _errMsg || 'bundle failed', lang };