From e6d47a3d781dcb3de6bcec1ee2469dd0a5cd36b6 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Tue, 30 Jun 2026 10:03:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E7=BB=93=E6=9E=84=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BF=9C=E7=AB=AF=E8=AF=AD=E8=A8=80=E5=8C=85=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 扁平化多语言文件层级,移除嵌套的命名空间 2. 更新组件内多语言调用路径,适配新的语言包结构 3. 重构HTTP请求错误处理逻辑 4. 新增remote-only模式的远端语言包加载器 5. 新增多语言数据源remote-only配置项 --- common/js/config-external.js | 16 +- common/js/http.js | 17 +- common/js/lang-loader.js | 272 ++++++++++++++++++++ common/js/lang.js | 127 ++++++++- components-diy/diy-category.vue | 12 +- components-diy/diy-index-page.vue | 8 +- components-diy/diy-store.vue | 14 +- lang/en-us/components-diy/diy-category.js | 24 +- lang/en-us/components-diy/diy-index-page.js | 20 +- lang/en-us/components-diy/diy-store.js | 20 +- lang/zh-cn/components-diy/diy-category.js | 24 +- lang/zh-cn/components-diy/diy-index-page.js | 20 +- lang/zh-cn/components-diy/diy-store.js | 20 +- 13 files changed, 477 insertions(+), 117 deletions(-) create mode 100644 common/js/lang-loader.js diff --git a/common/js/config-external.js b/common/js/config-external.js index f63185c..01b4db9 100644 --- a/common/js/config-external.js +++ b/common/js/config-external.js @@ -11,13 +11,19 @@ export const langConfig = { // 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载) loadMode: 'lazy', // 数据源开关 - // local : 纯本地包,忽略 remote - // remote : 本地兜底 + 后端 KV 覆写(首屏强依赖,见 bootTimeout) - // hybrid : 同 remote,后端不可用时用本地 - source: 'remote', + // 数据源开关 + // local : 纯本地包,忽略 remote + // remote : 本地兜底 + 后端 KV 覆写(首屏强依赖,见 bootTimeout) + // hybrid : 同 remote,后端不可用时用本地 + // remote-only : 完全依赖后端 /api/lang/bundle,本地 lang/ 目录可删除 + source: 'remote-only', // 远端覆写相关 remote: { overridesUrl: '/api/lang/overrides', + // bundle 接口(remote-only 模式下使用) + bundleUrl: '/api/lang/bundle', + // diff 接口(预留) + diffUrl: '/api/lang/diff', // 首屏强依赖远端翻译(多租户文案强隔离),阻塞启动 // 但必须有上限避免白屏 bootTimeout: 2000, @@ -28,6 +34,8 @@ export const langConfig = { storagePrefix: 'LANG_OVR_', // 切租户/uniacid 时强制重拉 refreshOnUniacidChange: true, + // 签名校验密钥,留空不校验(需与后端 LANG_SIGN_SECRET 一致) + signSecret: '', } }; diff --git a/common/js/http.js b/common/js/http.js index a55eb7d..ac5d584 100644 --- a/common/js/http.js +++ b/common/js/http.js @@ -95,16 +95,15 @@ export default { reject(res); }, complete: (res) => { - if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) { - uni.showToast({ - title: getLang().lang('http.requestFailed', { url: Config.baseUrl }), - 'icon': 'none', - duration: 10000 - }); - return; - } - reject(res.data); + // success/fail 已处理 resolve/reject,complete 只负责兜底提示非预期状态 + if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) { + uni.showToast({ + title: getLang().lang('http.requestFailed', { url: Config.baseUrl }), + 'icon': 'none', + duration: 10000 + }); } + } }); }); } else { diff --git a/common/js/lang-loader.js b/common/js/lang-loader.js new file mode 100644 index 0000000..2e22c47 --- /dev/null +++ b/common/js/lang-loader.js @@ -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: __ + */ +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); +} diff --git a/common/js/lang.js b/common/js/lang.js index 165d5e6..50c7935 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -2,6 +2,7 @@ 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 = {}; @@ -425,6 +426,10 @@ const overrideCache = {}; // 远端覆写:并发去重 const overrideInflight = {}; +// remote-only 模式:缓存远端 bundle tree +// key: getOverrideMemKey(locale), value: { version, tree, ts, source } +const remoteTreeCache = {}; + // 远端可用性三态机:'unknown'(启动后未拉过) | 'ok'(最近一次成功) | 'fail'(最近一次失败) // 用 epoch ms 记录"最近一次状态变更时间",便于 UI 显示"离线 X 秒" let remoteStatus = 'unknown'; @@ -432,12 +437,13 @@ let remoteStatusAt = 0; const remoteStatusListeners = []; /** - * 校验 source 配置:只接受 local / remote / hybrid,其它一律视为 remote(安全默认) + * 校验 source 配置:只接受 local / remote / hybrid / remote-only,其它一律视为 remote(安全默认) */ 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 IS_REMOTE_ONLY = SOURCE === 'remote-only'; /** * 远端失败原因归一(对外稳定枚举,内部细节不外泄) @@ -733,6 +739,41 @@ function emitUpdated(lang) { 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, @@ -780,6 +821,36 @@ export default { 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 t = new Promise((r) => setTimeout(() => r({ __timeout: true, code: -1, message: 'timeout' }), remote.bootTimeout)); @@ -822,6 +893,14 @@ export default { // 切租户后清除 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)]; @@ -920,10 +999,16 @@ export default { } // ------- 优化3: 合并语言包缓存(避免每次 deepClone + merge + 覆写应用) ------- - const mergeCacheKey = `${locale}__${langPath}__${_overrideVersion}`; - let mergedLang = _mergedLangCache[mergeCacheKey]; - if (!mergedLang) { - try { + 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); @@ -936,12 +1021,13 @@ export default { 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; + } catch (e) { + console.error('解析语言包失败:', e, { langPath, field, locale }); + mergedLang = {}; } + _mergedLangCache[mergeCacheKey] = mergedLang; + } // ------- 字段解析 ------- let value = field; @@ -1042,11 +1128,20 @@ export default { Object.keys(overrideCache).forEach(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 拿到的就是 { ok:false, source:'local' },不会阻塞后续 refresh/reLaunch // 注意: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) { /* 静默 */ } } @@ -1119,6 +1214,16 @@ export default { 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]; diff --git a/components-diy/diy-category.vue b/components-diy/diy-category.vue index 0865646..5d4739f 100644 --- a/components-diy/diy-category.vue +++ b/components-diy/diy-category.vue @@ -51,7 +51,7 @@ - {{ $lang('components-diy.diyCategory.expand') }} + {{ $lang('components-diy.diy-category.expand') }} @@ -68,7 +68,7 @@ - {{ $lang('components-diy.diyCategory.clickToCollapse') }} + {{ $lang('components-diy.diy-category.clickToCollapse') }} @@ -102,7 +102,7 @@ @tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint" @loadfinish="getHeightArea"> - {{ $lang('components-diy.diyCategory.reachedBottom') }} + {{ $lang('components-diy.diy-category.reachedBottom') }} - {{ $lang('components-diy.diyCategory.noCategory') }} + {{ $lang('components-diy.diy-category.noCategory') }} - {{ $lang('components-diy.diyCategory.noCategory') }} + {{ $lang('components-diy.diy-category.noCategory') }}