// 外置配置管理 // 用于管理需要外置的配置项,如语言包、主题配置等 // 语言包配置 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 前缀: __ // 三维隔离,与 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 前缀: // 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();