Files
lucky_shop/common/js/theme-override.js
ZF sun 555443dc44 feat: add remote theme override support
实现了全量的主题远端覆写功能,包含:
1. 新增主题合并工具类,支持多桶安全合并与原型链过滤
2. 新增主题远端覆写运行时,支持多租户、多端、三态缓存
3. 改造Vuex主题加载逻辑,实现本地预设+远端覆写的合并应用
4. 改造App.vue首屏主题加载流程,支持并行拉取与缓存预热
5. 新增主题更新、租户切换的事件监听与自动刷新逻辑
2026-06-11 14:55:32 +08:00

357 lines
11 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.
/**
* 主题 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: <prefix><uniacid>_<appType>_<theme>
*/
function getOverrideStorageKey(theme) {
const u = getCurrentUniacid();
const a = getCurrentAppType();
return `${REMOTE.storagePrefix || 'THEME_OVR_'}${u}_${a}_${theme}`;
}
/**
* 内存 cache key: <uniacid>_<appType>_<theme>
*/
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,
}