Files
lucky_shop/common/js/lang.js
ZF sun 071db2d70c feat(i18n): add remote translation override and hot update support
1. add remote translation override feature with multi-tenant isolation
2. implement hot reload for i18n texts via componentRefresh mechanism
3. add boot timeout fallback and local cache support
4. add events for translation update and fallback state
2026-06-10 14:12:49 +08:00

557 lines
17 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';
// 缓存已加载的语言包
const loadedLangPacks = {};
// 远端覆写:内存缓存
// shape: { [app_lang]: { version, map, ts, global_version } }
// key 格式 `${appType}_${lang}`,天然按平台 + 语种二维隔离,避免多端 cache 串味
const overrideCache = {};
// 远端覆写:并发去重
const overrideInflight = {};
// 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败)
// 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒"
let remoteStatus = 'unknown';
let remoteStatusAt = 0;
const remoteStatusListeners = [];
/**
* 校验 source 配置:只接受 local / remote / hybrid,其它一律视为 remote(安全默认)
*/
function normalizeSource(s) {
return ['local', 'remote', 'hybrid'].includes(s) ? s : 'remote';
}
const SOURCE = normalizeSource(langConfig.source);
/**
* 设置远端状态 + 触发监听器 + 发 uni.$emit
* 注意:ok 和 fail 都是"终态",允许相互覆盖
*/
function setRemoteStatus(s, detail) {
if (remoteStatus === s) return;
const prev = remoteStatus;
remoteStatus = s;
remoteStatusAt = Date.now();
remoteStatusListeners.forEach(fn => {
try { fn(s, detail || {}); } catch (e) { /* 忽略监听器异常 */ }
});
try { uni.$emit && uni.$emit('lang:remote-status', { from: prev, to: s, ...(detail || {}) }); } catch (e) {}
// 状态变成 fail 时,额外发一个"已切到本地兜底"事件,方便 UI 弹 toast
if (s === 'fail') {
try { uni.$emit && uni.$emit('lang:fallback', { reason: (detail && detail.reason) || 'remote-unavailable' }); } 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) {
console.error(`加载语言包 ${lang}/${path} 失败:`, error);
return {};
}
}
/**
* 获得当前本地语言
* @returns
*/
function getCurrentLocale() {
return uni.getStorageSync('lang') || langConfig.defaultLang;
}
/**
* 获得当前租户 id(用于多租户文案隔离)
* 复用 Config.uniacid 作为兜底,避免硬编码 0
*/
function getCurrentUniacid() {
const u = uni.getStorageSync('uniacid');
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>
* 加入 appType 是为了:H5 端缓存不会污染 MP/APP 端
*/
function getOverrideStorageKey(lang) {
const u = getCurrentUniacid();
const a = getCurrentAppType();
return `${langConfig.remote.storagePrefix}${u}_${a}_${lang}`;
}
/**
* 内存 cache key: <appType>_<lang>
* 与 storage key 一样按平台+语种二维区分
*/
function getOverrideMemKey(lang) {
return `${getCurrentAppType()}_${lang}`;
}
/**
* 把"远端扁平覆写 map"按 key 路径合并到 nested 对象上
* null/undefined/非 string 视为"还原",不写入(本地值保留)
* 空串 "" 是合法覆写,会写入
*/
function applyOverrides(merged, flatMap) {
if (!flatMap) return;
for (const key in flatMap) {
if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue;
const val = flatMap[key];
// 类型白名单:只接 string,其它都视为非法值,跳过(不污染 merged)
if (typeof val !== 'string') continue;
const parts = String(key).split('.');
let cur = merged;
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i];
if (typeof cur[p] !== 'object' || cur[p] === null || Array.isArray(cur[p])) {
cur[p] = {};
}
cur = cur[p];
}
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) {
try {
const store = require('@/store/index.js').default;
store.commit('setComponentRefresh');
} catch (e) {}
try { uni.$emit && uni.$emit('lang:updated', { lang }); } catch (e) {}
}
// 导出主体
export default {
langList: langConfig.langList,
/**
* 获得当前本地语言
* @returns
*/
getCurrentLocale,
/**
* 当前租户 id
*/
getCurrentUniacid,
/**
* * 阻塞式预热:App.onLaunch 调用
* 等待远端覆写到达(多租户强依赖),但有上限
* 同时检测 uniacid 是否在 App 启动时已变更,自动触发刷新
*
* @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:'unknown'|'ok'|'fail', reason?:string, lang:string}>}
*/
async boot() {
const lang = getCurrentLocale();
// source === 'local' 时不需要预热远端,本地全量已可用
if (SOURCE === 'local') {
return { ok: true, source: 'local', status: remoteStatus, lang };
}
const remote = langConfig.remote;
const t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout));
// 检测 uniacid 是否变了(防止 App.onLaunch 改了 uniacid 但没触发 onUniacidChange)
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) {}
// 跑一次远端拉取(被超时切走时,fail 状态会由 loadOverrides 内部设置)
const result = await Promise.race([loadOverrides(lang), t]);
const finalStatus = remoteStatus;
// 远端成功/失败与否,本地兜底都已可用
if (result && result.__timeout) {
// 超时分支:loadOverrides 内部 setRemoteStatus('fail') 已触发
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;
const lang = getCurrentLocale();
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'
*/
getSource() { return SOURCE; },
/**
* * 解析多语言
* @param {Object} field
*/
lang(field) {
let _page = getCurrentPages()[getCurrentPages().length - 1];
if (!_page) return field;
const locale = getCurrentLocale(); // 获得当前本地语言
let value = field; // 存放解析后的语言值
let langPath = ''; // 存放当前页面语言包路径
try {
//公共语言包(同步加载)
var lang = loadLangPackSync(locale, 'common');
//当前页面语言包(同步加载)
let route = _page.route;
langPath = processRoutePath(route);
// 加载当前页面语言包
let currentPageLang = loadLangPackSync(locale, langPath);
// 合并语言包 + 远端 KV 覆写
// 兜底语义:
// - local 永远在底层(merge 进 mergedLang)
// - 远端覆写由 overrideCache[mk].map 提供;失败时 map 是 {} 或上次成功的值
// - 无论 ok/fail,本次渲染都不会因为"远端拉不到"而崩 — 走的就是本地语言包
const mergedLang = Object.assign({}, lang, currentPageLang);
const ovr = overrideCache[getOverrideMemKey(locale)];
if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map);
// 解析字段
var arr = field.split(".");
if (arr.length > 1) {
// 处理嵌套属性,如 common.currencySymbol
let temp = mergedLang;
let found = true;
for (let key of arr) {
if (temp[key] !== undefined) {
temp = temp[key];
} else {
found = false;
break;
}
}
value = found ? temp : field;
} else {
value = mergedLang[field] !== undefined ? mergedLang[field] : field;
}
} catch (e) {
console.error('解析语言包失败:', e, { langPath, field, locale });
value = field;
}
if (arguments.length > 1) {
//有参数,需要替换
for (var i = 1; i < arguments.length; i++) {
value = String(value).replace("{" + (i - 1) + "}", arguments[i]);
}
}
if (value == undefined || (value == 'title' && field == 'title')) value = ''; // field
// 多语言调试,注释后可以关闭控制台输出
if (field == value) {
console.warn(`警告: 字段 ${field} 在语言包 ${langPath} 中未找到对应值,使用默认值 ${field} 当前语言: ${locale}`);
}
return value;
},
/**
* * 切换语言
* @param {String} value 语言值
* @param {String} url 切换后跳转的页面url
*/
async change(value, url = '/pages_tool/member/index') {
let _page = getCurrentPages()[getCurrentPages().length - 1];
if (!_page) return;
uni.setStorageSync("lang", value);
const locale = getCurrentLocale();
// 清空已加载的语言包缓存
for (let key in loadedLangPacks) {
if (!key.startsWith(locale)) {
delete loadedLangPacks[key];
}
}
// 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络)
// 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch
if (SOURCE !== 'local') {
try { await loadOverrides(value); } catch (e) { /* 静默 */ }
}
this.refresh();
if (url) {
uni.reLaunch({ url: url });
}
},
//刷新标题、tabbar
refresh() {
let _page = getCurrentPages()[getCurrentPages().length - 1];
if (_page) this.title(this.lang("title"));
// 远端 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")
// });
},
title(str) {
if (str) {
uni.setNavigationBarTitle({
title: str,
success: function (res) {
},
fail: function (err) {
}
});
}
},
// 获取语言包列表
list() {
var list = [];
try {
//公共语言包
for (var i = 0; i < langConfig.langList.length; i++) {
let langType = langConfig.langList[i];
let item = loadLangPackSync(langType, 'common');
list.push({
name: item.common ? item.common.name : langType,
value: langType
});
}
} catch (e) {
console.error('获取语言包列表失败:', e);
}
return list;
},
// 内部测试/调试用
__debug_overrideCache() { return overrideCache; },
__debug_loadOverrides: loadOverrides,
}