Files
lucky_shop/common/js/lang.js
2026-09-20 14:27:51 +08:00

1522 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 不能是空对象(空对象没有任何可用文案, 显然不可用)
const treeKeys = Object.keys(tree);
if (treeKeys.length === 0) {
return false;
}
// === 粗粒度校验(与上方设计哲学一致) ===
// 不再硬性要求 common / components 节点必须同时存在。
// 远端可能在某些时刻少发某个节点(部署期间、缓存未刷新、租户未配置 common 等),
// 此时仍应让 boot 成功写入 remoteTreeCache, 缺失字段由 $lang() 自然回落为空,
// UI 降级显示(空文案/空按钮文字), 而不是让整个 boot 失败导致整站变英文 / raw key。
// 软校验: components.privacyPopup(节点存在才检查, 避免 tree.components 为 undefined 时报错)
const privacyPopup = tree.components && tree.components.privacyPopup;
if (privacyPopup && typeof privacyPopup === 'object') {
// defaultAppNameMp 存在性仅作诊断, 不阻止 boot
if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
}
}
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;
// 沿 langPath 段向下找页面 leaf:同一段同时尝试 camelCase、snake_case 与原始段名
// (bundle 服务器侧对同一段实际可能用任一种命名,例如 point/order_list 在 bundle 里是
// `tree.point.order_list.X`,而 login/login 这种 camel 路径又是 `tree.login.login.X`;
// 路径含连字符(如 ai-chat/index)时,原始段名 `ai-chat` 是唯一能命中 bundle 的形式)。
let leaf = tree;
let hasLeaf = true;
for (const segment of segments) {
const camel = toCamelCaseName(segment);
const snake = toSnakeCaseName(segment);
const raw = segment;
// 优先 camelCase;回退 snake_case;再回退原始段名;都没有就放弃
const key = (leaf && typeof leaf === 'object' && !Array.isArray(leaf) && leaf[camel] !== undefined)
? camel
: (leaf && typeof leaf === 'object' && !Array.isArray(leaf) && leaf[snake] !== undefined
? snake
: (leaf && typeof leaf === 'object' && !Array.isArray(leaf) && raw !== camel && raw !== snake && leaf[raw] !== undefined
? raw
: null));
if (key === null) { hasLeaf = false; break; }
leaf = leaf[key];
}
const rawLast = segments[segments.length - 1];
const lastSegment = toCamelCaseName(rawLast);
const lastSegmentSnake = toSnakeCaseName(rawLast);
const currentPageLeaf =
hasLeaf && leaf && typeof leaf === 'object' && !Array.isArray(leaf)
? leaf
: (tree[lastSegment] && typeof tree[lastSegment] === 'object' && !Array.isArray(tree[lastSegment])
? tree[lastSegment]
: (tree[lastSegmentSnake] && typeof tree[lastSegmentSnake] === 'object' && !Array.isArray(tree[lastSegmentSnake])
? tree[lastSegmentSnake]
: (rawLast !== lastSegment && rawLast !== lastSegmentSnake && tree[rawLast] && typeof tree[rawLast] === 'object' && !Array.isArray(tree[rawLast])
? tree[rawLast]
: null)));
if (!currentPageLeaf) return tree;
// (1) namespaced merge —— 同时建 camelCase 与 snake_case 镜像键,
// 让两种调用形式都能落到同一份 leaf
const namespaced = {};
{
let cursor = namespaced;
for (let i = 0; i < segments.length; i++) {
const camel = toCamelCaseName(segments[i]);
cursor[camel] = i === segments.length - 1 ? deepCloneLang(currentPageLeaf) : {};
cursor = cursor[camel];
}
}
// 在 namespaced 的每个层级再挂一份 snake_case 镜像
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) : {};
}
}
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] !== '';
if (cachedFieldResult !== undefined) {
let value = cachedFieldResult;
// 没找到翻译
if (value === field) {
// 有默认值 → 使用默认值
if (hasDefault) {
return arguments[1];
}
// 开发环境 → 返回 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];
}
// 开发环境无默认值 → 保留 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 (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,
}