/** * 主题 token 远端覆写运行时 * * 设计原则(与 lang.js 完全同构): * 1) 后端只存"对要个性化的 token"做覆写 KV,前端拿本地 style_color.js + 远端覆写 合并 * 2) 三态机: unknown / ok / fail * 3) 多租户 × 多端平台 × 多模式(brand/light/dark)三维隔离缓存 * 4) 失败归一: timeout / network / http / parse / exception * 5) 失败时使用本地 style_color.js 兜底 * 6) 不修改 Vuex.themeStyle / themeColorSet / 页面 wrapper,只把"合并后的 token map" * 提供给 store/index.js 的 applyThemeWithRemote action 使用 * * 暴露的 API: * ThemeOverride.boot() 首屏阻塞拉取 + 缓存预热(失败静默) * ThemeOverride.refresh(force) 重新拉取 + 重新应用 * ThemeOverride.getByTheme(theme) 取单个模式桶(brand/light/dark)的合并 KV * ThemeOverride.getAll() { brand, light, dark } * ThemeOverride.onUniacidChange() 切租户后清旧缓存 * ThemeOverride.getRemoteStatus() 'unknown' | 'ok' | 'fail' * ThemeOverride.onRemoteStatusChange(fn) 订阅状态变化 * ThemeOverride.__debug_cache 内部缓存(测试用) * * 关键: 一次 boot 只发一次 HTTP 请求,后端 getAllByTenant 一次出 brand/light/dark 三桶 * 避免对每个 theme 单独请求造成的 3 倍带宽浪费 */ import { themeConfig } from './config-external.js'; import Config from './config.js'; import http from './http.js'; import { Platform } from './platform.js'; const THEMES = ['brand', 'light', 'dark']; // 远端覆写:内存缓存 // shape: { [theme]: { version, map, ts, global_version } } const overrideCache = {}; // 远端覆写:并发去重(以"全量拉取"为粒度) let allInflight = null; // 远端可用性三态机 let remoteStatus = 'unknown'; let remoteStatusAt = 0; const remoteStatusListeners = []; // 配置项缓存 const REMOTE = (themeConfig && themeConfig.overrideRemote) || {}; /** * 校验 source 配置:只接受 local / remote / hybrid,其它一律视为 remote(安全默认) */ function normalizeSource(s) { return ['local', 'remote', 'hybrid'].includes(s) ? s : 'remote'; } const SOURCE = normalizeSource(REMOTE.source || 'remote'); /** * 远端失败原因归一 */ function normalizeReason(s) { const allow = ['timeout', 'network', 'http', 'parse', 'exception', 'unknown']; return allow.includes(s) ? s : 'unknown'; } /** * 远端成功/失败状态机 */ function setRemoteStatus(s, detail) { if (remoteStatus === s) return; const prev = remoteStatus; remoteStatus = s; remoteStatusAt = Date.now(); const safeDetail = { reason: normalizeReason(detail && detail.reason), theme: detail && detail.theme, }; remoteStatusListeners.forEach(fn => { try { fn(s, safeDetail); } catch (e) { /* 忽略监听器异常 */ } }); try { uni.$emit && uni.$emit('theme:remote-status', { from: prev, to: s, ...safeDetail }); } catch (e) {} if (s === 'fail') { try { uni.$emit && uni.$emit('theme:fallback', { reason: safeDetail.reason }); } catch (e) {} } } /** * 获得当前租户 id */ function getCurrentUniacid() { const u = uni.getStorageSync('uniacid'); return u ? parseInt(u, 10) || Config.uniacid : Config.uniacid; } /** * 当前平台类型 */ function getCurrentAppType() { try { return Platform.getAppType() || '*'; } catch (e) { return '*'; } } /** * 持久化 key: __ */ function getOverrideStorageKey(theme) { const u = getCurrentUniacid(); const a = getCurrentAppType(); return `${REMOTE.storagePrefix || 'THEME_OVR_'}${u}_${a}_${theme}`; } /** * 内存 cache key: __ */ function getOverrideMemKey(theme) { return `${getCurrentUniacid()}_${getCurrentAppType()}_${theme}`; } /** * 从 storage 预热单个 theme 桶到内存(首屏兜底) * 仅在内存还没有该 key 时才读 storage,避免覆盖更新值 */ function preheatFromStorage(theme) { const mk = getOverrideMemKey(theme); if (overrideCache[mk]) return; try { const cached = uni.getStorageSync(getOverrideStorageKey(theme)); if (cached && cached.map) { overrideCache[mk] = { version: cached.version || 0, map: cached.map || {}, ts: cached.ts || 0, global_version: cached.global_version || 0, }; return; } } catch (e) { /* 忽略 */ } overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 }; } /** * 把"单次 HTTP 响应"按 by_theme 拆到 3 个内存桶 + 持久化 */ function applyRemoteResponse(d) { const byTheme = (d && d.by_theme && typeof d.by_theme === 'object') ? d.by_theme : {}; const gv = (d && d.global_version) || 0; const ts = Date.now(); THEMES.forEach(theme => { const remoteMap = (byTheme[theme] && typeof byTheme[theme] === 'object') ? byTheme[theme] : {}; const mk = getOverrideMemKey(theme); const baseCache = overrideCache[mk] || { version: 0, map: {}, ts: 0, global_version: 0 }; const merged = Object.assign({}, baseCache.map, remoteMap); overrideCache[mk] = { version: gv, map: merged, ts, global_version: gv, }; try { uni.setStorageSync(getOverrideStorageKey(theme), overrideCache[mk]); } catch (e) {} }); } /** * 取"3 个桶里最大的 global_version"(穿透缓存用) */ function maxCachedGlobalVersion() { let gv = 0; THEMES.forEach(t => { const mk = getOverrideMemKey(t); const c = overrideCache[mk]; if (c && c.global_version > gv) gv = c.global_version; }); return gv; } /** * 一次 HTTP 拉齐 3 个 theme 桶 * 这是 boot() / refresh() / onUniacidChange() 共用的"拉取入口" * * 关键: 1 次 HTTP, 拆 3 桶,避免对每个 theme 单独请求造成 3 倍浪费 * * @returns {Promise<{ source:'cache'|'remote'|'local', ok:boolean, reason?:string, themes:string[] }>} */ async function loadAllOverrides() { if (SOURCE === 'local') return { source: 'local', ok: true, themes: THEMES }; // 并发去重:同一刻只允许一个在飞 if (allInflight) return allInflight; allInflight = (async () => { // 1) 先预热 storage 兜底 THEMES.forEach(preheatFromStorage); // 2) 一次 HTTP 拉齐 3 桶 try { const res = await Promise.race([ http.sendRequest({ url: REMOTE.overridesUrl || '/api/theme/overrides', data: { global_version: maxCachedGlobalVersion(), }, async: false, }), new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)), ]); if (!res || res.code !== 0 || !res.data) { const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response')); setRemoteStatus('fail', { reason, theme: 'all' }); return { source: 'local', ok: false, reason, themes: THEMES }; } applyRemoteResponse(res.data); setRemoteStatus('ok', { theme: 'all' }); emitUpdated('all'); return { source: 'remote', ok: true, themes: THEMES }; } catch (e) { setRemoteStatus('fail', { reason: 'exception', error: String(e && e.message || e), theme: 'all' }); return { source: 'local', ok: false, reason: 'exception', themes: THEMES }; } })(); try { return await allInflight; } finally { allInflight = null; } } /** * 触发"主题已更新"事件 * 注意:Vuex 的 themeColorSet action 已经在 setThemeStyle 后跑过, * CSS 变量已挂到页面 wrapper 上,组件会自动刷新。 * 这里只发通知,方便业务代码(如自定义 JS 直读 themeStyle 的页面)主动 re-render。 */ function emitUpdated(theme) { try { uni.$emit && uni.$emit('theme:updated', { theme }); } catch (e) {} } // 导出主体 export default { SOURCE, /** * 阻塞式预热:App.onLaunch 在 Lang.boot 后调用 * 失败静默,不阻塞首屏 * * @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:string, reason?:string, themes:string[]}>} */ async boot() { if (SOURCE === 'local') { return { ok: true, source: 'local', status: remoteStatus, themes: THEMES }; } const bootTimeout = REMOTE.bootTimeout || 2000; const t = new Promise((r) => setTimeout(() => r({ __timeout: true }), bootTimeout)); // 切租户检测 const currentUid = getCurrentUniacid(); try { const lastUid = uni.getStorageSync('theme_last_uniacid'); if (lastUid && String(lastUid) !== String(currentUid)) { this.onUniacidChange(); } uni.setStorageSync('theme_last_uniacid', currentUid); } catch (e) {} const result = await Promise.race([loadAllOverrides(), t]); if (result && result.__timeout) { return { ok: false, source: 'timeout', status: 'fail', reason: 'timeout', themes: THEMES }; } return { ok: remoteStatus === 'ok', source: remoteStatus === 'ok' ? 'remote' : 'local', status: remoteStatus, themes: THEMES, }; }, /** * 主动重新拉取(管理后台改了之后 EventBus 通知触发) * @param {boolean} [force=false] true = 绕过缓存强刷 */ async refresh(force = false) { if (force) { // 强刷:清掉内存 + 持久化缓存,逼 loadAllOverrides 走远端 THEMES.forEach(t => { const mk = getOverrideMemKey(t); if (overrideCache[mk]) overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 }; try { uni.removeStorageSync(getOverrideStorageKey(t)); } catch (e) {} }); } return loadAllOverrides(); }, /** * 切租户后清旧缓存 */ onUniacidChange() { if (SOURCE === 'local') return; if (REMOTE.refreshOnUniacidChange === false) return; THEMES.forEach(t => { const sk = getOverrideStorageKey(t); try { uni.removeStorageSync(sk); } catch (e) {} delete overrideCache[getOverrideMemKey(t)]; }); loadAllOverrides(); }, /** * 取某个 theme 桶(同步:有缓存返缓存;无缓存时该桶返回 {}) * store/index.js 的 applyThemeWithRemote action 直接用 * @param {'brand'|'light'|'dark'} theme * @returns {object} 扁平 KV map (没有时返回 {}) */ getByTheme(theme) { const mk = getOverrideMemKey(theme); const cached = overrideCache[mk]; return (cached && cached.map) ? cached.map : {}; }, /** * 取所有 theme 桶 * @returns {{ brand: object, light: object, dark: object }} */ getAll() { return { brand: this.getByTheme('brand'), light: this.getByTheme('light'), dark: this.getByTheme('dark'), }; }, /** * 当前远端状态 */ getRemoteStatus() { return remoteStatus; }, /** * 订阅远端状态变化 * @param {(status:string, detail:object) => void} fn * @returns {() => void} unsubscribe */ onRemoteStatusChange(fn) { if (typeof fn !== 'function') return () => {}; remoteStatusListeners.push(fn); try { fn(remoteStatus, { at: remoteStatusAt, source: SOURCE }); } catch (e) {} return () => { const i = remoteStatusListeners.indexOf(fn); if (i >= 0) remoteStatusListeners.splice(i, 1); }; }, // 内部测试/调试用 __debug_overrideCache: overrideCache, __debug_loadAllOverrides: loadAllOverrides, }