refactor: 优化多语言结构并新增远端语言包加载能力
1. 扁平化多语言文件层级,移除嵌套的命名空间 2. 更新组件内多语言调用路径,适配新的语言包结构 3. 重构HTTP请求错误处理逻辑 4. 新增remote-only模式的远端语言包加载器 5. 新增多语言数据源remote-only配置项
This commit is contained in:
@@ -11,13 +11,19 @@ export const langConfig = {
|
|||||||
// 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载)
|
// 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载)
|
||||||
loadMode: 'lazy',
|
loadMode: 'lazy',
|
||||||
// 数据源开关
|
// 数据源开关
|
||||||
|
// 数据源开关
|
||||||
// local : 纯本地包,忽略 remote
|
// local : 纯本地包,忽略 remote
|
||||||
// remote : 本地兜底 + 后端 KV 覆写(首屏强依赖,见 bootTimeout)
|
// remote : 本地兜底 + 后端 KV 覆写(首屏强依赖,见 bootTimeout)
|
||||||
// hybrid : 同 remote,后端不可用时用本地
|
// hybrid : 同 remote,后端不可用时用本地
|
||||||
source: 'remote',
|
// remote-only : 完全依赖后端 /api/lang/bundle,本地 lang/ 目录可删除
|
||||||
|
source: 'remote-only',
|
||||||
// 远端覆写相关
|
// 远端覆写相关
|
||||||
remote: {
|
remote: {
|
||||||
overridesUrl: '/api/lang/overrides',
|
overridesUrl: '/api/lang/overrides',
|
||||||
|
// bundle 接口(remote-only 模式下使用)
|
||||||
|
bundleUrl: '/api/lang/bundle',
|
||||||
|
// diff 接口(预留)
|
||||||
|
diffUrl: '/api/lang/diff',
|
||||||
// 首屏强依赖远端翻译(多租户文案强隔离),阻塞启动
|
// 首屏强依赖远端翻译(多租户文案强隔离),阻塞启动
|
||||||
// 但必须有上限避免白屏
|
// 但必须有上限避免白屏
|
||||||
bootTimeout: 2000,
|
bootTimeout: 2000,
|
||||||
@@ -28,6 +34,8 @@ export const langConfig = {
|
|||||||
storagePrefix: 'LANG_OVR_',
|
storagePrefix: 'LANG_OVR_',
|
||||||
// 切租户/uniacid 时强制重拉
|
// 切租户/uniacid 时强制重拉
|
||||||
refreshOnUniacidChange: true,
|
refreshOnUniacidChange: true,
|
||||||
|
// 签名校验密钥,留空不校验(需与后端 LANG_SIGN_SECRET 一致)
|
||||||
|
signSecret: '',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -95,15 +95,14 @@ export default {
|
|||||||
reject(res);
|
reject(res);
|
||||||
},
|
},
|
||||||
complete: (res) => {
|
complete: (res) => {
|
||||||
|
// success/fail 已处理 resolve/reject,complete 只负责兜底提示非预期状态
|
||||||
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
|
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: getLang().lang('http.requestFailed', { url: Config.baseUrl }),
|
title: getLang().lang('http.requestFailed', { url: Config.baseUrl }),
|
||||||
'icon': 'none',
|
'icon': 'none',
|
||||||
duration: 10000
|
duration: 10000
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
reject(res.data);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
272
common/js/lang-loader.js
Normal file
272
common/js/lang-loader.js
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* 远端语言包 bundle 加载器
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从后端 /api/lang/bundle 拉取完整语言树
|
||||||
|
* 2. 做本地 storage 缓存 + 内存缓存
|
||||||
|
* 3. 支持签名校验(可选)
|
||||||
|
* 4. 支持 diff 增量更新(预留)
|
||||||
|
* 5. 对外暴露 loadBundleTree / getCachedBundle / clearBundleCache
|
||||||
|
*
|
||||||
|
* 与 lang.js 的关系:
|
||||||
|
* 当 langConfig.source === 'remote-only' 时,lang.js 不再 require 本地 lang/ 文件,
|
||||||
|
* 而是直接复用本模块加载的 tree。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { langConfig } from './config-external.js';
|
||||||
|
import http from './http.js';
|
||||||
|
import { Platform } from './platform.js';
|
||||||
|
|
||||||
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
|
// 内存缓存: { [memKey]: { version, tree, ts } }
|
||||||
|
const bundleCache = {};
|
||||||
|
|
||||||
|
// 并发去重
|
||||||
|
const bundleInflight = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三维隔离 key: <uniacid>_<appType>_<lang>
|
||||||
|
*/
|
||||||
|
function getBundleMemKey(uniacid, appType, lang) {
|
||||||
|
return `${uniacid || 0}_${appType || '*'}_${lang}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* storage key: 与 memKey 维度一致
|
||||||
|
*/
|
||||||
|
function getBundleStorageKey(uniacid, appType, lang) {
|
||||||
|
return `${langConfig.remote.storagePrefix || 'LANG_BUNDLE_'}${getBundleMemKey(uniacid, appType, lang)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentLocale() {
|
||||||
|
try {
|
||||||
|
return uni.getStorageSync('lang') || langConfig.defaultLang || 'zh-cn';
|
||||||
|
} catch (e) {
|
||||||
|
return langConfig.defaultLang || 'zh-cn';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentUniacid() {
|
||||||
|
try {
|
||||||
|
const u = uni.getStorageSync('uniacid');
|
||||||
|
return u ? parseInt(u, 10) || 0 : 0;
|
||||||
|
} catch (e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentAppType() {
|
||||||
|
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度合并两个对象,source 覆盖 target
|
||||||
|
*/
|
||||||
|
function deepMerge(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] = deepMerge(tgtVal, srcVal);
|
||||||
|
} else {
|
||||||
|
target[key] = srcVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易签名校验
|
||||||
|
* 后端: hash_hmac('sha256', version + json_encode(tree), secret)
|
||||||
|
* 前端: 同样算法
|
||||||
|
*/
|
||||||
|
function validateSign(tree, version, sign, secret) {
|
||||||
|
if (!secret || !sign) return true;
|
||||||
|
// 前端没有 crypto-js,用简单字符串拼接做校验(仅基础防篡改)
|
||||||
|
// 如需强校验,建议引入 crypto-js 或小程序/APP 端使用原生加密 API
|
||||||
|
const payload = JSON.stringify(tree);
|
||||||
|
const expected = btoa(version + ':' + payload).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
|
||||||
|
// 后端返回的是 hash_hmac('sha256', version + payload, secret)
|
||||||
|
// 这里只做格式校验,不强校验(因为无 crypto 库)
|
||||||
|
return typeof sign === 'string' && sign.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 storage 读取缓存
|
||||||
|
*/
|
||||||
|
function readStorageCache(sk) {
|
||||||
|
try {
|
||||||
|
const cached = uni.getStorageSync(sk);
|
||||||
|
if (cached && cached.tree && typeof cached.tree === 'object') {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入 storage 缓存
|
||||||
|
*/
|
||||||
|
function writeStorageCache(sk, data) {
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(sk, data);
|
||||||
|
} catch (e) {
|
||||||
|
// storage 写满时静默失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从后端拉取 bundle
|
||||||
|
*/
|
||||||
|
async function fetchBundle(lang, appType, uniacid, timeout) {
|
||||||
|
const url = langConfig.remote.bundleUrl || '/api/lang/bundle';
|
||||||
|
const data = { lang };
|
||||||
|
if (appType && appType !== '*') data.app_type = appType;
|
||||||
|
|
||||||
|
const res = await Promise.race([
|
||||||
|
http.sendRequest({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
async: false,
|
||||||
|
}),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!res || res.code !== 0 || !res.data) {
|
||||||
|
throw new Error(res && res.message ? res.message : 'bundle fetch failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载 bundle tree
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} lang 语言
|
||||||
|
* @param {string} [appType] 平台类型
|
||||||
|
* @param {number} [uniacid] 租户 id
|
||||||
|
* @param {boolean} [allowCache=true] 是否允许使用缓存
|
||||||
|
* @param {boolean} [force=false] 是否强制刷新
|
||||||
|
* @returns {Promise<{tree:Object, version:string, source:string}>}
|
||||||
|
*/
|
||||||
|
export async function loadBundleTree(options = {}) {
|
||||||
|
const lang = options.lang || getCurrentLocale();
|
||||||
|
const appType = options.appType || getCurrentAppType();
|
||||||
|
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||||||
|
const allowCache = options.allowCache !== false;
|
||||||
|
const force = options.force === true;
|
||||||
|
|
||||||
|
const mk = getBundleMemKey(uniacid, appType, lang);
|
||||||
|
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||||||
|
|
||||||
|
if (bundleInflight[mk]) return bundleInflight[mk];
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
try {
|
||||||
|
// 1) 强制刷新时跳过缓存
|
||||||
|
if (!force && allowCache && bundleCache[mk]) {
|
||||||
|
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 读取 storage 缓存作为首屏兜底
|
||||||
|
let cached = null;
|
||||||
|
if (!force && allowCache) {
|
||||||
|
cached = readStorageCache(sk);
|
||||||
|
if (cached && !bundleCache[mk]) {
|
||||||
|
bundleCache[mk] = cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 远端拉取
|
||||||
|
const timeout = langConfig.remote.requestTimeout || 4000;
|
||||||
|
const payload = await fetchBundle(lang, appType, uniacid, timeout);
|
||||||
|
const tree = payload.tree || {};
|
||||||
|
const version = String(payload.version || '');
|
||||||
|
const sign = payload.sign || '';
|
||||||
|
|
||||||
|
// 4) 签名校验(可选)
|
||||||
|
const secret = langConfig.remote.signSecret || '';
|
||||||
|
if (!validateSign(tree, version, sign, secret)) {
|
||||||
|
throw new Error('bundle sign invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 更新缓存
|
||||||
|
const record = { version, tree, ts: Date.now() };
|
||||||
|
bundleCache[mk] = record;
|
||||||
|
writeStorageCache(sk, record);
|
||||||
|
|
||||||
|
return { tree, version, source: 'remote' };
|
||||||
|
} catch (e) {
|
||||||
|
// 6) 失败时回退到缓存
|
||||||
|
if (allowCache && bundleCache[mk]) {
|
||||||
|
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'cache', error: e.message };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
delete bundleInflight[mk];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
bundleInflight[mk] = task;
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已缓存的 bundle(不触发网络请求)
|
||||||
|
*/
|
||||||
|
export function getCachedBundle(options = {}) {
|
||||||
|
const lang = options.lang || getCurrentLocale();
|
||||||
|
const appType = options.appType || getCurrentAppType();
|
||||||
|
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||||||
|
const mk = getBundleMemKey(uniacid, appType, lang);
|
||||||
|
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||||||
|
|
||||||
|
if (bundleCache[mk]) return bundleCache[mk];
|
||||||
|
const cached = readStorageCache(sk);
|
||||||
|
if (cached) {
|
||||||
|
bundleCache[mk] = cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除 bundle 缓存
|
||||||
|
*/
|
||||||
|
export function clearBundleCache(options = {}) {
|
||||||
|
const lang = options.lang || getCurrentLocale();
|
||||||
|
const appType = options.appType || getCurrentAppType();
|
||||||
|
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
|
||||||
|
const mk = getBundleMemKey(uniacid, appType, lang);
|
||||||
|
const sk = getBundleStorageKey(uniacid, appType, lang);
|
||||||
|
|
||||||
|
delete bundleCache[mk];
|
||||||
|
try { uni.removeStorageSync(sk); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有缓存可用(用于启动时快速判断)
|
||||||
|
*/
|
||||||
|
export function hasBundleCache(options = {}) {
|
||||||
|
return getCachedBundle(options) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并本地语言包与远端 bundle tree(混合模式用)
|
||||||
|
*/
|
||||||
|
export function mergeLocalWithBundle(localTree, bundleTree) {
|
||||||
|
if (!localTree || typeof localTree !== 'object') return bundleTree || {};
|
||||||
|
if (!bundleTree || typeof bundleTree !== 'object') return localTree || {};
|
||||||
|
return deepMerge(deepMerge({}, localTree), bundleTree);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { langConfig } from './config-external.js';
|
|||||||
import Config from './config.js';
|
import Config from './config.js';
|
||||||
import http from './http.js';
|
import http from './http.js';
|
||||||
import { Platform } from './platform.js';
|
import { Platform } from './platform.js';
|
||||||
|
import { loadBundleTree, getCachedBundle, clearBundleCache } from './lang-loader.js';
|
||||||
|
|
||||||
// 缓存已加载的语言包
|
// 缓存已加载的语言包
|
||||||
const loadedLangPacks = {};
|
const loadedLangPacks = {};
|
||||||
@@ -425,6 +426,10 @@ const overrideCache = {};
|
|||||||
// 远端覆写:并发去重
|
// 远端覆写:并发去重
|
||||||
const overrideInflight = {};
|
const overrideInflight = {};
|
||||||
|
|
||||||
|
// remote-only 模式:缓存远端 bundle tree
|
||||||
|
// key: getOverrideMemKey(locale), value: { version, tree, ts, source }
|
||||||
|
const remoteTreeCache = {};
|
||||||
|
|
||||||
// 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败)
|
// 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败)
|
||||||
// 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒"
|
// 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒"
|
||||||
let remoteStatus = 'unknown';
|
let remoteStatus = 'unknown';
|
||||||
@@ -432,12 +437,13 @@ let remoteStatusAt = 0;
|
|||||||
const remoteStatusListeners = [];
|
const remoteStatusListeners = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验 source 配置:只接受 local / remote / hybrid,其它一律视为 remote(安全默认)
|
* 校验 source 配置:只接受 local / remote / hybrid / remote-only,其它一律视为 remote(安全默认)
|
||||||
*/
|
*/
|
||||||
function normalizeSource(s) {
|
function normalizeSource(s) {
|
||||||
return ['local', 'remote', 'hybrid'].includes(s) ? s : 'remote';
|
return ['local', 'remote', 'hybrid', 'remote-only'].includes(s) ? s : 'remote';
|
||||||
}
|
}
|
||||||
const SOURCE = normalizeSource(langConfig.source);
|
const SOURCE = normalizeSource(langConfig.source);
|
||||||
|
const IS_REMOTE_ONLY = SOURCE === 'remote-only';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 远端失败原因归一(对外稳定枚举,内部细节不外泄)
|
* 远端失败原因归一(对外稳定枚举,内部细节不外泄)
|
||||||
@@ -733,6 +739,41 @@ function emitUpdated(lang) {
|
|||||||
try { uni.$emit && uni.$emit('lang:updated', { lang }); } 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 {
|
export default {
|
||||||
langList: langConfig.langList,
|
langList: langConfig.langList,
|
||||||
@@ -780,6 +821,36 @@ export default {
|
|||||||
return { ok: true, source: 'local', status: remoteStatus, lang };
|
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) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tree, version, source } = await loadBundleTree({ lang });
|
||||||
|
remoteTreeCache[getOverrideMemKey(lang)] = { version, tree, ts: Date.now(), source };
|
||||||
|
setRemoteStatus('ok', { lang });
|
||||||
|
// bundle 就绪后清空字段缓存并触发全局刷新,避免首屏组件在 boot 前渲染的 raw key 被缓存
|
||||||
|
emitUpdated(lang);
|
||||||
|
return { ok: true, source, status: 'ok', version, lang };
|
||||||
|
} catch (e) {
|
||||||
|
const cached = getCachedBundle({ lang });
|
||||||
|
if (cached) {
|
||||||
|
remoteTreeCache[getOverrideMemKey(lang)] = { version: cached.version, tree: cached.tree, ts: cached.ts, source: 'cache' };
|
||||||
|
setRemoteStatus('fail', { reason: 'exception', lang });
|
||||||
|
return { ok: false, source: 'cache', status: 'fail', reason: e.message || 'bundle failed', lang };
|
||||||
|
}
|
||||||
|
setRemoteStatus('fail', { reason: 'exception', lang });
|
||||||
|
return { ok: false, source: 'local', status: 'fail', reason: e.message || 'bundle failed', lang };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const remote = langConfig.remote;
|
const remote = langConfig.remote;
|
||||||
const t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout));
|
const t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout));
|
||||||
|
|
||||||
@@ -822,6 +893,14 @@ export default {
|
|||||||
// 切租户后清除 storage 缓存,确保下次读时拿到新 uniacid
|
// 切租户后清除 storage 缓存,确保下次读时拿到新 uniacid
|
||||||
invalidateStorageCache('uniacid');
|
invalidateStorageCache('uniacid');
|
||||||
const lang = getCurrentLocale();
|
const lang = getCurrentLocale();
|
||||||
|
|
||||||
|
if (IS_REMOTE_ONLY) {
|
||||||
|
clearBundleCache({ lang });
|
||||||
|
delete remoteTreeCache[getOverrideMemKey(lang)];
|
||||||
|
loadBundleTree({ lang });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sk = getOverrideStorageKey(lang);
|
const sk = getOverrideStorageKey(lang);
|
||||||
try { uni.removeStorageSync(sk); } catch (e) {}
|
try { uni.removeStorageSync(sk); } catch (e) {}
|
||||||
delete overrideCache[getOverrideMemKey(lang)];
|
delete overrideCache[getOverrideMemKey(lang)];
|
||||||
@@ -924,6 +1003,12 @@ export default {
|
|||||||
let mergedLang = _mergedLangCache[mergeCacheKey];
|
let mergedLang = _mergedLangCache[mergeCacheKey];
|
||||||
if (!mergedLang) {
|
if (!mergedLang) {
|
||||||
try {
|
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 lang = loadLangPackSync(locale, 'common');
|
||||||
const currentPageLang = langPath ? normalizePageLangPack(langPath, loadLangPackSync(locale, langPath)) : {};
|
const currentPageLang = langPath ? normalizePageLangPack(langPath, loadLangPackSync(locale, langPath)) : {};
|
||||||
mergedLang = deepCloneLang(lang);
|
mergedLang = deepCloneLang(lang);
|
||||||
@@ -936,6 +1021,7 @@ export default {
|
|||||||
mergedLang = deepMergeLang(mergedLang, deepCloneLang(currentPageLang));
|
mergedLang = deepMergeLang(mergedLang, deepCloneLang(currentPageLang));
|
||||||
const ovr = overrideCache[getOverrideMemKey(locale)];
|
const ovr = overrideCache[getOverrideMemKey(locale)];
|
||||||
if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map);
|
if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('解析语言包失败:', e, { langPath, field, locale });
|
console.error('解析语言包失败:', e, { langPath, field, locale });
|
||||||
mergedLang = {};
|
mergedLang = {};
|
||||||
@@ -1042,11 +1128,20 @@ export default {
|
|||||||
Object.keys(overrideCache).forEach(mk => {
|
Object.keys(overrideCache).forEach(mk => {
|
||||||
if (!mk.endsWith('_' + locale)) delete overrideCache[mk];
|
if (!mk.endsWith('_' + locale)) delete overrideCache[mk];
|
||||||
});
|
});
|
||||||
|
// remote-only 模式:清除旧语种 bundle 缓存
|
||||||
|
Object.keys(remoteTreeCache).forEach(mk => {
|
||||||
|
if (!mk.endsWith('_' + locale)) delete remoteTreeCache[mk];
|
||||||
|
});
|
||||||
|
|
||||||
// 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络)
|
// 切换语种后,先 await 远端覆写(有 storage 缓存的同语种秒开,无缓存的会触发一次网络)
|
||||||
// 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch
|
// 远端失败/超时也返回 — await 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch
|
||||||
// 注意:loadOverrides 内部已 emitUpdated(若成功),refresh() 内部不再重复 emit
|
// 注意:loadOverrides 内部已 emitUpdated(若成功),refresh() 内部不再重复 emit
|
||||||
if (SOURCE !== 'local') {
|
if (IS_REMOTE_ONLY) {
|
||||||
|
try {
|
||||||
|
const { tree, version, source } = await loadBundleTree({ lang: value });
|
||||||
|
remoteTreeCache[getOverrideMemKey(value)] = { version, tree, ts: Date.now(), source };
|
||||||
|
} catch (e) { /* 静默 */ }
|
||||||
|
} else if (SOURCE !== 'local') {
|
||||||
try { await loadOverrides(value); } catch (e) { /* 静默 */ }
|
try { await loadOverrides(value); } catch (e) { /* 静默 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,6 +1214,16 @@ export default {
|
|||||||
const getCommonName = (langType) => {
|
const getCommonName = (langType) => {
|
||||||
try {
|
try {
|
||||||
if (tmp[langType]) return tmp[langType];
|
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;
|
const data = require(`@/lang/${langType}/common.js`).lang;
|
||||||
tmp[langType] = (data && data.common) ? data.common.name : langType;
|
tmp[langType] = (data && data.common) ? data.common.name : langType;
|
||||||
return tmp[langType];
|
return tmp[langType];
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
<view class="category-item-all" @tap.stop="$refs.templateFourPopup.open()">
|
<view class="category-item-all" @tap.stop="$refs.templateFourPopup.open()">
|
||||||
<view class="category-item-all-wrap">
|
<view class="category-item-all-wrap">
|
||||||
<text class="text">{{ $lang('components-diy.diyCategory.expand') }}</text>
|
<text class="text">{{ $lang('components-diy.diy-category.expand') }}</text>
|
||||||
<image class="img" :src="$util.img('/public/uniapp/category/unfold.png')" mode="aspectFill" show-menu-by-longpress></image>
|
<image class="img" :src="$util.img('/public/uniapp/category/unfold.png')" mode="aspectFill" show-menu-by-longpress></image>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
<view class="pack-up" @tap.stop="$refs.templateFourPopup.close()">
|
<view class="pack-up" @tap.stop="$refs.templateFourPopup.close()">
|
||||||
<text>{{ $lang('components-diy.diyCategory.clickToCollapse') }}</text>
|
<text>{{ $lang('components-diy.diy-category.clickToCollapse') }}</text>
|
||||||
<text class="iconfont icon-iconangledown-copy"></text>
|
<text class="iconfont icon-iconangledown-copy"></text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
|
||||||
@loadfinish="getHeightArea"></diy-category-item>
|
@loadfinish="getHeightArea"></diy-category-item>
|
||||||
</view>
|
</view>
|
||||||
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diyCategory.reachedBottom') }}</view>
|
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<view class="content-wrap"
|
<view class="content-wrap"
|
||||||
@@ -120,12 +120,12 @@
|
|||||||
</block>
|
</block>
|
||||||
<view class="category-empty" v-else>
|
<view class="category-empty" v-else>
|
||||||
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
||||||
<view class="tips">{{ $lang('components-diy.diyCategory.noCategory') }}</view>
|
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="category-empty" v-else>
|
<view class="category-empty" v-else>
|
||||||
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
||||||
<view class="tips">{{ $lang('components-diy.diyCategory.noCategory') }}</view>
|
<view class="tips">{{ $lang('components-diy.diy-category.noCategory') }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- <view class="cart-box" v-if="(value.template == 2 || value.template == 4) && value.quickBuy && storeToken && categoryTree && categoryTree.length">
|
<!-- <view class="cart-box" v-if="(value.template == 2 || value.template == 4) && value.quickBuy && storeToken && categoryTree && categoryTree.length">
|
||||||
@@ -409,7 +409,7 @@ export default {
|
|||||||
success: res => {
|
success: res => {
|
||||||
if (res.code >= 0) {
|
if (res.code >= 0) {
|
||||||
let item = res.data;
|
let item = res.data;
|
||||||
item.unit = item.unit || this.$lang('components-diy.diyCategory.pieceUnit');
|
item.unit = item.unit || this.$lang('components-diy.diy-category.pieceUnit');
|
||||||
|
|
||||||
if (item.sku_images) item.sku_images = item.sku_images.split(',');
|
if (item.sku_images) item.sku_images = item.sku_images.split(',');
|
||||||
else item.sku_images = [];
|
else item.sku_images = [];
|
||||||
|
|||||||
@@ -147,13 +147,13 @@
|
|||||||
<text>{{ showMarketPrice(item) }}</text>
|
<text>{{ showMarketPrice(item) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="sale font-size-activity-tag color-tip">{{
|
<view class="sale font-size-activity-tag color-tip">{{
|
||||||
$lang('components-diy.diyIndexPage.soldCount', { count: item.sale_num, unit: item.unit || $lang('components-diy.diyIndexPage.soldUnit') }) }}</view>
|
$lang('components-diy.diy-index-page.soldCount', { count: item.sale_num, unit: item.unit || $lang('components-diy.diy-index-page.soldUnit') }) }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="!isloading && goodsList[pageIndex].list.length == 0">
|
<view v-if="!isloading && goodsList[pageIndex].list.length == 0">
|
||||||
<ns-empty :text="$lang('components-diy.diyIndexPage.noGoodsInCategory')" :isIndex="false"></ns-empty>
|
<ns-empty :text="$lang('components-diy.diy-index-page.noGoodsInCategory')" :isIndex="false"></ns-empty>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
</mescroll-uni>
|
</mescroll-uni>
|
||||||
@@ -259,7 +259,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.cateList[0].category_name = this.$lang('components-diy.diyIndexPage.home');
|
this.cateList[0].category_name = this.$lang('components-diy.diy-index-page.home');
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
type(newVal, oldVal) {
|
type(newVal, oldVal) {
|
||||||
@@ -326,7 +326,7 @@ export default {
|
|||||||
let obj = {
|
let obj = {
|
||||||
list: []
|
list: []
|
||||||
};
|
};
|
||||||
obj.category_name = this.value.title ? this.value.title : this.$lang('components-diy.diyIndexPage.home');
|
obj.category_name = this.value.title ? this.value.title : this.$lang('components-diy.diy-index-page.home');
|
||||||
arr.push(obj);
|
arr.push(obj);
|
||||||
this.cateList = arr.concat(res.data);
|
this.cateList = arr.concat(res.data);
|
||||||
Object.keys(this.cateList).forEach((key, index) => {
|
Object.keys(this.cateList).forEach((key, index) => {
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
<block v-if="globalStoreInfo && globalStoreInfo.store_id">
|
<block v-if="globalStoreInfo && globalStoreInfo.store_id">
|
||||||
<text class="title">{{ globalStoreInfo.store_name }}</text>
|
<text class="title">{{ globalStoreInfo.store_name }}</text>
|
||||||
<text>
|
<text>
|
||||||
<text class="change margin-left">{{ $lang('components-diy.diyStore.switchStore') }}</text>
|
<text class="change margin-left">{{ $lang('components-diy.diy-store.switchStore') }}</text>
|
||||||
<text class="iconfont icon-right"></text>
|
<text class="iconfont icon-right"></text>
|
||||||
</text>
|
</text>
|
||||||
</block>
|
</block>
|
||||||
<text class="title" v-else>{{ $lang('components-diy.diyStore.locating') }}</text>
|
<text class="title" v-else>{{ $lang('components-diy.diy-store.locating') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="address-wrap" :style="{ color: value.textColor }">
|
<view class="address-wrap" :style="{ color: value.textColor }">
|
||||||
<text class="iconfont icon-dizhi"></text>
|
<text class="iconfont icon-dizhi"></text>
|
||||||
<text v-if="globalStoreInfo && globalStoreInfo.store_id" @tap.stop="mapRoute" class="address">{{
|
<text v-if="globalStoreInfo && globalStoreInfo.store_id" @tap.stop="mapRoute" class="address">{{
|
||||||
globalStoreInfo.show_address }}</text>
|
globalStoreInfo.show_address }}</text>
|
||||||
<text v-else>{{ $lang('components-diy.diyStore.gettingLocation') }}</text>
|
<text v-else>{{ $lang('components-diy.diy-store.gettingLocation') }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="store-image" @tap.stop="selectStore()">
|
<view class="store-image" @tap.stop="selectStore()">
|
||||||
@@ -40,11 +40,11 @@
|
|||||||
<block v-if="globalStoreInfo && globalStoreInfo.store_id">
|
<block v-if="globalStoreInfo && globalStoreInfo.store_id">
|
||||||
<text class="title">{{ globalStoreInfo.store_name }}</text>
|
<text class="title">{{ globalStoreInfo.store_name }}</text>
|
||||||
<text>
|
<text>
|
||||||
<text class="change margin-left">{{ $lang('components-diy.diyStore.switchStore') }}</text>
|
<text class="change margin-left">{{ $lang('components-diy.diy-store.switchStore') }}</text>
|
||||||
<text class="iconfont icon-right"></text>
|
<text class="iconfont icon-right"></text>
|
||||||
</text>
|
</text>
|
||||||
</block>
|
</block>
|
||||||
<text class="title" v-else>{{ $lang('components-diy.diyStore.locating') }}</text>
|
<text class="title" v-else>{{ $lang('components-diy.diy-store.locating') }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="store-icon" @tap.stop="search()"><text class="iconfont icon-sousuo3"
|
<view class="store-icon" @tap.stop="search()"><text class="iconfont icon-sousuo3"
|
||||||
@@ -60,10 +60,10 @@
|
|||||||
<text class="title" :style="{ color: value.textColor }">{{ globalStoreInfo.store_name }}</text>
|
<text class="title" :style="{ color: value.textColor }">{{ globalStoreInfo.store_name }}</text>
|
||||||
<text class="iconfont icon-unfold" :style="{ color: value.textColor }"></text>
|
<text class="iconfont icon-unfold" :style="{ color: value.textColor }"></text>
|
||||||
</block>
|
</block>
|
||||||
<text class="title" v-else>{{ $lang('components-diy.diyStore.locating') }}</text>
|
<text class="title" v-else>{{ $lang('components-diy.diy-store.locating') }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="store-right-search">
|
<view class="store-right-search">
|
||||||
<input type="text" class="uni-input font-size-tag" disabled :placeholder="$lang('components-diy.diyStore.searchPlaceholder')"
|
<input type="text" class="uni-input font-size-tag" disabled :placeholder="$lang('components-diy.diy-store.searchPlaceholder')"
|
||||||
@tap.stop="search()" />
|
@tap.stop="search()" />
|
||||||
<text class="iconfont icon-sousuo3" @tap.stop="search()"></text>
|
<text class="iconfont icon-sousuo3" @tap.stop="search()"></text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
'components-diy': {
|
|
||||||
diyCategory: {
|
|
||||||
// Expand all categories (button on the right of horizontal scroll)
|
// Expand all categories (button on the right of horizontal scroll)
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
// Click to collapse (button at the bottom of expanded popup)
|
// Click to collapse (button at the bottom of expanded popup)
|
||||||
@@ -11,6 +9,4 @@ export const lang = {
|
|||||||
noCategory: 'No categories yet!',
|
noCategory: 'No categories yet!',
|
||||||
// Product unit: piece
|
// Product unit: piece
|
||||||
pieceUnit: 'pc(s)',
|
pieceUnit: 'pc(s)',
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
'components-diy': {
|
|
||||||
diyIndexPage: {
|
|
||||||
// Home (default category name)
|
// Home (default category name)
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
// Sales text: Sold X units
|
// Sales text: Sold X units
|
||||||
@@ -9,6 +7,4 @@ export const lang = {
|
|||||||
soldUnit: 'pcs',
|
soldUnit: 'pcs',
|
||||||
// Empty state prompt when no goods in category
|
// Empty state prompt when no goods in category
|
||||||
noGoodsInCategory: 'No goods in this category',
|
noGoodsInCategory: 'No goods in this category',
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
'components-diy': {
|
|
||||||
diyStore: {
|
|
||||||
// Switch store button text
|
// Switch store button text
|
||||||
switchStore: 'Switch Store',
|
switchStore: 'Switch Store',
|
||||||
// Locating status text
|
// Locating status text
|
||||||
@@ -9,6 +7,4 @@ export const lang = {
|
|||||||
gettingLocation: 'Getting location',
|
gettingLocation: 'Getting location',
|
||||||
// Search input placeholder
|
// Search input placeholder
|
||||||
searchPlaceholder: 'Search',
|
searchPlaceholder: 'Search',
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
'components-diy': {
|
|
||||||
diyCategory: {
|
|
||||||
// 展开全部分类(横向滚动右侧展开按钮)
|
// 展开全部分类(横向滚动右侧展开按钮)
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
// 点击收起(展开弹窗底部收起按钮)
|
// 点击收起(展开弹窗底部收起按钮)
|
||||||
@@ -11,6 +9,4 @@ export const lang = {
|
|||||||
noCategory: '暂时没有分类哦!',
|
noCategory: '暂时没有分类哦!',
|
||||||
// 商品单位:件
|
// 商品单位:件
|
||||||
pieceUnit: '件',
|
pieceUnit: '件',
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
'components-diy': {
|
|
||||||
diyIndexPage: {
|
|
||||||
// 首页(默认分类名称)
|
// 首页(默认分类名称)
|
||||||
home: '首页',
|
home: '首页',
|
||||||
// 销量文案:已售X件
|
// 销量文案:已售X件
|
||||||
@@ -9,6 +7,4 @@ export const lang = {
|
|||||||
soldUnit: '件',
|
soldUnit: '件',
|
||||||
// 分类下无商品的空状态提示
|
// 分类下无商品的空状态提示
|
||||||
noGoodsInCategory: '该分类下暂无商品',
|
noGoodsInCategory: '该分类下暂无商品',
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export const lang = {
|
export const lang = {
|
||||||
'components-diy': {
|
|
||||||
diyStore: {
|
|
||||||
// 切换门店(按钮文案)
|
// 切换门店(按钮文案)
|
||||||
switchStore: '切换门店',
|
switchStore: '切换门店',
|
||||||
// 定位中(加载状态提示)
|
// 定位中(加载状态提示)
|
||||||
@@ -9,6 +7,4 @@ export const lang = {
|
|||||||
gettingLocation: '获取位置中',
|
gettingLocation: '获取位置中',
|
||||||
// 搜索框占位符
|
// 搜索框占位符
|
||||||
searchPlaceholder: '搜索',
|
searchPlaceholder: '搜索',
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user