Files
lucky_shop/common/js/config-external.js
ZF sun a913961be5 feat: 实现隐私弹窗全链路功能,支持本地配置与远程patch融合
- 新增隐私管理器核心逻辑,支持URL路由规则匹配、远程patch签名校验与融合
- 实现启动期资源等待机制,确保弹窗在i18n、主题配置就绪后触发
- 新增启动进度条组件,展示启动阶段进度与状态
- 重构隐私弹窗组件生命周期,修复事件丢失问题并支持规则动态重评估
- 适配多端平台隐私检查逻辑,支持微信小程序、鸿蒙元服务与H5
- 支持uniacid租户隔离的持久化规则存储
2026-07-11 18:28:36 +08:00

332 lines
13 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.
// 外置配置管理
// 用于管理需要外置的配置项,如语言包、主题配置等
// 语言包配置
export const langConfig = {
// 语言列表 — 与后端 Lang.php::overrides() 白名单对齐
// 增删需要后端同步改 LangOverrideModel::normalizeAppType 旁的 $allow_lang
langList: ['zh-cn', 'en-us'],
// 默认语言
defaultLang: 'zh-cn',
// 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载)
loadMode: 'lazy',
// 数据源开关
// 数据源开关
// 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',
// 首屏强依赖远端翻译(多租户文案强隔离),阻塞启动
// 但必须有上限避免白屏
// 2026-07-02: 从 2000 改为 5000 — 慢启动场景下,给首屏渲染更多时间
bootTimeout: 5000,
// 单次请求超时(ms)
// 2026-07-02: 从 4000 改为 60000 — 远端首次冷启动可能慢(CDN 边缘冷缓存/跨网段回源/MySQL JOIN)
// 配合 loadBundleTree 的"迟到数据补救"机制,即便超时也能拿到远端最终返回的数据
requestTimeout: 60000,
// 缓存有效期(秒),与后端 Cache::set 600s 对齐;过期后后台刷新
cacheTtl: 600,
// 本地缓存 key 前缀: <prefix><uniacid>_<appType>_<lang>
// 三维隔离,与 lang.js::getOverrideStorageKey / getOverrideMemKey 保持一致
storagePrefix: 'LANG_OVR_',
// 切租户/uniacid 时强制重拉
refreshOnUniacidChange: true,
// 签名校验密钥,留空不校验(需与后端 LANG_SIGN_SECRET 一致)
signSecret: '',
}
};
// 主题配置
export const themeConfig = {
// 主题列表 — 与 style_color.js 顶层的 preset 名严格一致(8 个)
// 关键: 这里列出的名字必须能在 style_color.js (或后端 style_color.php 镜像) 中找到对应 preset
// 历史上曾包含 'red' 但 style_color.js 从未定义 'red' preset, 切到 'red' 主题时
// loadTheme() 会返回 undefined → 走 stub default 兜底样式, 远端也无法补齐
// 2026-07-07 移除 'red' 以保持一致性
themeList: ['default', 'green', 'blue', 'pink', 'gold', 'purple', 'yellow', 'black'],
// 默认主题
defaultTheme: 'default',
// 远端 token 覆写相关(与 langConfig 同构)
// source : 'local' | 'remote' | 'hybrid' | 'remote-only'
// remote.* : 首屏拉取 + 缓存 + 三态机 + 多租户多端隔离
overrideRemote: {
overridesUrl: '/api/theme/overrides',
// 全量 preset 拉取(remote-only 模式下使用)
// 返回 8 个 preset 完整 token,替代本地 style_color.js
presetsUrl: '/api/theme/presets',
bootTimeout: 5000,
requestTimeout: 60000,
// 远端全量 preset 缓存 key 前缀
storagePresetsPrefix: 'THEME_PRESETS_',
storagePrefix: 'THEME_OVR_',
refreshOnUniacidChange: true,
// source: 'remote-only' 时,webpack 会用 NormalModuleReplacementPlugin
// 把 style_color.js 替换为 empty-style-stub.js(只含 emergency fallback)
// 节省主包 ~75KB(完整 8 preset × ~50 token = 80KB → stub 7KB)
//
// 与 lang/ 目录的 remote-only 模式完全同构:
// - 构建期: webpack 替换为 stub
// - 运行时: ThemeOverride.loadAllPresets() 异步拉 /api/theme/presets
// - 首屏: 走 stub 的 default preset,不白屏
// - 切换: 切到非 default preset 时,等远端拉完再应用(由 applyThemeWithRemote
// 订阅 theme:presets-updated 事件驱动)
source: 'remote-only',
}
};
// API配置
export const apiConfig = {
// API请求超时时间
timeout: 30000,
// 是否开启重试
retry: true,
// 重试次数
retryCount: 3,
// 重试间隔时间(毫秒)
retryDelay: 1000
};
// 隐私弹窗配置 (与 langConfig/themeConfig 同构)
// 本地兜底 + 远程 patch (来自 /api/config/init data.privacy_url_rules) 融合
// source: 'local' - 仅本地兜底, 忽略远程 patch
// 'remote' - 本地兜底 + 远程 patch 覆盖 (默认)
// 'hybrid' - 同 remote, 远程失败时退回本地
// 'remote-only' - 完全依赖远程 patch, 本地兜底为空
export const privacyConfig = {
// 本地兜底规则 (mode: 'all'|'only'|'skip', patterns: string[])
// 远程 patch 由 store/index.js 的 init action 通过 PrivacyManager.applyWithMerge() 融合
// patterns 写法:
// - 子串匹配 + 结尾匹配 (双通道), 业务侧可以写完整路径或相对路径
// - subPackage 路由 (如 /pages_tool/member/index) 用短路径 'member/index' 即可命中
urlRulesBase: {
// 场景 2: 只有首页、商品详情、个人中心才弹, 其他页面都不弹
mode: 'only',
patterns: [
// 首页 (含 subPackage 重定向后也是 pages/index/index)
'pages/index/index',
// 商品详情 (subPackage 内可能为 pages_goods/goods/detail/detail, 这里用末尾匹配)
'goods/detail/detail',
// 个人中心 (subPackage 内通常为 pages_tool/member/index)
'member/index',
],
},
// dataSource: 与 langConfig.source 同构
// 'local' 纯本地, 忽略 /api/config/init 的 privacy_url_rules
// 'remote' 本地 + 远程 patch (远程失败时仍用本地)
// 'hybrid' 同 remote, 语义别名
// 'remote-only' 必须有远程 patch, 本地空 patterns → 一律不弹
source: 'remote',
// 启动期等待通道 (弹窗必须等这些 promise 才弹)
// waitLang: Lang.boot() - i18n 数据就绪
// waitTheme: applyThemeWithRemote() - 本地 preset 应用
// waitThemeOverride: ThemeOverride.boot() - brand/light/dark 三桶 token
// waitRemoteConfig: /api/config/init (含 privacy_url_rules 远程 patch)
waitChannels: {
lang: true,
theme: true,
themeOverride: true,
remoteConfig: true,
},
// 总超时熔断 (ms): 所有等待通道超过这个时间强制放行, 不阻塞弹窗
// 取 langConfig.remote.bootTimeout (5000) 和 themeConfig.overrideRemote.bootTimeout (5000) 的最大值
// + 1000ms 余量 → 6000ms. 与原硬编码值一致, 但可通过修改此字段调整.
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大; 想"快速失败" → 调小.
bootTimeout: 6000,
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
// 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
// secret='' → 跳过校验, 接受任意 patch (向后兼容老后端)
// secret='xxx' → 校验 patch.sign, 不匹配则拒绝
// 留空默认不校验. 前端实现是 btoa 弱校验, 仅防简单篡改, 强校验建议用 crypto-js.
signSecret: '',
// 单次 /api/config/init 请求超时 (ms)
// 复用 apiConfig.timeout, 不单独配置
// remote: {
// // 远程 patch 字段名 (后端 /api/config/init 返回 data.privacy_url_rules)
// fieldPath: 'privacy_url_rules',
// // 切租户/uniacid 时强制重拉 (远端 patch 重新下发)
// refreshOnUniacidChange: true,
// // 本地缓存 key 前缀: <prefix><uniacid>
// storagePrefix: 'privacy_url_rules_',
// },
};
// 配置外置管理类
class ConfigExternal {
constructor() {
this.loadedConfigs = {};
this.loadPromises = {};
// 启动期各通道的 await promise (由 App.vue onLaunch 注入)
// 隐私弹窗 show() 时通过 PrivacyManager.getReadyWaiters() 拿到 → 串成 readyGuard
this.readyWaiters = {
lang: null,
theme: null,
themeOverride: null,
remoteConfig: null,
};
}
/**
* 设置启动期各通道的 await promise
* App.vue onLaunch 在 step 2 注入, PrivacyManager.show() 时读取
* @param {Object} waiters { lang?, theme?, themeOverride?, remoteConfig? }
*/
setReadyWaiters(waiters) {
if (!waiters || typeof waiters !== 'object') return
for (const k of Object.keys(this.readyWaiters)) {
if (waiters[k] && typeof waiters[k].then === 'function') {
this.readyWaiters[k] = waiters[k]
}
}
}
/**
* 获取隐私弹窗 readyGuard (合并所有启用的等待通道)
* 任意通道 disabled → 不等
* 所有通道都 null → 返回 Promise.resolve() (不退化)
* @param {Object} channels { lang?, theme?, themeOverride?, remoteConfig? }
* @returns {Promise}
*/
getPrivacyReadyGuard(channels) {
const cfg = (typeof channels === 'object' && channels) || (privacyConfig.waitChannels || {})
const promises = []
for (const k of ['lang', 'theme', 'themeOverride', 'remoteConfig']) {
if (cfg[k] && this.readyWaiters[k]) {
promises.push(this.readyWaiters[k])
}
}
if (!promises.length) return Promise.resolve()
return Promise.all(promises).catch(() => {})
}
/**
* 隐私弹窗总超时熔断 (ms)
* 默认取 langConfig.remote.bootTimeout 与 themeConfig.overrideRemote.bootTimeout 的最大值 + 1000ms 余量
* 业务侧可通过 privacyConfig.bootTimeout 覆盖
*/
getPrivacyBootTimeout() {
if (privacyConfig && typeof privacyConfig.bootTimeout === 'number' && privacyConfig.bootTimeout > 0) {
return privacyConfig.bootTimeout
}
const langT = (langConfig && langConfig.remote && langConfig.remote.bootTimeout) || 5000
const themeT = (themeConfig && themeConfig.overrideRemote && themeConfig.overrideRemote.bootTimeout) || 5000
return Math.max(langT, themeT) + 1000
}
/**
* 加载语言包
* @param {string} lang - 语言类型
* @returns {Promise} - 加载结果
*/
async loadLang(lang = langConfig.defaultLang) {
if (this.loadedConfigs[`lang_${lang}`]) {
return this.loadedConfigs[`lang_${lang}`];
}
if (this.loadPromises[`lang_${lang}`]) {
return this.loadPromises[`lang_${lang}`];
}
this.loadPromises[`lang_${lang}`] = new Promise((resolve, reject) => {
try {
// 动态加载语言包
const langData = require(`@/lang/${lang}/common.js`).lang;
this.loadedConfigs[`lang_${lang}`] = langData;
resolve(langData);
} catch (error) {
// console.error(`加载语言包 ${lang} 失败:`, error);
reject(error);
}
});
return this.loadPromises[`lang_${lang}`];
}
/**
* 加载页面语言包
* @param {string} lang - 语言类型
* @param {string} pagePath - 页面路径
* @returns {Promise} - 加载结果
*/
async loadPageLang(lang = langConfig.defaultLang, pagePath) {
const key = `page_${lang}_${pagePath}`;
if (this.loadedConfigs[key]) {
return this.loadedConfigs[key];
}
if (this.loadPromises[key]) {
return this.loadPromises[key];
}
this.loadPromises[key] = new Promise((resolve, reject) => {
try {
// 动态加载页面语言包
const pageLangData = require(`@/lang/${lang}/${pagePath}.js`).lang;
this.loadedConfigs[key] = pageLangData;
resolve(pageLangData);
} catch (error) {
console.error(`加载页面语言包 ${lang}/${pagePath} 失败:`, error);
resolve({});
}
});
return this.loadPromises[key];
}
/**
* 加载主题配置(异步方式)
* 关键: 在 remote-only 模式下, style_color.js 已被 webpack 替换为 empty-style-stub.js,
* 只含 1 个 default preset. 其它 7 个 preset 由 ThemeOverride.loadAllPresets()
* 异步拉取并合并到本地缓存. 这里的 loadTheme 优先从缓存取,缓存未命中时回退到 stub.
*
* @param {string} theme - 主题名称
* @returns {Promise} - 加载结果
*/
async loadTheme(theme = themeConfig.defaultTheme) {
// 1) 先查已加载的内存缓存(包括远端拉取后写入的)
if (this.loadedConfigs[`theme_${theme}`]) {
return this.loadedConfigs[`theme_${theme}`];
}
// 2) remote-only 模式下,从 ThemeOverride 内存缓存(由 loadAllPresets 写入)取
try {
const ThemeOverride = require('./theme-override.js').default;
const remotePresets = ThemeOverride.getAllPresets();
if (remotePresets && remotePresets[theme]) {
this.loadedConfigs[`theme_${theme}`] = remotePresets[theme];
return remotePresets[theme];
}
} catch (e) {
// ignore — ThemeOverride 不可用时继续走 require stub
}
// 3) 兜底: require style_color.js (local 模式全量, remote-only 模式 stub)
if (this.loadPromises[`theme_${theme}`]) {
return this.loadPromises[`theme_${theme}`];
}
this.loadPromises[`theme_${theme}`] = new Promise((resolve, reject) => {
try {
// 动态加载主题配置 (webpack 替换为 stub 时,只返回 1 个 default preset)
const themeData = require(`@/common/js/style_color.js`)['default'][theme];
this.loadedConfigs[`theme_${theme}`] = themeData;
resolve(themeData);
} catch (error) {
console.error(`加载主题 ${theme} 失败:`, error);
reject(error);
}
});
return this.loadPromises[`theme_${theme}`];
}
}
export default new ConfigExternal();