1. 新增theme-override.js实现主题远端拉取逻辑 2. 新增empty-style-stub.js作为主题包占位符,减少主包~75KB 3. 在vue.config.js中添加构建期主题stub替换逻辑 4. 重构config-external.js的主题配置,新增remote-only模式支持 5. 重构store的applyThemeWithRemote方法,适配远端主题拉取 6. 在bootstrap中新增主题初始化和异步拉取逻辑
477 lines
15 KiB
JavaScript
477 lines
15 KiB
JavaScript
/**
|
||
* 主题 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 = {};
|
||
|
||
// 远端全量 preset 内存缓存(remote-only 模式用)
|
||
// shape: { [presetName]: { ...tokenMap } }
|
||
const presetsCache = {};
|
||
|
||
// 远端覆写:并发去重(以"全量拉取"为粒度)
|
||
let allInflight = null;
|
||
|
||
// 远端全量 preset 拉取:并发去重
|
||
let presetsInflight = 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) {}
|
||
}
|
||
|
||
// 远端全量 preset 持久化 key: <storagePresetsPrefix><uniacid>_<appType>
|
||
function getPresetsStorageKey() {
|
||
const u = getCurrentUniacid();
|
||
const a = getCurrentAppType();
|
||
return `${REMOTE.storagePresetsPrefix || 'THEME_PRESETS_'}${u}_${a}`;
|
||
}
|
||
|
||
// 远端全量 preset 内存 cache key
|
||
function getPresetsMemKey() {
|
||
return `${getCurrentUniacid()}_${getCurrentAppType()}`;
|
||
}
|
||
|
||
// 从 storage 预热全量 preset 到内存(首屏兜底)
|
||
function preheatPresetsFromStorage() {
|
||
const mk = getPresetsMemKey();
|
||
if (presetsCache[mk] && Object.keys(presetsCache[mk]).length) return;
|
||
try {
|
||
const cached = uni.getStorageSync(getPresetsStorageKey());
|
||
if (cached && typeof cached === 'object') {
|
||
presetsCache[mk] = cached;
|
||
return;
|
||
}
|
||
} catch (e) { /* 忽略 */ }
|
||
}
|
||
|
||
/**
|
||
* 一次 HTTP 拉齐 8 个 preset(remote-only 模式)
|
||
* 关键: 不阻塞首屏,失败静默. store/index.js 的 applyThemeWithRemote 会优先用
|
||
* 内存缓存(由 loadAllPresets 写入), 缓存未命中时回退到 webpack stub(只含 default).
|
||
*
|
||
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
|
||
*/
|
||
async function loadAllPresets() {
|
||
if (SOURCE === 'local') return { source: 'skipped', ok: true, presets: {} };
|
||
if (!REMOTE.presetsUrl) return { source: 'skipped', ok: false, reason: 'no-url' };
|
||
|
||
// 并发去重
|
||
if (presetsInflight) return presetsInflight;
|
||
|
||
presetsInflight = (async () => {
|
||
// 1) 先预热 storage 兜底
|
||
preheatPresetsFromStorage();
|
||
|
||
// 2) 一次 HTTP 拉齐 8 个 preset
|
||
try {
|
||
const res = await Promise.race([
|
||
http.sendRequest({
|
||
url: REMOTE.presetsUrl,
|
||
data: {},
|
||
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'));
|
||
console.warn('[theme-presets] remote load fail, keep stub:', reason);
|
||
return { source: 'local', ok: false, reason };
|
||
}
|
||
|
||
// 3) 解析响应. 期望格式: { by_preset: { default: {...}, green: {...}, ... } }
|
||
const byPreset = (res.data.by_preset && typeof res.data.by_preset === 'object') ? res.data.by_preset : null;
|
||
if (!byPreset) {
|
||
console.warn('[theme-presets] response missing by_preset');
|
||
return { source: 'local', ok: false, reason: 'parse' };
|
||
}
|
||
|
||
// 4) 写入内存缓存 + 持久化
|
||
const mk = getPresetsMemKey();
|
||
presetsCache[mk] = byPreset;
|
||
try { uni.setStorageSync(getPresetsStorageKey(), byPreset); } catch (e) {}
|
||
|
||
// 5) 通知已更新
|
||
try { uni.$emit && uni.$emit('theme:presets-updated'); } catch (e) {}
|
||
|
||
return { source: 'remote', ok: true, presets: byPreset };
|
||
} catch (e) {
|
||
console.warn('[theme-presets] remote load exception:', e && e.message);
|
||
return { source: 'local', ok: false, reason: 'exception' };
|
||
}
|
||
})();
|
||
|
||
try {
|
||
return await presetsInflight;
|
||
} finally {
|
||
presetsInflight = null;
|
||
}
|
||
}
|
||
|
||
// 导出主体
|
||
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)];
|
||
});
|
||
// 同步清空全量 preset 缓存(remote-only 模式)
|
||
const psk = getPresetsStorageKey();
|
||
try { uni.removeStorageSync(psk); } catch (e) {}
|
||
delete presetsCache[getPresetsMemKey()];
|
||
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'),
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 取全量 preset 内存缓存(remote-only 模式)
|
||
* store/index.js 的 applyThemeWithRemote 通过这个取到远端拉的全量 preset
|
||
* @returns {{ [presetName: string]: object }}
|
||
*/
|
||
getAllPresets() {
|
||
const mk = getPresetsMemKey();
|
||
return presetsCache[mk] || {};
|
||
},
|
||
|
||
/**
|
||
* 主动拉全量 preset(remote-only 模式)
|
||
* - 失败静默, 不抛错
|
||
* - 内存 + 持久化双写
|
||
* - 拉成功后发 uni.$emit('theme:presets-updated')
|
||
*
|
||
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
|
||
*/
|
||
loadAllPresets,
|
||
|
||
/**
|
||
* 当前远端状态
|
||
*/
|
||
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,
|
||
}
|