feat: add remote theme override support
实现了全量的主题远端覆写功能,包含: 1. 新增主题合并工具类,支持多桶安全合并与原型链过滤 2. 新增主题远端覆写运行时,支持多租户、多端、三态缓存 3. 改造Vuex主题加载逻辑,实现本地预设+远端覆写的合并应用 4. 改造App.vue首屏主题加载流程,支持并行拉取与缓存预热 5. 新增主题更新、租户切换的事件监听与自动刷新逻辑
This commit is contained in:
73
App.vue
73
App.vue
@@ -1,7 +1,8 @@
|
||||
<script>
|
||||
import auth from 'common/js/auth.js';
|
||||
import configExternal from 'common/js/config-external.js'
|
||||
import configExternal, { themeConfig } from 'common/js/config-external.js'
|
||||
import Lang from 'common/js/lang.js';
|
||||
import ThemeOverride from 'common/js/theme-override.js';
|
||||
|
||||
import {
|
||||
Weixin
|
||||
@@ -10,19 +11,53 @@
|
||||
export default {
|
||||
mixins: [auth],
|
||||
onLaunch: async function(options) {
|
||||
// 方式:支持快应用,从url中query部分获取uniacid,或useragent中获取uniacid
|
||||
// ============================================================
|
||||
// 主题提前加载(关键):让首屏渲染前 CSS 变量已挂上,避免闪一下
|
||||
// ============================================================
|
||||
|
||||
// ---- 0) 同步预热: 立即读 storage 拿 preset,触发 require(不 await)
|
||||
// 目的: 让 loadTheme 的 require 提前进入 microtask,后续 await 几乎瞬时
|
||||
const cached = uni.getStorageSync('themeStyle');
|
||||
let presetName = themeConfig.defaultTheme;
|
||||
if (cached) {
|
||||
presetName = (typeof cached === 'object') ? cached.name : cached;
|
||||
}
|
||||
// 触发 preset require(不 await,缓存 promise 到 configExternal.loadedConfigs)
|
||||
configExternal.loadTheme(presetName);
|
||||
|
||||
// ---- 1) uniacid 立即写入(后续所有 HTTP 都要用到)
|
||||
const uniacidChanged = options.query.uniacid && String(uni.getStorageSync('uniacid') || '') !== String(options.query.uniacid);
|
||||
if (options.query.uniacid) {
|
||||
uni.setStorageSync('uniacid', options.query.uniacid);
|
||||
}
|
||||
// uniacid 显式切换后,主动通知 Lang 清旧租户缓存,避免 boot 期间读到旧值
|
||||
if (uniacidChanged) {
|
||||
try { Lang.onUniacidChange(); } catch (e) {}
|
||||
try { ThemeOverride.onUniacidChange(); } catch (e) {}
|
||||
try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {}
|
||||
}
|
||||
|
||||
// 首屏强依赖远端翻译覆写(多租户文案强隔离)
|
||||
// boot() 内部已有 bootTimeout 上限 + 失败静默,这里包 try 防止阻塞首屏
|
||||
try { await Lang.boot(); } catch (e) { console.warn('[i18n] boot fail', e); }
|
||||
// ---- 2) 主题应用(同步 await) + 远端拉取(并行 await)
|
||||
// 关键: 这一步必须 await,否则 page 会在主题挂上之前就渲染
|
||||
// 内部行为: 第一次 apply 只用 preset(此时 remote 还没拉),远端就绪后第二次 apply 叠加
|
||||
// 并行把 Lang.boot / ThemeOverride.boot 也拉了,节省总时间
|
||||
await Promise.all([
|
||||
// A) 主题第一次 apply — 走 preset,失败静默走默认
|
||||
this.$store.dispatch('applyThemeWithRemote', presetName)
|
||||
.catch(e => console.warn('[theme] pre-apply fail', e)),
|
||||
// B) Lang boot(首屏强依赖,bootTimeout=2000ms)
|
||||
Lang.boot().catch(e => console.warn('[i18n] boot fail', e)),
|
||||
// C) ThemeOverride boot(brand/light/dark 三桶,bootTimeout=2000ms)
|
||||
ThemeOverride.boot().catch(e => console.warn('[theme] boot fail', e)),
|
||||
]);
|
||||
|
||||
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并)
|
||||
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
|
||||
// 如果跟 step 2 的 apply 值相同,Vuex 会重复 commit 但页面无视觉变化
|
||||
await this.$store.dispatch('applyThemeWithRemote', presetName);
|
||||
|
||||
// ============================================================
|
||||
// 后续: 监听器 / 平台条件 / 其它 storage commit(全部同步/轻量)
|
||||
// ============================================================
|
||||
|
||||
// 监听远端翻译更新事件(管理后台改了词条后由后端推送 / 主动 reload)
|
||||
// 注意:lang.js 内 emitUpdated 已经 commit 了 setComponentRefresh,这里
|
||||
@@ -32,7 +67,19 @@
|
||||
Lang.refresh(true); // true = 显式推送 emitUpdated(虽然 loadOverrides 内部已发,这里保险起见)
|
||||
}
|
||||
});
|
||||
// lang:fallback / lang:remote-status 不在 App.vue 监听,留给业务按需订阅
|
||||
// 监听远端主题覆写更新(管理后台改了 token 后,本地 Vuex 重新合并并应用)
|
||||
// 关键: 走 applyThemeWithRemote action,保留原 setThemeStyle/themeColorSet 链路不动
|
||||
uni.$on && uni.$on('theme:updated', () => {
|
||||
// 重新从 storage 读 preset(可能用户在管理页切了预设)
|
||||
const cur = uni.getStorageSync('themeStyle');
|
||||
const curName = (cur && typeof cur === 'object') ? cur.name : (cur || 'default');
|
||||
this.$store.dispatch('applyThemeWithRemote', curName);
|
||||
});
|
||||
// 监听 uniacid 切换: 通知主题运行时清旧租户缓存,下次 refresh 拿到新值
|
||||
uni.$on && uni.$on('uniacid:changed', () => {
|
||||
try { ThemeOverride.onUniacidChange(); } catch (e) {}
|
||||
});
|
||||
// lang:fallback / lang:remote-status / theme:fallback / theme:remote-status 不在 App.vue 监听,留给业务按需订阅
|
||||
uni.hideTabBar();
|
||||
|
||||
// #ifdef MP
|
||||
@@ -75,17 +122,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// /api/config/init 拉服务器侧数据(包含真正该用的 preset name)
|
||||
// 内部: success 回调里再次 dispatch('applyThemeWithRemote', serverPreset)
|
||||
// fire-and-forget: 不阻塞后续同步 commit
|
||||
this.$store.dispatch('init');
|
||||
|
||||
// 存储到store中
|
||||
|
||||
// 主题风格
|
||||
if (uni.getStorageSync('themeStyle')) {
|
||||
const themeData = await configExternal.loadTheme(uni.getStorageSync('themeStyle'));
|
||||
this.$store.commit('setThemeStyle', themeData);
|
||||
this.$store.dispatch('themeColorSet');
|
||||
}
|
||||
|
||||
// 插件是否存在
|
||||
if (uni.getStorageSync('addonIsExist')) {
|
||||
this.$store.commit('setAddonIsExist', uni.getStorageSync('addonIsExist'));
|
||||
|
||||
@@ -33,10 +33,21 @@ export const langConfig = {
|
||||
|
||||
// 主题配置
|
||||
export const themeConfig = {
|
||||
// 主题列表
|
||||
// 主题列表 — 与 style_color.js 顶层的 preset 名一致
|
||||
themeList: ['default', 'red', 'green', 'blue', 'pink', 'gold', 'purple', 'yellow', 'black'],
|
||||
// 默认主题
|
||||
defaultTheme: 'default'
|
||||
defaultTheme: 'default',
|
||||
// 远端 token 覆写相关(与 langConfig 同构)
|
||||
// source : 'local' | 'remote' | 'hybrid'
|
||||
// remote.* : 首屏拉取 + 缓存 + 三态机 + 多租户多端隔离
|
||||
overrideRemote: {
|
||||
overridesUrl: '/api/theme/overrides',
|
||||
bootTimeout: 2000,
|
||||
requestTimeout: 4000,
|
||||
storagePrefix: 'THEME_OVR_',
|
||||
refreshOnUniacidChange: true,
|
||||
source: 'remote',
|
||||
}
|
||||
};
|
||||
|
||||
// API配置
|
||||
|
||||
81
common/js/merge-theme.js
Normal file
81
common/js/merge-theme.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 主题 token 合并工具
|
||||
*
|
||||
* 与 lang.js::applyOverrides 同构(都是"远端扁平覆写 map"按 key 路径合并),
|
||||
* 差异:
|
||||
* - 主题 token 是"扁平叶子"(如 main_color / bg_color),没有嵌套层级
|
||||
* - 需要支持"三桶叠加":presetBase(本地预设) < brand(品牌色) < userMode(夜间/浅色)
|
||||
* - value 是色值字符串(也允许空串 = 显式清空)
|
||||
*
|
||||
* 危险 key 过滤:拦截 __proto__/constructor/prototype,防止恶意覆写污染对象原型链
|
||||
*/
|
||||
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/**
|
||||
* 把单层 map 的 keys 过滤掉危险段(白名单:值必须是 string,null/undefined/其它跳过)
|
||||
* - 输入: 远端扁平 map { 'main_color': '#FF4646', '...': null, ... }
|
||||
* - 输出: 净化后的 map(只保留 string 值,空串也是合法覆写)
|
||||
*/
|
||||
export function sanitizeFlatMap(flatMap) {
|
||||
const out = {};
|
||||
if (!flatMap || typeof flatMap !== 'object') return out;
|
||||
for (const key in flatMap) {
|
||||
if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue;
|
||||
// 危险 key 过滤
|
||||
if (FORBIDDEN_KEYS.has(String(key))) continue;
|
||||
const val = flatMap[key];
|
||||
if (typeof val !== 'string') continue;
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多桶 map
|
||||
* @param {...object} maps 从低优先级到高优先级依次传入
|
||||
* 例: mergeThemeBuckets(presetBase, brand, dark) = preset 被 brand 覆盖,brand 被 dark 覆盖
|
||||
* @returns {object}
|
||||
*/
|
||||
export function mergeThemeBuckets(...maps) {
|
||||
const out = {};
|
||||
for (const m of maps) {
|
||||
if (!m || typeof m !== 'object') continue;
|
||||
for (const k in m) {
|
||||
if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
|
||||
if (FORBIDDEN_KEYS.has(String(k))) continue;
|
||||
const v = m[k];
|
||||
if (typeof v !== 'string') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一站式合并:本地预设(已选中的 theme 子集) + brand 桶 + userMode 桶
|
||||
* @param {object} preset 本地 style_color.js 中选中的 theme(如 'default' 的所有 token)
|
||||
* @param {object} brandOverrides brand 桶(任何模式都生效)
|
||||
* @param {object} userModeOverrides userMode 桶(light/dark,叠加在 brand 之上)
|
||||
* @returns {object} { tokens, sources }
|
||||
* tokens: 最终扁平 map
|
||||
* sources: 每个 key 的来源(用于调试/可视化) 'preset' | 'brand' | 'userMode'
|
||||
*/
|
||||
export function composeFinalTokens(preset, brandOverrides, userModeOverrides) {
|
||||
const tokens = {};
|
||||
const sources = {};
|
||||
const presetMap = sanitizeFlatMap(preset);
|
||||
const brandMap = sanitizeFlatMap(brandOverrides);
|
||||
const modeMap = sanitizeFlatMap(userModeOverrides);
|
||||
|
||||
for (const k in presetMap) { tokens[k] = presetMap[k]; sources[k] = 'preset'; }
|
||||
for (const k in brandMap) { tokens[k] = brandMap[k]; sources[k] = 'brand'; }
|
||||
for (const k in modeMap) { tokens[k] = modeMap[k]; sources[k] = 'userMode'; }
|
||||
return { tokens, sources };
|
||||
}
|
||||
|
||||
export default {
|
||||
sanitizeFlatMap,
|
||||
mergeThemeBuckets,
|
||||
composeFinalTokens,
|
||||
};
|
||||
356
common/js/theme-override.js
Normal file
356
common/js/theme-override.js
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 主题 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,
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import Http from '../common/js/http.js'
|
||||
import { themeConfig } from '../common/js/config-external.js'
|
||||
import configExternal from '../common/js/config-external.js'
|
||||
import util from '../common/js/util.js'
|
||||
import ThemeOverride from '../common/js/theme-override.js'
|
||||
import { sanitizeFlatMap, mergeThemeBuckets } from '../common/js/merge-theme.js'
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
@@ -266,9 +268,9 @@ const store = new Vuex.Store({
|
||||
success: async res => {
|
||||
var data = res.data;
|
||||
if (data) {
|
||||
const themeData = await configExternal.loadTheme(data.style_theme?.name);
|
||||
this.commit('setThemeStyle', themeData);
|
||||
this.dispatch('themeColorSet');
|
||||
// 走新 action: 加载预设 + 远端覆写合并 + 应用
|
||||
// (不直接 commit setThemeStyle + themeColorSet,避免漏远端)
|
||||
await this.dispatch('applyThemeWithRemote', data.style_theme?.name || themeConfig.defaultTheme);
|
||||
|
||||
// 底部导航
|
||||
this.commit('setTabBarList', data.diy_bottom_nav);
|
||||
@@ -443,6 +445,54 @@ const store = new Vuex.Store({
|
||||
} catch (e) {
|
||||
console.error('设置主题颜色失败', e);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 加载预设 + 拉远端覆写 + 深合并 + 走原 setThemeStyle/themeColorSet 链路
|
||||
* 关键: 应用层一行不动 — Vuex.themeStyle / themeColor / 页面 wrapper / CSS var
|
||||
* 全部保持兼容,所有改动只发生在"加载"环节
|
||||
*
|
||||
* @param {string} presetName style_color.js 中的 preset 名(default/red/green/...)
|
||||
* @returns {Promise<{ ok: boolean, source: string, applied: object }>}
|
||||
*/
|
||||
async applyThemeWithRemote({ state, commit, dispatch }, presetName) {
|
||||
const finalPresetName = presetName || themeConfig.defaultTheme;
|
||||
try {
|
||||
// 1) 加载本地预设(style_color.js)
|
||||
const base = await configExternal.loadTheme(finalPresetName);
|
||||
if (!base) {
|
||||
console.warn('[theme] loadTheme 返回空:', finalPresetName);
|
||||
return { ok: false, source: 'empty', applied: null };
|
||||
}
|
||||
|
||||
// 2) 拉远端 brand 桶(失败静默,使用本地缓存或空)
|
||||
const brandOverrides = ThemeOverride.getByTheme('brand') || {};
|
||||
|
||||
// 3) 浅合并(主题 token 是扁平叶子,不需要深合并)
|
||||
// preset 先, brand 后 — 远端覆盖本地
|
||||
const merged = mergeThemeBuckets(
|
||||
sanitizeFlatMap(base),
|
||||
sanitizeFlatMap(brandOverrides)
|
||||
);
|
||||
|
||||
// 4) 走原链路(完全不动下游) — Vuex.themeStyle = 合并后对象
|
||||
commit('setThemeStyle', merged);
|
||||
dispatch('themeColorSet');
|
||||
return { ok: true, source: 'merged', applied: merged };
|
||||
} catch (e) {
|
||||
console.error('[theme] applyThemeWithRemote 失败,回退到纯预设:', e);
|
||||
// 兜底: 至少把预设应用了,避免页面无主题
|
||||
try {
|
||||
const base = await configExternal.loadTheme(finalPresetName);
|
||||
if (base) {
|
||||
commit('setThemeStyle', base);
|
||||
dispatch('themeColorSet');
|
||||
return { ok: false, source: 'preset-only-fallback', applied: base };
|
||||
}
|
||||
} catch (e2) {
|
||||
console.error('[theme] 纯预设回退也失败:', e2);
|
||||
}
|
||||
return { ok: false, source: 'fail', applied: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user