修复remote-only模式下语言包归一化的历史bug:当页面leaf顶层存在与命名空间同名的字符串键时,会覆盖整个namespace导致子key失效。通过对齐normalizePageLangPack的两层合并逻辑来修复: 1. 先将页面语言包按路径段转为命名空间结构再合并 2. 额外平铺leaf的基本类型键到根节点以兼容短key调用
1498 lines
52 KiB
JavaScript
1498 lines
52 KiB
JavaScript
import { langConfig } from './config-external.js';
|
||
import Config from './config.js';
|
||
import http from './http.js';
|
||
import { Platform } from './platform.js';
|
||
import { loadBundleTree, getCachedBundle, getCachedBundleAny, clearBundleCache } from './lang-loader.js';
|
||
|
||
// 缓存已加载的语言包
|
||
const loadedLangPacks = {};
|
||
|
||
// 缓存合并后的语言包(common + page + 远端覆写)
|
||
// key: `${locale}__${langPath}__${overrideVersion}, value: 合并后的对象
|
||
const _mergedLangCache = {};
|
||
let _overrideVersion = 0;
|
||
|
||
// 缓存字段解析结果
|
||
// key: `${locale}__${langPath}__${overrideVersion}__${field}, value: 翻译后的字符串
|
||
const _fieldResultCache = {};
|
||
|
||
// 缓存当前页面路由(页面生命周期内不变)
|
||
// key: 页面实例标识, value: langPath
|
||
let _cachedLangPath = '';
|
||
let _cachedLangPathPage = null;
|
||
|
||
// 组件级共享语言包。
|
||
// 这些语言包不隶属于某个页面,但组件会在任意页面中直接调用
|
||
// $lang('components.xxx') / $lang('components-diy.xxx')。
|
||
// 因此必须在每个页面的合并语言包中统一注入,否则运行时会出现 raw key。
|
||
const SHARED_LANG_PACKS = [
|
||
'components/img-cropping',
|
||
'components/mescroll',
|
||
'components/ns-birthday-gift',
|
||
'components/ns-form',
|
||
'components/ns-login',
|
||
'components/ns-new-gift',
|
||
'components/ns-newform',
|
||
'components/ns-payment',
|
||
'components/ns-select-time',
|
||
'components/nsChatOrder',
|
||
'components/nsContact',
|
||
'components/nsGoodsPromotion',
|
||
'components/payment',
|
||
'components/privacy-popup',
|
||
'components/register-reward',
|
||
'components/wxwork-contact',
|
||
'components/yuyue-date',
|
||
'components/chat-message',
|
||
'components/pick-regions',
|
||
'components/ns-goods-sku',
|
||
'components/ns-chat-receiveGoods',
|
||
'components/ns-chat-goods',
|
||
'components/ns-video-player-popup',
|
||
'components-diy/diy-article',
|
||
'components-diy/diy-bargain',
|
||
'components-diy/diy-category',
|
||
'components-diy/diy-category-item',
|
||
'components-diy/diy-coupon',
|
||
'components-diy/diy-fenxiao-goods-list',
|
||
'components-diy/diy-goods-list',
|
||
'components-diy/diy-index-page',
|
||
'components-diy/diy-member-info',
|
||
'components-diy/diy-notice',
|
||
'components-diy/diy-payment-qrcode',
|
||
'components-diy/diy-pinfan',
|
||
'components-diy/diy-pintuan',
|
||
'components-diy/diy-seckill',
|
||
'components-diy/diy-store',
|
||
'components-diy/diy-video',
|
||
'components-diy/diy-search',
|
||
'components-diy/diy-map',
|
||
'components-diy/diy-kefu',
|
||
'components-diy/diy-channel-video',
|
||
'components-diy/diy-live',
|
||
'components-diy/diy-listmenu',
|
||
'components-diy/diy-text',
|
||
'components-diy/diy-goods-recommend',
|
||
'components-diy/diy-notes',
|
||
'member/public',
|
||
];
|
||
|
||
function toCamelCaseName(name) {
|
||
return String(name || '').replace(/[-_]([a-z])/g, function(_, letter) {
|
||
return letter.toUpperCase();
|
||
});
|
||
}
|
||
|
||
function toSnakeCaseName(name) {
|
||
return String(name || '')
|
||
.replace(/([A-Z])/g, '_$1')
|
||
.replace(/-/g, '_')
|
||
.toLowerCase();
|
||
}
|
||
|
||
function buildLangPathCandidates(parts) {
|
||
const results = [];
|
||
function walk(index, current) {
|
||
if (index >= parts.length) {
|
||
const path = current.join('/');
|
||
if (!results.includes(path)) results.push(path);
|
||
return;
|
||
}
|
||
const raw = parts[index];
|
||
const snake = toSnakeCaseName(raw);
|
||
walk(index + 1, current.concat(raw));
|
||
if (snake !== raw) walk(index + 1, current.concat(snake));
|
||
}
|
||
walk(0, []);
|
||
return results;
|
||
}
|
||
|
||
function normalizeSharedLangPack(path, langData) {
|
||
if (!langData || typeof langData !== 'object') return {};
|
||
|
||
const segments = String(path || '').split('/');
|
||
const group = segments[0];
|
||
const fileName = segments[segments.length - 1];
|
||
if (group !== 'components' && group !== 'components-diy') return langData;
|
||
|
||
if (langData[group]) return langData;
|
||
|
||
const componentName = toCamelCaseName(fileName);
|
||
const keys = Object.keys(langData);
|
||
|
||
if (keys.length === 1 && langData[keys[0]] && typeof langData[keys[0]] === 'object') {
|
||
return {
|
||
[group]: langData
|
||
};
|
||
}
|
||
|
||
return {
|
||
[group]: {
|
||
[componentName]: langData
|
||
}
|
||
};
|
||
}
|
||
|
||
function normalizePageLangPack(path, langData) {
|
||
if (!path || !langData || typeof langData !== 'object') return langData || {};
|
||
|
||
const segments = String(path).split('/').filter(Boolean);
|
||
if (!segments.length) return langData;
|
||
|
||
let leaf = langData;
|
||
let hasLeaf = true;
|
||
for (const segment of segments) {
|
||
const key = toCamelCaseName(segment);
|
||
if (leaf && typeof leaf === 'object' && !Array.isArray(leaf) && leaf[key] !== undefined) {
|
||
leaf = leaf[key];
|
||
} else {
|
||
hasLeaf = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const lastSegment = toCamelCaseName(segments[segments.length - 1]);
|
||
const currentPageLeaf =
|
||
hasLeaf && leaf && typeof leaf === 'object' && !Array.isArray(leaf)
|
||
? leaf
|
||
: (langData[lastSegment] && typeof langData[lastSegment] === 'object' && !Array.isArray(langData[lastSegment]) ? langData[lastSegment] : langData);
|
||
let result = deepCloneLang(langData);
|
||
if (currentPageLeaf !== langData) {
|
||
// 部分语言包本身就是完整路径结构,如 { pay: { result: { title } } }。
|
||
// 页面内通常仍会调用 $lang('title') / $lang('xxx'),所以需要把当前页面 leaf
|
||
// 也展开到顶层,避免运行时出现缺失 key 警告。
|
||
result = deepMergeLang(deepCloneLang(currentPageLeaf), result);
|
||
}
|
||
if (!result[lastSegment]) {
|
||
// 兼容页面内按文件名命名空间调用的写法,如 point/goods_list 页面调用
|
||
// $lang('point.goods_list.searchPlaceholder')。
|
||
result[lastSegment] = deepCloneLang(currentPageLeaf);
|
||
}
|
||
|
||
let namespaced = {};
|
||
{
|
||
let cursor = namespaced;
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const camelSegment = toCamelCaseName(segments[i]);
|
||
cursor[camelSegment] = i === segments.length - 1 ? deepCloneLang(currentPageLeaf) : {};
|
||
cursor = cursor[camelSegment];
|
||
}
|
||
}
|
||
|
||
// 同步建立 snake_case 镜像:让 camelCase 与 snake_case 路径都能解析到同一份 leaf
|
||
// (与 bundle 服务器侧 emit 形式对齐 —— bundle 用 snake_case 段名,如 point.order_list.statusAll)
|
||
// 实现:在每个 namespace 节点同时挂一个 snake 别名键,与 camel 节点共享 leaf 引用(0 拷贝)
|
||
const nested = [];
|
||
{
|
||
let cursor = namespaced;
|
||
nested.push(cursor);
|
||
for (let i = 0; i < segments.length - 1; i++) {
|
||
cursor = cursor[toCamelCaseName(segments[i])];
|
||
nested.push(cursor);
|
||
}
|
||
}
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const raw = segments[i];
|
||
const camel = toCamelCaseName(raw);
|
||
const snake = toSnakeCaseName(raw);
|
||
if (snake !== camel) {
|
||
const isLeaf = i === segments.length - 1;
|
||
nested[i][snake] = isLeaf ? deepCloneLang(currentPageLeaf) : {};
|
||
}
|
||
}
|
||
|
||
return deepMergeLang(result, namespaced);
|
||
}
|
||
|
||
function tryResolveQualifiedPageField(locale, field) {
|
||
const arr = String(field || '').split('.');
|
||
if (arr.length < 3) return undefined;
|
||
|
||
for (let i = arr.length - 1; i >= 2; i--) {
|
||
const langPathCandidates = buildLangPathCandidates(arr.slice(0, i));
|
||
for (const langPath of langPathCandidates) {
|
||
const pack = normalizePageLangPack(langPath, loadLangPackSync(locale, langPath));
|
||
const value = resolveLangField(pack, arr);
|
||
if (value !== undefined) return value;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function resolveLangField(langData, arr) {
|
||
let temp = langData;
|
||
for (const key of arr) {
|
||
if (temp && typeof temp === 'object' && temp[key] !== undefined) {
|
||
temp = temp[key];
|
||
} else {
|
||
return undefined;
|
||
}
|
||
}
|
||
return typeof temp === 'string' ? temp : undefined;
|
||
}
|
||
|
||
function tryResolveLoadedPageField(locale, field) {
|
||
const arr = String(field || '').split('.');
|
||
if (!arr.length) return undefined;
|
||
|
||
let matchedValue;
|
||
let matchedCount = 0;
|
||
const prefix = `${locale}_`;
|
||
for (const cacheKey in loadedLangPacks) {
|
||
if (!Object.prototype.hasOwnProperty.call(loadedLangPacks, cacheKey)) continue;
|
||
if (cacheKey.indexOf(prefix) !== 0) continue;
|
||
const langPath = cacheKey.slice(prefix.length);
|
||
if (langPath === 'common' || SHARED_LANG_PACKS.includes(langPath)) continue;
|
||
|
||
const pack = normalizePageLangPack(langPath, loadedLangPacks[cacheKey]);
|
||
const value = resolveLangField(pack, arr);
|
||
if (value !== undefined) {
|
||
matchedValue = value;
|
||
matchedCount++;
|
||
if (matchedCount > 1) return undefined;
|
||
}
|
||
}
|
||
return matchedCount === 1 ? matchedValue : undefined;
|
||
}
|
||
|
||
// 清除所有语言缓存(切语言/切租户/远端覆写更新时调用)
|
||
function invalidateLangCaches() {
|
||
for (const k in _mergedLangCache) { if (Object.prototype.hasOwnProperty.call(_mergedLangCache, k)) delete _mergedLangCache[k]; }
|
||
for (const k in _fieldResultCache) { if (Object.prototype.hasOwnProperty.call(_fieldResultCache, k)) delete _fieldResultCache[k]; }
|
||
_cachedLangPath = '';
|
||
_cachedLangPathPage = null;
|
||
_overrideVersion++;
|
||
}
|
||
|
||
// 递归调用保护:防止 $lang() 被间接递归(如响应式 getter 回调中再次调用 $lang)
|
||
let _langRecursionDepth = 0;
|
||
const MAX_LANG_RECURSION_DEPTH = 8;
|
||
|
||
// refresh 重入保护:防止 refresh() 内部调用 lang() 触发事件后再次调用 refresh()
|
||
let _refreshInProgress = false;
|
||
|
||
// storage 结果内存缓存:防止反复调用 uni.getStorageSync 触发 Vue 事件导致循环
|
||
// key: storage key, value: { value, ts }
|
||
const _storageResultCache = {};
|
||
const STORAGE_CACHE_TTL_MS = 200; // 200ms 内复用缓存值,足以阻断单次渲染中的事件循环
|
||
|
||
// 从缓存读取 storage 值,未命中则调用 uni.getStorageSync 并存入缓存
|
||
function cachedStorageGet(key, defaultValue) {
|
||
const now = Date.now();
|
||
const cached = _storageResultCache[key];
|
||
if (cached && (now - cached.ts) < STORAGE_CACHE_TTL_MS) {
|
||
return cached.value;
|
||
}
|
||
let value;
|
||
try {
|
||
value = uni.getStorageSync(key);
|
||
} catch (e) {
|
||
value = defaultValue;
|
||
}
|
||
_storageResultCache[key] = { value, ts: now };
|
||
return value;
|
||
}
|
||
|
||
// 手动清除 storage 缓存(切语言/切租户时调用)
|
||
function invalidateStorageCache(key) {
|
||
if (key) {
|
||
delete _storageResultCache[key];
|
||
} else {
|
||
Object.keys(_storageResultCache).forEach(k => delete _storageResultCache[k]);
|
||
}
|
||
}
|
||
|
||
// 语言包安全深复制:JSON 安全的对象即可,不处理函数/symbol/循环引用
|
||
function deepCloneLang(obj) {
|
||
if (obj === null || typeof obj !== 'object') return obj;
|
||
if (Array.isArray(obj)) {
|
||
const result = new Array(obj.length);
|
||
for (let i = 0; i < obj.length; i++) {
|
||
result[i] = deepCloneLang(obj[i]);
|
||
}
|
||
return result;
|
||
}
|
||
const result = {};
|
||
for (const k in obj) {
|
||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||
result[k] = deepCloneLang(obj[k]);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 语言包深度合并:将 source 合并到 target 中,返回 target
|
||
* - 对象类型递归合并(嵌套 key 不会整体覆盖)
|
||
* - 数组/字符串/数字等基本类型直接覆盖
|
||
* - 危险 key 过滤:__proto__ / constructor / prototype
|
||
*
|
||
* 为什么需要深合并:
|
||
* 之前用 Object.assign 做顶层浅合并,如果 page 语言包定义了 common: { submit: 'x' },
|
||
* 会整体替换 common.js 中的 common 对象,导致 common.currencySymbol 等子 key 丢失。
|
||
* 深合并可以保留两边的 key,只在 leaf 节点覆盖。
|
||
*/
|
||
function deepMergeLang(target, source) {
|
||
if (target === null || typeof target !== 'object') return source;
|
||
if (source === null || typeof source !== 'object') return source;
|
||
if (Array.isArray(target) || Array.isArray(source)) return source;
|
||
|
||
for (const key in source) {
|
||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
||
if (FORBIDDEN_KEYS.has(key)) continue;
|
||
|
||
const srcVal = source[key];
|
||
const tgtVal = target[key];
|
||
|
||
// 两边都是纯对象 → 递归合并
|
||
if (
|
||
srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) &&
|
||
tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal)
|
||
) {
|
||
target[key] = deepMergeLang(tgtVal, srcVal);
|
||
} else {
|
||
// 基本类型 / 数组 / 类型不一致 → 直接覆盖
|
||
target[key] = deepCloneLang(srcVal);
|
||
}
|
||
}
|
||
return target;
|
||
}
|
||
|
||
/**
|
||
* 翻译参数替换:支持位置参数和命名参数
|
||
*
|
||
* 位置参数: $lang('key', 'a', 'b') → {0}=a, {1}=b
|
||
* 命名参数: $lang('key', { name: 'Tom', count: 3 }) → {name}=Tom, {count}=3
|
||
*
|
||
* 规则:
|
||
* - 第 2 个参数起如果是对象(非 null 非数组),则当作命名参数 map
|
||
* - 其他情况当作位置参数
|
||
* - 全局替换(同一个占位符出现多次都能替换)
|
||
* - 字面量花括号可以用 {{ 和 }} 转义(暂不支持,有需要再加)
|
||
*/
|
||
function applyLangParams(value, args) {
|
||
if (args.length === 0) return value;
|
||
let result = String(value);
|
||
|
||
const firstArg = args[0];
|
||
const isNamed = firstArg !== null && typeof firstArg === 'object' && !Array.isArray(firstArg);
|
||
|
||
if (isNamed) {
|
||
// 命名参数:遍历对象的每个 key
|
||
const map = firstArg;
|
||
for (const key in map) {
|
||
if (!Object.prototype.hasOwnProperty.call(map, key)) continue;
|
||
if (FORBIDDEN_KEYS.has(key)) continue;
|
||
const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
result = result.replace(new RegExp('\\{' + safeKey + '\\}', 'g'), String(map[key]));
|
||
}
|
||
} else {
|
||
// 位置参数: {0}, {1}, {2}...
|
||
for (let i = 0; i < args.length; i++) {
|
||
result = result.replace(new RegExp('\\{' + i + '\\}', 'g'), String(args[i]));
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 从后端数据中提取当前语言的文案(向后兼容)
|
||
*
|
||
* 优先使用多语言 map(如 title_map / text_map),格式:
|
||
* { "zh-cn": "标题", "en-us": "Title" }
|
||
*
|
||
* 如果没有 map,回退到二元模式(兼容旧数据):
|
||
* field = 'title' → 取 title / en_title
|
||
* field = 'text' → 取 text / en_text
|
||
*
|
||
* @param {Object} data 后端数据对象
|
||
* @param {string} field 基础字段名(如 'title'、'text'、'goods_name')
|
||
* @param {string} [locale] 目标语言,默认取当前语言
|
||
* @returns {string} 对应语言的文案,找不到返回空串
|
||
*/
|
||
function getI18nField(data, field, locale) {
|
||
if (!data || !field) return '';
|
||
const lang = locale || getCurrentLocale();
|
||
|
||
// 1) 优先尝试多语言 map: title_map / text_map / goods_name_map
|
||
const mapKey = field + '_map';
|
||
const map = data[mapKey];
|
||
if (map && typeof map === 'object' && !Array.isArray(map)) {
|
||
// 精确匹配当前语言
|
||
if (map[lang] !== undefined && map[lang] !== null && map[lang] !== '') {
|
||
return String(map[lang]);
|
||
}
|
||
// 兜底:取第一个非空值
|
||
for (const k of Object.keys(map)) {
|
||
if (map[k] !== undefined && map[k] !== null && map[k] !== '') {
|
||
return String(map[k]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2) 回退到二元模式: field / en_field
|
||
const enKey = 'en_' + field;
|
||
if (lang === 'en-us' && data[enKey] !== undefined && data[enKey] !== '') {
|
||
return String(data[enKey]);
|
||
}
|
||
if (data[field] !== undefined && data[field] !== '') {
|
||
return String(data[field]);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// 远端覆写:内存缓存
|
||
// shape: { [app_lang]: { version, map, ts, global_version } }
|
||
// key 格式 `${appType}_${lang}`,天然按平台 + 语种二维隔离,避免多端 cache 串味
|
||
const overrideCache = {};
|
||
|
||
// 远端覆写:并发去重
|
||
const overrideInflight = {};
|
||
|
||
// remote-only 模式:缓存远端 bundle tree
|
||
// key: getOverrideMemKey(locale), value: { version, tree, ts, source }
|
||
const remoteTreeCache = {};
|
||
|
||
// remote-only 模式: boot() 是否已完成(远端 bundle 已就绪)
|
||
// boot 完成前, lang() 返回空串且不报 missing key, 避免首屏 raw key 和噪音警告
|
||
// boot 完成后 emitUpdated 触发 $forceUpdate, 页面重新渲染显示真实翻译
|
||
let bootCompleted = false;
|
||
|
||
// boot() 并发去重: 避免多次调用 boot 时重复执行逻辑和 emitUpdated
|
||
let _bootInflight = null;
|
||
|
||
/**
|
||
* 校验远端 bundle 是否"足够可用"
|
||
*
|
||
* 重要设计哲学: 这一步只做"粗粒度"校验,确保 boot 能成功完成并写入 remoteTreeCache。
|
||
* 后续 $lang() 调用时, 缺失的字段会自然返回空串,UI 降级显示空文案(按钮仍可点)。
|
||
* 这样的好处: 远端 bundle 偶尔缺个别 key 时, **不会让整个 boot 失败**,
|
||
* 隐私弹窗等首屏 UI 仍能弹出(只是文案可能为空),用户体验更好。
|
||
*
|
||
* 关键:远端 bundle 的 key 命名约定是**驼峰式** `components.privacyPopup.xxx`,
|
||
* 而不是 kebab-case `components.privacy-popup`。
|
||
* 后端 PHP 数组的 key 命名风格统一为驼峰, 前端 $lang('components.privacyPopup.xxx')
|
||
* 的调用也是驼峰, 因此 bundle tree 中 `tree.components.privacyPopup` 才是正确路径。
|
||
* 这里必须**用驼峰名查找**, 否则会把"远端结构正确"误判为"不完整"。
|
||
*
|
||
* 设计取舍: 为什么不是"必须包含 defaultAppNameMp 才算完整"?
|
||
* - 后端可能在某些时刻少发某个 key(部署期间、缓存未刷新等)
|
||
* - 之前的"严格校验"会导致整个 boot 失败, 远端数据被完全丢弃, UI 显示 raw key
|
||
* - 改为"粗粒度校验": 只要 tree 不是 null/undefined/完全空对象, 就视为可用
|
||
* - 缺失的 key 让 $lang() 自然返回空, UI 降级显示(空文案/空按钮文字)
|
||
* - 静默(只记录 warn)而非抛错, 避免触发"不写 remoteTreeCache"分支
|
||
*/
|
||
function isBundleComplete(tree) {
|
||
// 1) tree 必须是对象(不能 null/undefined/字符串等)
|
||
if (!tree || typeof tree !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
// 2) tree 不能是空对象(空对象没有 common 也没有 components, 显然不可用)
|
||
const treeKeys = Object.keys(tree);
|
||
if (treeKeys.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
// 3) tree 必须有 common 节点(基础文案, 几乎所有 UI 都依赖)
|
||
if (!tree.common || typeof tree.common !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
// 4) tree 必须有 components 节点(组件级文案)
|
||
if (!tree.components || typeof tree.components !== 'object') {
|
||
return false;
|
||
}
|
||
|
||
// === 以下为软校验(只 warn 不 return false) ===
|
||
// 设计: 不再因为"缺某个 key"就让整个 boot 失败
|
||
// 缺失的字段让 $lang() 自然返回空, UI 降级显示
|
||
|
||
// 软校验 1: components.privacyPopup
|
||
const privacyPopup = tree.components.privacyPopup;
|
||
if (!privacyPopup || typeof privacyPopup !== 'object') {
|
||
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
||
} else if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
|
||
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
||
}
|
||
|
||
// 软校验 2: 哪些 key 看起来像隐私弹窗,但在错误位置?(诊断)
|
||
// 此处仅保留注释说明, 不在生产代码中输出
|
||
return true;
|
||
}
|
||
|
||
// 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败)
|
||
// 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒"
|
||
let remoteStatus = 'unknown';
|
||
let remoteStatusAt = 0;
|
||
const remoteStatusListeners = [];
|
||
|
||
/**
|
||
* 校验 source 配置:只接受 local / remote / hybrid / remote-only,其它一律视为 remote(安全默认)
|
||
*/
|
||
function normalizeSource(s) {
|
||
return ['local', 'remote', 'hybrid', 'remote-only'].includes(s) ? s : 'remote';
|
||
}
|
||
const SOURCE = normalizeSource(langConfig.source);
|
||
const IS_REMOTE_ONLY = SOURCE === 'remote-only';
|
||
|
||
/**
|
||
* 远端失败原因归一(对外稳定枚举,内部细节不外泄)
|
||
* - timeout : 网络/服务端超时
|
||
* - network : 网络层失败(断网/DNS/...)
|
||
* - http : 业务码非 0
|
||
* - parse : 响应解析失败
|
||
* - exception : JS 异常
|
||
*/
|
||
function normalizeReason(s) {
|
||
const allow = ['timeout', 'network', 'http', 'parse', 'exception', 'unknown'];
|
||
return allow.includes(s) ? s : 'unknown';
|
||
}
|
||
|
||
/**
|
||
* 设置远端状态 + 触发监听器 + 发 uni.$emit
|
||
* 注意:ok 和 fail 都是"终态",允许相互覆盖
|
||
* detail 字段只对外透出 enum(无原始 message / error),避免内部栈外泄
|
||
*/
|
||
function setRemoteStatus(s, detail) {
|
||
if (remoteStatus === s) return;
|
||
const prev = remoteStatus;
|
||
remoteStatus = s;
|
||
remoteStatusAt = Date.now();
|
||
// 归一化 detail:reason,detail 内部不向外透 error/message
|
||
const safeDetail = {
|
||
reason: normalizeReason(detail && detail.reason),
|
||
lang: detail && detail.lang,
|
||
};
|
||
remoteStatusListeners.forEach(fn => {
|
||
try { fn(s, safeDetail); } catch (e) { /* 忽略监听器异常 */ }
|
||
});
|
||
try { uni.$emit && uni.$emit('lang:remote-status', { from: prev, to: s, ...safeDetail }); } catch (e) {}
|
||
// 状态变成 fail 时,额外发一个"已切到本地兜底"事件,方便 UI 弹 toast
|
||
if (s === 'fail') {
|
||
try { uni.$emit && uni.$emit('lang:fallback', { reason: safeDetail.reason }); } catch (e) {}
|
||
}
|
||
}
|
||
|
||
// 处理页面目录映射
|
||
function processRoutePath(route) {
|
||
let routeParts = route.split("/");
|
||
|
||
// ---- 处理页面目录映射 <begin> 分包造成的,需要根据实际目录结构进行映射----
|
||
// 先处理特殊的分包路径
|
||
if (routeParts[0] === 'pages_tool') {
|
||
// pages_tool 分包下的页面,直接使用子目录作为语言包路径
|
||
routeParts = [routeParts[1], ...routeParts.slice(2)];
|
||
} else if (routeParts[0] === 'pages_goods') {
|
||
// pages_goods 分包映射到 goods 目录
|
||
routeParts[0] = 'goods';
|
||
} else if (routeParts[0] === 'pages_member') {
|
||
// pages_member 分包映射到 member 目录
|
||
routeParts[0] = 'member';
|
||
} else if (routeParts[0] === 'pages_order') {
|
||
// pages_order 分包映射到 order 目录
|
||
routeParts[0] = 'order';
|
||
} else if (routeParts[0] === 'pages_promotion') {
|
||
// pages_promotion 分包特殊处理
|
||
const promotionModules = ['point', 'fenxiao', 'merch'];
|
||
if (routeParts[1] && promotionModules.includes(routeParts[1])) {
|
||
routeParts = [routeParts[1], ...routeParts.slice(2)];
|
||
}
|
||
}
|
||
// ---- 处理页面目录目录映射 <end>----
|
||
|
||
// 去掉pages目录,只保留子目录
|
||
if (routeParts[0] === 'pages') {
|
||
routeParts = routeParts.slice(1);
|
||
}
|
||
|
||
return routeParts.join("/");
|
||
}
|
||
|
||
// 加载语言包(同步方式)
|
||
function loadLangPackSync(lang, path) {
|
||
try {
|
||
if (loadedLangPacks[`${lang}_${path}`]) {
|
||
return loadedLangPacks[`${lang}_${path}`];
|
||
}
|
||
const langData = require(`@/lang/${lang}/${path}.js`).lang;
|
||
loadedLangPacks[`${lang}_${path}`] = langData;
|
||
return langData;
|
||
} catch (error) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获得当前本地语言
|
||
* @returns
|
||
*/
|
||
function getCurrentLocale() {
|
||
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
||
const val = cachedStorageGet('lang', null);
|
||
return val || langConfig.defaultLang;
|
||
}
|
||
|
||
/**
|
||
* 获得当前租户 id(用于多租户文案隔离)
|
||
* 复用 Config.uniacid 作为兜底,避免硬编码 0
|
||
*/
|
||
function getCurrentUniacid() {
|
||
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
||
const u = cachedStorageGet('uniacid', null);
|
||
return u ? parseInt(u, 10) || Config.uniacid : Config.uniacid;
|
||
}
|
||
|
||
/**
|
||
* 当前平台类型(多端翻译隔离)
|
||
* 复用 Platform.getAppType(),与服务端 /api/BaseApi 的 $app_type 对齐
|
||
*/
|
||
function getCurrentAppType() {
|
||
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
|
||
}
|
||
|
||
/**
|
||
* 持久化 key: <prefix><uniacid>_<appType>_<lang>
|
||
* 三维隔离:多租户 × 多端 × 多语种,任何一维切换都拿不到旧值
|
||
*/
|
||
function getOverrideStorageKey(lang) {
|
||
const u = getCurrentUniacid();
|
||
const a = getCurrentAppType();
|
||
return `${langConfig.remote.storagePrefix}${u}_${a}_${lang}`;
|
||
}
|
||
|
||
/**
|
||
* 内存 cache key: <uniacid>_<appType>_<lang>
|
||
* 必须与 storage key 三维一致 — 切租户时内存不会"巧合"命中旧值
|
||
*/
|
||
function getOverrideMemKey(lang, uniacid, appType) {
|
||
const u = uniacid !== undefined ? uniacid : getCurrentUniacid();
|
||
const a = appType !== undefined ? appType : getCurrentAppType();
|
||
return `${u}_${a}_${lang}`;
|
||
}
|
||
|
||
/**
|
||
* 把"远端扁平覆写 map"按 key 路径合并到 nested 对象上
|
||
* null/undefined/非 string 视为"还原",不写入(本地值保留)
|
||
* 空串 "" 是合法覆写,会写入
|
||
*
|
||
* 危险 key 过滤:拦截 __proto__/constructor/prototype,防止恶意覆写污染对象原型链
|
||
*/
|
||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||
|
||
function applyOverrides(merged, flatMap) {
|
||
if (!flatMap) return;
|
||
// 遍历路径 visited 集合:防止循环引用(如 cur[p] 恰好指向 cur 自身或父级)
|
||
const visited = new Set();
|
||
for (const key in flatMap) {
|
||
if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue;
|
||
const val = flatMap[key];
|
||
// 类型白名单:只接 string,其它都视为非法值,跳过(不污染 merged)
|
||
if (typeof val !== 'string') continue;
|
||
// 危险 key 过滤
|
||
const parts = String(key).split('.').filter(p => p && !FORBIDDEN_KEYS.has(p));
|
||
if (parts.length === 0) continue;
|
||
let cur = merged;
|
||
visited.clear();
|
||
visited.add(cur);
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
const p = parts[i];
|
||
// 条件:非对象 / null / 数组 → 必须创建新对象
|
||
// 或:已在 visited 中(循环引用) → 创建新对象覆盖
|
||
const needsNew = typeof cur[p] !== 'object' || cur[p] === null || Array.isArray(cur[p]) || visited.has(cur[p]);
|
||
if (needsNew) {
|
||
cur[p] = {};
|
||
}
|
||
cur = cur[p];
|
||
visited.add(cur);
|
||
}
|
||
cur[parts[parts.length - 1]] = val;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 拉取并刷新内存中的远端覆写
|
||
* 设计:
|
||
* 1) source === 'local' 时整个函数直接返回 null(永远走本地)
|
||
* 2) 否则:先从 storage 取上一份缓存作为首屏兜底
|
||
* 3) 再异步拉远端,根据结果设置三态机:
|
||
* - code=0 + 有 data -> ok
|
||
* - 任何失败/超时/异常 -> fail(reason 分类: timeout/network/parse/empty)
|
||
* 4) 拉取期间不抛错;不更新 overrideCache 时,既有的 local fallback 自然生效
|
||
*
|
||
* 缓存按 (appType, lang) 二维隔离,避免 H5/MP/APP 端 cache 串味
|
||
*
|
||
* @returns {Promise<null | { source:'cache'|'remote'|'local', map, version, ok:boolean, reason?:string }>}
|
||
*/
|
||
async function loadOverrides(lang) {
|
||
if (SOURCE === 'local') return null;
|
||
|
||
const mk = getOverrideMemKey(lang);
|
||
if (overrideInflight[mk]) return overrideInflight[mk];
|
||
|
||
const task = (async () => {
|
||
const sk = getOverrideStorageKey(lang);
|
||
// 1) 复用本地缓存作为首屏兜底
|
||
try {
|
||
const cached = uni.getStorageSync(sk);
|
||
if (cached && cached.map && !overrideCache[mk]) {
|
||
overrideCache[mk] = {
|
||
version: cached.version || 0,
|
||
map: cached.map || {},
|
||
ts: cached.ts || 0,
|
||
global_version: cached.global_version || 0,
|
||
};
|
||
}
|
||
} catch (e) { /* 忽略 */ }
|
||
if (!overrideCache[mk]) overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 };
|
||
|
||
// 2) 拉远端(async:false 让 sendRequest 返回 Promise)
|
||
// app_type 不用传,http.js 内部已自动注入
|
||
try {
|
||
const res = await Promise.race([
|
||
http.sendRequest({
|
||
url: langConfig.remote.overridesUrl,
|
||
data: {
|
||
lang,
|
||
version: overrideCache[mk].version,
|
||
global_version: overrideCache[mk].global_version || 0, // 穿透全局缓存
|
||
},
|
||
async: false,
|
||
}),
|
||
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), langConfig.remote.requestTimeout)),
|
||
]);
|
||
|
||
// 远端返回不合法(code!=0 / 没 data) -> fail
|
||
if (!res || res.code !== 0 || !res.data) {
|
||
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
|
||
setRemoteStatus('fail', { reason, lang });
|
||
return {
|
||
source: 'local', // 此时实际渲染:本地 + 空覆写 = 纯本地兜底
|
||
map: overrideCache[mk].map,
|
||
version: overrideCache[mk].version,
|
||
ok: false,
|
||
reason,
|
||
};
|
||
}
|
||
|
||
const d = res.data;
|
||
const remoteMap = (d.overrides && typeof d.overrides === 'object') ? d.overrides : {};
|
||
// 合并:远端为准(本身就是已合并"全局+租户 × 平台+跨端"的结果)
|
||
const merged = Object.assign({}, overrideCache[mk].map, remoteMap);
|
||
overrideCache[mk] = {
|
||
version: d.version || overrideCache[mk].version,
|
||
map: merged,
|
||
ts: Date.now(),
|
||
global_version: d.global_version || 0,
|
||
};
|
||
// 持久化
|
||
try { uni.setStorageSync(sk, overrideCache[mk]); } catch (e) {}
|
||
// 标记远端可用
|
||
setRemoteStatus('ok', { lang });
|
||
// 通知前端刷新
|
||
emitUpdated(lang);
|
||
return {
|
||
source: 'remote',
|
||
map: overrideCache[mk].map,
|
||
version: overrideCache[mk].version,
|
||
ok: true,
|
||
};
|
||
} catch (e) {
|
||
// 任何异常(网络/解析/...) -> 静默,落到本地
|
||
setRemoteStatus('fail', { reason: 'exception', error: String(e && e.message || e), lang });
|
||
return {
|
||
source: 'local',
|
||
map: overrideCache[mk].map,
|
||
version: overrideCache[mk].version,
|
||
ok: false,
|
||
reason: 'exception',
|
||
};
|
||
} finally {
|
||
delete overrideInflight[mk];
|
||
}
|
||
})();
|
||
|
||
overrideInflight[mk] = task;
|
||
return task;
|
||
}
|
||
|
||
/**
|
||
* 触发"翻译已更新"事件
|
||
* 复用现有 componentRefresh 模式:commit 后, golbalConfig.js 里注册的全局 computed
|
||
* `componentRefresh` 会感知到引用变化,所有使用它的组件自动重新渲染,
|
||
* 模板里的 {{$lang('xxx')}} 就会重新执行,读到最新 overrideCache
|
||
*/
|
||
function emitUpdated(lang) {
|
||
invalidateLangCaches();
|
||
try {
|
||
const store = require('@/store/index.js').default;
|
||
store.commit('setComponentRefresh');
|
||
} catch (e) {}
|
||
try { uni.$emit && uni.$emit('lang:updated', { lang }); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* remote-only 模式下,为当前页面做 tree 归一化
|
||
* 把页面路径对应的 subtree 合并到顶层,兼容 $lang('title') 等短 key 调用
|
||
*
|
||
* 历史 bug: 之前直接 `deepMergeLang(tree, currentPageLeaf)`,
|
||
* 如果 currentPageLeaf 顶层有一个与 `tree.login` 命名空间同名(key)且值为字符串的键
|
||
* (例如 `login.login.js` 里的 `login: '登录/注册'` 会出现在 leaf 顶层),
|
||
* 会触发 `merge` 的"基本类型覆盖对象"分支,把 `tree.login` 整个 namespace 替换为字符串,
|
||
* 导致 `tree.login.login.welcomeText` 等所有子页 key 全部失效。
|
||
*
|
||
* 修复方案(对齐 `normalizePageLangPack` 的两层合并):
|
||
* 1) namespaced merge: 把 currentPageLeaf 按 langPath 段逐层包成 `{seg1: {seg2: leaf}}`,
|
||
* 再 merge 进 tree —— 同样的 key 仍能递归合并,而 namespace 不会被字符串覆盖。
|
||
* 2) 在 result 上额外"轻量 overlay":把 leaf 里的 SCALAR key 平铺到 root,
|
||
* 但跳过 target 已经是 namespace 对象的 key,避免再次触发 clobber。
|
||
* 这一步让 `$lang('title')` / `$lang('welcomeText')` 这类短 key 仍能在 root 命中页面副本。
|
||
*/
|
||
function normalizeRemoteTreeForPage(tree, langPath) {
|
||
if (!tree || typeof tree !== 'object') return tree || {};
|
||
if (!langPath) return tree;
|
||
|
||
const segments = String(langPath).split('/').filter(Boolean);
|
||
if (!segments.length) return tree;
|
||
|
||
let leaf = tree;
|
||
let hasLeaf = true;
|
||
for (const segment of segments) {
|
||
const key = toCamelCaseName(segment);
|
||
if (leaf && typeof leaf === 'object' && !Array.isArray(leaf) && leaf[key] !== undefined) {
|
||
leaf = leaf[key];
|
||
} else {
|
||
hasLeaf = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const lastSegment = toCamelCaseName(segments[segments.length - 1]);
|
||
const currentPageLeaf =
|
||
hasLeaf && leaf && typeof leaf === 'object' && !Array.isArray(leaf)
|
||
? leaf
|
||
: (tree[lastSegment] && typeof tree[lastSegment] === 'object' && !Array.isArray(tree[lastSegment]) ? tree[lastSegment] : null);
|
||
|
||
if (!currentPageLeaf) return tree;
|
||
|
||
// (1) namespaced merge
|
||
const namespaced = {};
|
||
let cursor = namespaced;
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const segment = toCamelCaseName(segments[i]);
|
||
cursor[segment] = i === segments.length - 1 ? deepCloneLang(currentPageLeaf) : {};
|
||
cursor = cursor[segment];
|
||
}
|
||
const merged = deepMergeLang(deepCloneLang(tree), namespaced);
|
||
|
||
// (2) 短 key 兼容: 把 leaf 里的 SCALAR 平铺到 root,但跳过 target 已有 namespace 的 key
|
||
try {
|
||
for (const k of Object.prototype.hasOwnProperty.call(currentPageLeaf, '__proto__') ? [] : Object.keys(currentPageLeaf)) {
|
||
if (FORBIDDEN_KEYS.has(k)) continue;
|
||
const srcVal = currentPageLeaf[k];
|
||
const tgtVal = merged[k];
|
||
// 源非对象(基本类型)才平铺;源是对象则让上一步 namespaced merge 处理
|
||
if (srcVal === null || typeof srcVal !== 'object' || Array.isArray(srcVal)) {
|
||
// 若 target 已是 namespace 对象,跳过,避免 string-vs-object clobber
|
||
if (tgtVal && typeof tgtVal === 'object' && !Array.isArray(tgtVal)) continue;
|
||
merged[k] = srcVal;
|
||
} else {
|
||
// 源是对象 —— 递归合并到 merged[k](若存在)或跳过
|
||
if (tgtVal && typeof tgtVal === 'object' && !Array.isArray(tgtVal)) {
|
||
merged[k] = deepMergeLang(deepCloneLang(tgtVal), deepCloneLang(srcVal));
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* 静默 */ }
|
||
|
||
return merged;
|
||
}
|
||
|
||
// 导出主体
|
||
export default {
|
||
langList: langConfig.langList,
|
||
|
||
/**
|
||
* 是否 remote-only 模式
|
||
*/
|
||
isRemoteOnly() {
|
||
return IS_REMOTE_ONLY;
|
||
},
|
||
|
||
/**
|
||
* boot() 是否已完成(remote-only 模式下用于判断翻译是否就绪)
|
||
*/
|
||
isBootCompleted() {
|
||
return bootCompleted;
|
||
},
|
||
|
||
/**
|
||
* 获得当前本地语言
|
||
* @returns
|
||
*/
|
||
getCurrentLocale,
|
||
|
||
/**
|
||
* 从后端数据中提取当前语言的文案(向后兼容)
|
||
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
||
*
|
||
* 用法:
|
||
* this.$langConfig.getField(item, 'title') // 取标题
|
||
* this.$langConfig.getField(item, 'text') // 取 tabBar 文案
|
||
* this.$langConfig.getField(item, 'goods_name') // 取商品名
|
||
*
|
||
* @param {Object} data 后端数据对象
|
||
* @param {string} field 基础字段名
|
||
* @returns {string}
|
||
*/
|
||
getField(data, field) {
|
||
return getI18nField(data, field);
|
||
},
|
||
|
||
/**
|
||
* 当前租户 id
|
||
*/
|
||
getCurrentUniacid,
|
||
|
||
/**
|
||
* * 阻塞式预热:App.onLaunch 调用
|
||
* 等待远端覆写到达(多租户强依赖),但有上限
|
||
* 同时检测 uniacid 是否在 App 启动时已变更,自动触发刷新
|
||
*
|
||
* 完整性保证(remote-only 模式):
|
||
* - 第一次拉取 bundle 后, 用 isBundleComplete() 校验关键 key(defaultAppNameMp 等)
|
||
* - 不完整时 force 重试(跳过 memory/storage cache 重新拉远端)
|
||
* - force 重试仍不完整则抛错, **不静默回退到 bundleCache, 也不加载本地 lang 兜底**
|
||
* - 所以 boot() resolve 后, $lang() 要么返回真实翻译, 要么返回空(完整性失败时)
|
||
* - **永远不会有"残缺文案"被使用**
|
||
*
|
||
* @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout'|'remote-failed', status:'unknown'|'ok'|'fail', reason?:string, lang:string}>}
|
||
*/
|
||
boot() {
|
||
// 并发去重: 同一次 boot 未完成时,后续调用复用同一个 promise
|
||
if (_bootInflight) return _bootInflight;
|
||
_bootInflight = this._bootImpl();
|
||
const p = _bootInflight;
|
||
// 无论成功失败,清理 inflight 允许后续重新 boot
|
||
_bootInflight.then(() => { _bootInflight = null; }, () => { _bootInflight = null; });
|
||
return p;
|
||
},
|
||
|
||
async _bootImpl() {
|
||
const lang = getCurrentLocale();
|
||
|
||
// source === 'local' 时不需要预热远端,本地全量已可用
|
||
if (SOURCE === 'local') {
|
||
bootCompleted = true;
|
||
return { ok: true, source: 'local', status: remoteStatus, lang };
|
||
}
|
||
|
||
// remote-only 模式:加载完整 bundle tree
|
||
if (IS_REMOTE_ONLY) {
|
||
const currentUid = getCurrentUniacid();
|
||
try {
|
||
const lastUid = uni.getStorageSync('lang_last_uniacid');
|
||
if (lastUid && String(lastUid) !== String(currentUid)) {
|
||
clearBundleCache({ lang });
|
||
}
|
||
uni.setStorageSync('lang_last_uniacid', currentUid);
|
||
} catch (e) {}
|
||
|
||
const uniacid = getCurrentUniacid();
|
||
const appType = getCurrentAppType();
|
||
const _preloadMk = getOverrideMemKey(lang, uniacid, appType);
|
||
|
||
try {
|
||
// 1) 第一次拉取(可能命中 memory/storage cache)
|
||
let result = await loadBundleTree({ lang, appType, uniacid });
|
||
|
||
// 2) 完整性校验
|
||
// 远端返回的 bundle 也可能不完整(CDN 截断/中间代理丢包/服务端 bug)
|
||
// 用 isBundleComplete() 做结构性校验 → 不完整时 force 重试
|
||
// force 重试时 loadBundleTree 内部会跳过所有 cache 重新拉远端
|
||
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
||
let isComplete1 = isBundleComplete(result.tree);
|
||
if (!isComplete1) {
|
||
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
||
delete remoteTreeCache[_preloadMk];
|
||
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
||
}
|
||
|
||
// 3) 再次校验, force 重试后仍不完整 → 抛错
|
||
// 不静默回退到 bundleCache(避免使用不完整 bundle)
|
||
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
||
let isComplete2 = isBundleComplete(result.tree);
|
||
if (!isComplete2) {
|
||
throw new Error('lang bundle unusable after force retry');
|
||
}
|
||
|
||
// 4) bundle 完整, 标记 boot 完成
|
||
const { tree, version, source } = result;
|
||
remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version, tree, ts: Date.now(), source };
|
||
setRemoteStatus('ok', { lang });
|
||
bootCompleted = true;
|
||
emitUpdated(lang);
|
||
return { ok: true, source, status: 'ok', version, lang };
|
||
} catch (e) {
|
||
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);
|
||
|
||
// 强制重试失败 / 远端拉取异常时,用任意缓存兜底(不检查 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 };
|
||
}
|
||
}
|
||
|
||
// 非 remote-only: 走原有的 loadOverrides 逻辑
|
||
const remote = langConfig.remote;
|
||
const t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout));
|
||
|
||
const currentUid = getCurrentUniacid();
|
||
try {
|
||
const lastUid = uni.getStorageSync('lang_last_uniacid');
|
||
if (lastUid && String(lastUid) !== String(currentUid)) {
|
||
this.onUniacidChange();
|
||
}
|
||
uni.setStorageSync('lang_last_uniacid', currentUid);
|
||
} catch (e) {}
|
||
|
||
const result = await Promise.race([loadOverrides(lang), t]);
|
||
const finalStatus = remoteStatus;
|
||
bootCompleted = true;
|
||
|
||
if (result && result.__timeout) {
|
||
return { ok: false, source: 'timeout', status: finalStatus, reason: 'timeout', lang };
|
||
}
|
||
return {
|
||
ok: !!(result && result.ok),
|
||
source: (result && result.source) || 'local',
|
||
status: finalStatus,
|
||
reason: result && result.reason,
|
||
lang,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* * 切租户/uniacid 后调用:清旧缓存,强制重拉
|
||
* 受 langConfig.remote.refreshOnUniacidChange 配置控制
|
||
*/
|
||
onUniacidChange() {
|
||
if (SOURCE === 'local') return;
|
||
// 尊重配置项:关闭时不强制刷新
|
||
if (langConfig.remote.refreshOnUniacidChange === false) return;
|
||
// 切租户后清除 storage 缓存,确保下次读时拿到新 uniacid
|
||
invalidateStorageCache('uniacid');
|
||
const lang = getCurrentLocale();
|
||
|
||
if (IS_REMOTE_ONLY) {
|
||
clearBundleCache({ lang });
|
||
delete remoteTreeCache[getOverrideMemKey(lang)];
|
||
loadBundleTree({ lang });
|
||
return;
|
||
}
|
||
|
||
const sk = getOverrideStorageKey(lang);
|
||
try { uni.removeStorageSync(sk); } catch (e) {}
|
||
delete overrideCache[getOverrideMemKey(lang)];
|
||
loadOverrides(lang);
|
||
},
|
||
|
||
/**
|
||
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
||
* @param {(status:string, detail:object) => void} fn
|
||
* @returns {() => void} unsubscribe
|
||
*/
|
||
onRemoteStatusChange(fn) {
|
||
if (typeof fn !== 'function') return () => {};
|
||
remoteStatusListeners.push(fn);
|
||
// 立刻 push 当前状态(订阅时拿到初始值)
|
||
try { fn(remoteStatus, { at: remoteStatusAt, source: SOURCE }); } catch (e) {}
|
||
return () => {
|
||
const i = remoteStatusListeners.indexOf(fn);
|
||
if (i >= 0) remoteStatusListeners.splice(i, 1);
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 取得当前远端状态('unknown' | 'ok' | 'fail')
|
||
*/
|
||
getRemoteStatus() { return remoteStatus; },
|
||
|
||
/**
|
||
* 当前 source 配置(规范化后):'local' | 'remote' | 'hybrid' | 'remote-only'
|
||
*/
|
||
getSource() { return SOURCE; },
|
||
|
||
/**
|
||
* * 解析多语言
|
||
* @param {Object} field
|
||
*/
|
||
lang(field) {
|
||
// 递归保护:防止响应式 getter 回调中间接调用 $lang 导致栈溢出
|
||
if (_langRecursionDepth >= MAX_LANG_RECURSION_DEPTH) {
|
||
return typeof field === 'string' ? field : String(field || '');
|
||
}
|
||
|
||
// remote-only 模式(小程序/鸿蒙): boot 未完成时返回空串
|
||
// H5 不走此分支(main.js 已 await boot 后才 $mount)
|
||
// boot 完成后 emitUpdated 触发 $forceUpdate, 页面重新渲染显示真实翻译
|
||
if (IS_REMOTE_ONLY && !bootCompleted) {
|
||
return '';
|
||
}
|
||
|
||
_langRecursionDepth++;
|
||
try {
|
||
const locale = getCurrentLocale();
|
||
|
||
// ------- 优化1: 页面路由缓存(页面生命周期内不变) -------
|
||
// 每次都检查当前页面实例是否变化(页面切换后需重新计算)
|
||
let _page = getCurrentPages()[getCurrentPages().length - 1];
|
||
let langPath;
|
||
if (_page && _page === _cachedLangPathPage && _cachedLangPath) {
|
||
// 同一页面实例,直接复用缓存
|
||
langPath = _cachedLangPath;
|
||
} else if (_page) {
|
||
// 新页面或首次计算,重新计算并缓存
|
||
const route = _page.route || (_page.$page && _page.$page.path) || '';
|
||
langPath = route ? processRoutePath(route) : '';
|
||
_cachedLangPath = langPath;
|
||
_cachedLangPathPage = _page;
|
||
} else {
|
||
// _page 为空时,只使用 common 语言包
|
||
langPath = '';
|
||
_cachedLangPath = '';
|
||
_cachedLangPathPage = null;
|
||
}
|
||
|
||
// ------- 优化2: 字段解析结果缓存(命中直接返回) -------
|
||
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
|
||
const cachedFieldResult = _fieldResultCache[fieldCacheKey];
|
||
const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== '';
|
||
const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production');
|
||
if (cachedFieldResult !== undefined) {
|
||
let value = cachedFieldResult;
|
||
// 没找到翻译
|
||
if (value === field) {
|
||
// 有默认值 → 使用默认值
|
||
if (hasDefault) {
|
||
return arguments[1];
|
||
}
|
||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||
if (!__DEV__) {
|
||
return '';
|
||
}
|
||
// 开发环境 → 返回 raw key(便于发现问题)
|
||
return value;
|
||
}
|
||
// 带参数的翻译:支持位置参数({0})和命名参数({name})
|
||
if (arguments.length > 1) {
|
||
// 从第 2 个参数开始提取参数数组
|
||
const args = new Array(arguments.length - 1);
|
||
for (let i = 1; i < arguments.length; i++) {
|
||
args[i - 1] = arguments[i];
|
||
}
|
||
value = applyLangParams(value, args);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
// ------- 优化3: 合并语言包缓存(避免每次 deepClone + merge + 覆写应用) -------
|
||
const mergeCacheKey = `${locale}__${langPath}__${_overrideVersion}`;
|
||
let mergedLang = _mergedLangCache[mergeCacheKey];
|
||
if (!mergedLang) {
|
||
try {
|
||
if (IS_REMOTE_ONLY) {
|
||
// remote-only 模式:直接复用远端 bundle tree,不再 require 本地文件
|
||
const cache = remoteTreeCache[getOverrideMemKey(locale)];
|
||
const baseTree = (cache && cache.tree) ? cache.tree : {};
|
||
mergedLang = normalizeRemoteTreeForPage(baseTree, langPath);
|
||
} else {
|
||
const lang = loadLangPackSync(locale, 'common');
|
||
const currentPageLang = langPath ? normalizePageLangPack(langPath, loadLangPackSync(locale, langPath)) : {};
|
||
mergedLang = deepCloneLang(lang);
|
||
for (const sharedPath of SHARED_LANG_PACKS) {
|
||
const sharedLang = normalizeSharedLangPack(sharedPath, loadLangPackSync(locale, sharedPath));
|
||
if (sharedLang && typeof sharedLang === 'object') {
|
||
mergedLang = deepMergeLang(mergedLang, deepCloneLang(sharedLang));
|
||
}
|
||
}
|
||
mergedLang = deepMergeLang(mergedLang, deepCloneLang(currentPageLang));
|
||
const ovr = overrideCache[getOverrideMemKey(locale)];
|
||
if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map);
|
||
}
|
||
} catch (e) {
|
||
console.error('解析语言包失败:', e, { langPath, field, locale });
|
||
mergedLang = {};
|
||
}
|
||
_mergedLangCache[mergeCacheKey] = mergedLang;
|
||
}
|
||
|
||
// ------- 字段解析 -------
|
||
let value = field;
|
||
var arr = field.split(".");
|
||
if (arr.length > 1) {
|
||
let temp = mergedLang;
|
||
let tempVisited = new Set([temp]);
|
||
let found = true;
|
||
for (let key of arr) {
|
||
if (temp[key] !== undefined) {
|
||
temp = temp[key];
|
||
if (tempVisited.has(temp)) {
|
||
found = false;
|
||
break;
|
||
}
|
||
tempVisited.add(temp);
|
||
} else {
|
||
found = false;
|
||
break;
|
||
}
|
||
}
|
||
value = found ? (typeof temp === 'string' ? temp : field) : field;
|
||
} else {
|
||
const raw = mergedLang[field];
|
||
value = (raw !== undefined && typeof raw === 'string') ? raw : field;
|
||
}
|
||
|
||
if (value === field && arr.length >= 3) {
|
||
const qualifiedValue = tryResolveQualifiedPageField(locale, field);
|
||
if (qualifiedValue !== undefined) value = qualifiedValue;
|
||
}
|
||
|
||
if (value === field && field !== 'title') {
|
||
const loadedPageValue = tryResolveLoadedPageField(locale, field);
|
||
if (loadedPageValue !== undefined) value = loadedPageValue;
|
||
}
|
||
|
||
// 将结果存入字段缓存(不含参数替换部分:参数每次可能不同)
|
||
_fieldResultCache[fieldCacheKey] = value;
|
||
|
||
// 没找到翻译
|
||
if (value === field) {
|
||
// 有默认值 → 使用默认值
|
||
if (hasDefault) {
|
||
value = arguments[1];
|
||
} else if (!__DEV__) {
|
||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||
value = '';
|
||
}
|
||
// 开发环境无默认值 → 保留 raw key(便于发现问题)
|
||
} else if (arguments.length > 1) {
|
||
// 带参数的翻译:支持位置参数({0})和命名参数({name})
|
||
const args = new Array(arguments.length - 1);
|
||
for (let i = 1; i < arguments.length; i++) {
|
||
args[i - 1] = arguments[i];
|
||
}
|
||
value = applyLangParams(value, args);
|
||
}
|
||
|
||
if (value == undefined) value = '';
|
||
|
||
// 多语言调试
|
||
if (__DEV__ && field == value) {
|
||
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
||
}
|
||
|
||
return value;
|
||
} finally {
|
||
_langRecursionDepth--;
|
||
if (_langRecursionDepth < 0) _langRecursionDepth = 0;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* * 切换语言
|
||
* @param {String} value 语言值
|
||
* @param {String} url 切换后跳转的页面url
|
||
*/
|
||
async change(value, url = '/pages_tool/member/index') {
|
||
let _page = getCurrentPages()[getCurrentPages().length - 1];
|
||
if (!_page) return;
|
||
|
||
const prevLocale = getCurrentLocale();
|
||
uni.setStorageSync("lang", value);
|
||
// 切语言后清除 storage 缓存,确保下次读时拿到新值
|
||
invalidateStorageCache('lang');
|
||
const locale = getCurrentLocale();
|
||
|
||
// 切语言后清除语言包合并缓存 + 字段解析缓存(这些是翻译结果缓存,必须清)
|
||
invalidateLangCaches();
|
||
|
||
// 策略优化: 保留旧语种的 bundle/override 缓存,只在内存中限制最大语种数
|
||
// 这样切回旧语种时能直接命中缓存,不重复请求
|
||
// 仅清空 loadedLangPacks 的合并结果缓存(非远端原始数据)
|
||
Object.keys(loadedLangPacks).forEach(key => {
|
||
if (!key.startsWith(locale + '_')) delete loadedLangPacks[key];
|
||
});
|
||
|
||
// 限制 remoteTreeCache 最大保留 3 个语种,超出时淘汰最旧的
|
||
const maxLocaleCache = 3;
|
||
const cachedLocales = Object.keys(remoteTreeCache);
|
||
if (cachedLocales.length >= maxLocaleCache) {
|
||
// 按时间戳排序,淘汰最旧的非当前语种
|
||
const sorted = cachedLocales
|
||
.filter(mk => !mk.endsWith('_' + locale))
|
||
.sort((a, b) => (remoteTreeCache[a].ts || 0) - (remoteTreeCache[b].ts || 0));
|
||
while (sorted.length >= maxLocaleCache) {
|
||
const evict = sorted.shift();
|
||
delete remoteTreeCache[evict];
|
||
}
|
||
}
|
||
|
||
// 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络)
|
||
// 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch
|
||
// 注意:loadOverrides 内部已 emitUpdated(若成功),refresh() 内部不再重复 emit
|
||
if (IS_REMOTE_ONLY) {
|
||
try {
|
||
const uniacid = getCurrentUniacid();
|
||
const appType = getCurrentAppType();
|
||
const { tree, version, source } = await loadBundleTree({ lang: value, uniacid, appType });
|
||
remoteTreeCache[getOverrideMemKey(value, uniacid, appType)] = { version, tree, ts: Date.now(), source };
|
||
setRemoteStatus('ok', { lang: value });
|
||
// 新语种 bundle 就绪后,同步 App 全局 promise 并触发刷新
|
||
try {
|
||
const app = getApp();
|
||
if (app && app.$langReady) app.$langReady = Promise.resolve({ ok: true, source, status: 'ok', version, lang: value });
|
||
} catch (e) {}
|
||
emitUpdated(value);
|
||
} catch (e) { /* 静默 */ }
|
||
} else if (SOURCE !== 'local') {
|
||
try { await loadOverrides(value); } catch (e) { /* 静默 */ }
|
||
}
|
||
|
||
this.refresh(false); // false = 不重复 emitUpdated
|
||
|
||
if (url) {
|
||
uni.reLaunch({ url: url });
|
||
}
|
||
},
|
||
|
||
//刷新标题、tabbar
|
||
// @param {boolean} [emit=true] 是否触发 setComponentRefresh
|
||
// change() 流程里 loadOverrides 已经 emit 过,这里要传 false 防重复
|
||
refresh(emit = true) {
|
||
// 重入保护:防止 refresh() 内部调用 lang() → 触发 storage 事件 → App.vue 再次调用 refresh() 形成死循环
|
||
if (_refreshInProgress) return;
|
||
_refreshInProgress = true;
|
||
try {
|
||
let _page = getCurrentPages()[getCurrentPages().length - 1];
|
||
if (_page) this.title(this.lang("title"));
|
||
|
||
if (emit) {
|
||
// 远端 i18n 翻译更新后,推送重渲染信号;
|
||
// 复用现有 componentRefresh 机制, golbalConfig.js 已把 componentRefresh
|
||
// 注册为全局 computed,commit 后所有引用它的组件自动重渲染
|
||
emitUpdated(getCurrentLocale());
|
||
}
|
||
|
||
//设置tabbar的文字语言
|
||
// uni.setTabBarItem({
|
||
// index: 0,
|
||
// text: this.lang("tabBar.home")
|
||
// });
|
||
// uni.setTabBarItem({
|
||
// index: 1,
|
||
// text: this.lang("tabBar.category")
|
||
// });
|
||
// uni.setTabBarItem({
|
||
// index: 2,
|
||
// text: this.lang("tabBar.cart")
|
||
// });
|
||
// uni.setTabBarItem({
|
||
// index: 3,
|
||
// text: this.lang("tabBar.member")
|
||
// });
|
||
} finally {
|
||
_refreshInProgress = false;
|
||
}
|
||
},
|
||
|
||
title(str) {
|
||
if (str) {
|
||
uni.setNavigationBarTitle({
|
||
title: str,
|
||
success: function (res) {
|
||
},
|
||
fail: function (err) {
|
||
}
|
||
});
|
||
}
|
||
},
|
||
|
||
// 获取语言包列表
|
||
// 优化:只读取 common.name 字段,不要全量 require 进 loadedLangPacks
|
||
// 否则后续切到不常用的语种会"恰好命中缓存",但首次访问要全量加载
|
||
list() {
|
||
var list = [];
|
||
// 用一次性临时缓存避免污染 loadedLangPacks
|
||
const tmp = {};
|
||
const getCommonName = (langType) => {
|
||
try {
|
||
if (tmp[langType]) return tmp[langType];
|
||
|
||
// remote-only 模式下优先从 bundle tree 读取
|
||
if (IS_REMOTE_ONLY) {
|
||
const cache = remoteTreeCache[getOverrideMemKey(langType)];
|
||
if (cache && cache.tree && cache.tree.common && cache.tree.common.name) {
|
||
tmp[langType] = cache.tree.common.name;
|
||
return tmp[langType];
|
||
}
|
||
}
|
||
|
||
const data = require(`@/lang/${langType}/common.js`).lang;
|
||
tmp[langType] = (data && data.common) ? data.common.name : langType;
|
||
return tmp[langType];
|
||
} catch (e) {
|
||
return langType;
|
||
}
|
||
};
|
||
for (var i = 0; i < langConfig.langList.length; i++) {
|
||
let langType = langConfig.langList[i];
|
||
list.push({ name: getCommonName(langType), value: langType });
|
||
}
|
||
return list;
|
||
},
|
||
|
||
// 内部测试/调试用
|
||
__debug_overrideCache() { return overrideCache; },
|
||
__debug_loadOverrides: loadOverrides,
|
||
}
|