feat(i18n): add fallback toast and cached bundle fallback logic
1. add global i18n fallback toast on lang:fallback event, only show once per process 2. add getCachedBundleAny to retrieve expired cache for boot fallback 3. add cache fallback logic when remote i18n bundle load fails
This commit is contained in:
22
App.vue
22
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
|
||||
|
||||
@@ -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 缓存
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user