import { langConfig } from './config-external.js'; import Config from './config.js'; import http from './http.js'; import { Platform } from './platform.js'; import { loadBundleTree, getCachedBundle, clearBundleCache } from './lang-loader.js'; // 缓存已加载的语言包 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('goods_list.searchPlaceholder')。 result[lastSegment] = deepCloneLang(currentPageLeaf); } let 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]; } 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; // 远端可用性三态机:'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("/"); // ---- 处理页面目录映射 分包造成的,需要根据实际目录结构进行映射---- // 先处理特殊的分包路径 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)]; } } // ---- 处理页面目录目录映射 ---- // 去掉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: __ * 三维隔离:多租户 × 多端 × 多语种,任何一维切换都拿不到旧值 */ function getOverrideStorageKey(lang) { const u = getCurrentUniacid(); const a = getCurrentAppType(); return `${langConfig.remote.storagePrefix}${u}_${a}_${lang}`; } /** * 内存 cache key: __ * 必须与 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} */ 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 调用 */ 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; // 页面 leaf 合并到顶层,同时也保留完整路径 return deepMergeLang(deepCloneLang(tree), deepCloneLang(currentPageLeaf)); } // 导出主体 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 启动时已变更,自动触发刷新 * * @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', 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(); try { let result = await loadBundleTree({ lang, appType, uniacid }); // 只要不是从远端获取的(memory/cache),都强制刷新一次,避免旧缓存结构导致 missing key if (result.source !== 'remote') { try { result = await loadBundleTree({ lang, appType, uniacid, force: true }); } catch (e) { // 网络刷新失败仍用缓存兜底 } } 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 cached = getCachedBundle({ lang, uniacid, appType }); if (cached) { remoteTreeCache[getOverrideMemKey(lang, uniacid, appType)] = { version: cached.version, tree: cached.tree, ts: cached.ts, source: 'cache' }; setRemoteStatus('fail', { reason: 'exception', lang }); bootCompleted = true; emitUpdated(lang); return { ok: false, source: 'cache', status: 'fail', reason: e.message || 'bundle failed', lang }; } setRemoteStatus('fail', { reason: 'exception', lang }); bootCompleted = true; emitUpdated(lang); return { ok: false, source: 'local', status: 'fail', reason: e.message || '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 || ''} (${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, }