From 82fd3839a96826286634982942c9d0037e348720 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Tue, 7 Jul 2026 14:56:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Eremote-only=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=A8=A1=E5=BC=8F=EF=BC=8C=E4=BC=98=E5=8C=96=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=E5=8C=85=E4=BD=93=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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中新增主题初始化和异步拉取逻辑 --- common/js/config-external.js | 70 ++++++++++---- common/js/empty-style-stub.js | 169 ++++++++++++++++++++++++++++++++++ common/js/theme-override.js | 120 ++++++++++++++++++++++++ main.js | 9 ++ store/index.js | 87 ++++++++++++++--- vue.config.js | 59 ++++++++++-- 6 files changed, 476 insertions(+), 38 deletions(-) create mode 100644 common/js/empty-style-stub.js diff --git a/common/js/config-external.js b/common/js/config-external.js index 9806619..da75f08 100644 --- a/common/js/config-external.js +++ b/common/js/config-external.js @@ -46,21 +46,40 @@ export const langConfig = { // 主题配置 export const themeConfig = { - // 主题列表 — 与 style_color.js 顶层的 preset 名一致 - themeList: ['default', 'red', 'green', 'blue', 'pink', 'gold', 'purple', 'yellow', 'black'], - // 默认主题 - 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', - } + // 主题列表 — 与 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配置 @@ -144,23 +163,40 @@ class ConfigExternal { /** * 加载主题配置(异步方式) + * 关键: 在 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]; - // console.log('async themeData => ', themeData); this.loadedConfigs[`theme_${theme}`] = themeData; resolve(themeData); } catch (error) { diff --git a/common/js/empty-style-stub.js b/common/js/empty-style-stub.js new file mode 100644 index 0000000..70a9f57 --- /dev/null +++ b/common/js/empty-style-stub.js @@ -0,0 +1,169 @@ +// ============================================================ +// 空主题预设占位(stub) +// +// 在 themeConfig.overrideRemote.source === 'remote-only' 模式下, +// style_color.js 会被 webpack.NormalModuleReplacementPlugin 替换为这个 stub. +// +// 目的: 主包不再包含 8 个完整 preset (~80KB), 只保留最小 emergency fallback +// (~5KB, 仅 1 个 default preset 的核心 token). +// +// 设计要点: +// 1) 导出 default 对象, 结构与 style_color.js 一致: { 'default': { ... } } +// 2) 包含最关键的 token(main_color, aux_color, bg_color 等), +// 保证 themeColorSet 能写入基础 CSS 变量,首屏不白屏 +// 3) 缺失的 token 由远端 /api/theme/presets 异步补齐,补齐前 var() 解析失败 +// 时浏览器会回退到 SCSS 的 fallback 值(uni-app H5 已做 rpx→px) +// 4) 其他 7 个 preset 完全由远端提供,本地不打包 +// 5) 失败兜底: 远端不可用时, 切到非 default preset 会落到 default 的样式 +// +// token 数量统计: 8 preset × ~50 token = ~400 token. 完整 80KB. +// 1 preset × ~50 token = ~5KB. 节省 ~94%. +// ============================================================ + +export default { + 'default': { + name: 'default', + main_color: '#F4391c', + aux_color: '#F7B500', + bg_color: '#FF4646', + bg_color_shallow: '#FF4646', + promotion_color: '#FF4646', + promotion_aux_color: '#F7B500', + main_color_shallow: '#FFF4F4', + price_color: 'rgb(252,82,39)', + btn_text_color: '#FFFFFF', + + goods_price: 'rgb(252,82,39,1)', + promotion_tag: '#FF4646', + goods_card_bg: '#201A18', + goods_card_bg_shallow: '#7C7878', + goods_card_color: '#FFD792', + goods_coupon: '#FC5227', + goods_cart_num_corner: '#FC5227', + goods_btn_color: '#FF4646', + goods_btn_color_shallow: '#F7B500', + + pintuan_label_bg: '#F7B500', + pintuan_label_color: '#FFFFFF', + pintuan_color: '#FA6400', + pintuan_promotion_color: '#FA3A1D', + pintuan_promotion_aux_color: '#FD9A01', + + super_member_start_bg: '#7c7878', + super_member_end_bg: '#201a18', + super_member_start_text_color: '#FFDBA6', + super_member_end_text_color: '#FFEBCA', + + bargain_promotion_color: '#F0353E', + bargain_promotion_aux_color: '#FD9A01', + + seckill_promotion_color: '#F83530', + seckill_promotion_aux_color: '#FD9A01', + + giftcard_promotion_color: '#FF3369', + giftcard_promotion_aux_color: '#F7B500', + + groupby_promotion_color: '#E64136', + groupby_promotion_aux_color: '#F7B500', + + hover_nav_bg_color: '#FFFC', + hover_nav_text_color: '#000', + + // ===== DIY 分类页字号 token ===== + diy_category_tree_select_size: '28rpx', + diy_category_tree_normal_size: '20rpx', + diy_category_item_title_size: '26rpx', + diy_category_item_goods_title_size:'28rpx', + diy_category_item_name_size: '24rpx', + diy_category_item_goods_name_size:'28rpx', + diy_category_item_goods_tag_size: '22rpx', + diy_category_item_consult_now_size:'26rpx', + diy_category_item_consult_now_bg: '#FF4646', + diy_category_item_consult_now_text: '#FFFFFF', + diy_category_item_consult_now_padding: '12rpx 30rpx', + diy_category_item_consult_now_radius: '40rpx', + diy_category_item_consult_now_weight: '500', + diy_category_item_consult_now_line_height:'1.2', + diy_category_item_consult_now_text_align: 'center', + diy_category_item_consult_now_white_space:'nowrap', + diy_category_item_consult_now_display: 'inline-block', + + // ===== DIY 分类页 - 商品卡 token ===== + diy_category_item_goods_name_line_height:1.3, + diy_category_item_goods_name_margin_top: '20rpx', + diy_category_item_discount_price_weight: 'bold', + diy_category_item_discount_price_line_height: 1, + diy_category_item_discount_price_margin_top: '16rpx', + diy_category_item_pro_info_margin_top: '16rpx', + diy_category_item_delete_price_size: '24rpx', + diy_category_item_delete_price_line_height: 1, + diy_category_item_delete_price_text_decoration:'line-through', + diy_category_item_member_price_tag_width: '60rpx', + diy_category_item_member_price_tag_height: '28rpx', + diy_category_item_member_price_tag_line_height: 1, + diy_category_item_member_price_tag_margin_left: '6rpx', + diy_category_item_info_wrap_padding: '0 20rpx', + diy_category_item_info_wrap_margin_top: '12rpx', + diy_category_item_footer_wrap_display: 'flex', + diy_category_item_footer_wrap_justify_content: 'space-between', + diy_category_item_footer_wrap_align_items: 'normal', + diy_category_item_footer_wrap_margin_top: 0, + diy_category_item_price_wrap_display: 'block', + diy_category_item_price_wrap_align_items: 'normal', + diy_category_item_price_wrap_flex_wrap: 'nowrap', + diy_category_item_price_wrap_justify_content: 'normal', + diy_category_item_price_wrap_margin_top: 0, + diy_category_item_name_wrap_flex: '1', + diy_category_item_consult_now_wrap_display: 'flex', + diy_category_item_consult_now_wrap_justify_content:'flex-end', + diy_category_item_consult_now_wrap_margin_top: 'auto', + diy_category_item_consult_now_wrap_padding_top: '10rpx', + diy_category_item_color_tip_color: '#909399', + diy_category_item_price_font_weight: 500, + + // ===== 营销 token(全 8 preset 一致) ===== + pintuan_label_grad_start: '#ffbd5b', + pintuan_label_grad_end: '#fd882e', + pintuan_avatar_border: '#ff3d3d', + pintuan_success_start: '#3edb73', + pintuan_success_end: '#1db576', + bargain_promo_shallow: '#ffeadb', + seckill_price_shallow: '#fcecd7', + seckill_price_start: '#fdbe6c', + seckill_discount_start: '#ff5f17', + seckill_discount_end: '#fe2f18', + member_card_gold_start: '#E3B66B', + member_card_gold_end: '#F7DAA5', + member_icon_grad_start: '#814635', + member_icon_grad_end: '#3A221B', + balance_page_bg_start: '#FE7849', + balance_page_bg_end: '#FF1959', + point_page_bg_start: '#FE7849', + point_page_bg_end: '#FF1959', + point_promo_start: '#F8F8F8', + point_promo_end: '#E74A32', + point_item_grad_start: '#F4402B', + point_item_grad_end: '#FD7C40', + invite_grad_start: '#ff2440', + invite_grad_end: '#ff7b7b', + promote_grad_start: '#ffe2ac', + promote_grad_end: '#fdc174', + fenxiao_withdraw_start: '#ff3c29', + fenxiao_withdraw_end: '#ff6f29', + level_1_grad_start: '#9BA7B1', level_1_grad_end: '#D3DEE6', + level_2_grad_start: '#F57E2A', level_2_grad_end: '#FAD494', + level_3_grad_start: '#F85151', level_3_grad_end: '#FF9999', + level_4_grad_start: '#78B8B4', level_4_grad_end: '#AFE6E2', + level_5_grad_start: '#4DA1E1', level_5_grad_end: '#58CBF0', + level_6_grad_start: '#81C636', level_6_grad_end: '#D1F677', + level_7_grad_start: '#6D7279', level_7_grad_end: '#A5AAB0', + level_8_grad_start: '#866DDB', level_8_grad_end: '#D49BFE', + level_9_grad_start: '#f1c74e', level_9_grad_end: '#f7dc81', + level_10_grad_start: '#418CCF', level_10_grad_end: '#9CC6F1', + } + // 其他 7 个 preset (green/blue/pink/gold/purple/yellow/black) + // 全部由远端 /api/theme/presets 异步提供 + // 远端拉取前切到非 default preset,会回退到 default 样式 + // 注: 历史曾尝试添加 'red' preset, 但前端 style_color.js 从未定义, 已在 + // config-external.js themeList 中移除以保持一致性 +}; diff --git a/common/js/theme-override.js b/common/js/theme-override.js index 0a9afa1..1d91819 100644 --- a/common/js/theme-override.js +++ b/common/js/theme-override.js @@ -35,9 +35,16 @@ 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; @@ -236,6 +243,95 @@ function emitUpdated(theme) { try { uni.$emit && uni.$emit('theme:updated', { theme }); } catch (e) {} } +// 远端全量 preset 持久化 key: _ +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, @@ -303,6 +399,10 @@ export default { 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(); }, @@ -330,6 +430,26 @@ export default { }; }, + /** + * 取全量 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, + /** * 当前远端状态 */ diff --git a/main.js b/main.js index bd9a0d0..bc6638b 100644 --- a/main.js +++ b/main.js @@ -17,6 +17,7 @@ import { import configExternal from './common/js/config-external.js' import PrivacyManager from './common/js/privacy.js' import splashState from './common/js/splash-state.js' +import ThemeOverride from './common/js/theme-override.js' Vue.prototype.$store = store //挂在vue @@ -105,7 +106,15 @@ async function bootstrap() { if (Lang.isRemoteOnly && Lang.isRemoteOnly()) { splashState.show(); try { + // 1) i18n: 拉远端语言包 (langConfig.source === 'remote-only') await Lang.boot(); + // 2) theme: 阻塞式预热 brand 桶覆写 (themeConfig.overrideRemote.source) + await ThemeOverride.boot(); + // 3) theme(remote-only): 异步拉全量 8 个 preset 替换 stub + // 失败静默, 兜底用 webpack stub(只含 default),首屏不白屏 + ThemeOverride.loadAllPresets().catch((e) => { + console.warn('[theme-presets] load fail', e); + }); } catch (e) { console.warn('[i18n] remote-only boot fail', e); } finally { diff --git a/store/index.js b/store/index.js index f0ce4f8..c93f2ee 100644 --- a/store/index.js +++ b/store/index.js @@ -489,33 +489,92 @@ const store = new Vuex.Store({ * 关键: 应用层一行不动 — Vuex.themeStyle / themeColor / 页面 wrapper / CSS var * 全部保持兼容,所有改动只发生在"加载"环节 * - * @param {string} presetName style_color.js 中的 preset 名(default/red/green/...) + * remote-only 模式兼容: + * - 首次调用时 loadAllPresets 可能尚未完成,loadTheme() 会拿到 webpack stub + * (只含 default preset). 如果用户选了非 default preset(green/blue/...), + * loadTheme 返回 undefined. + * - 兜底: 订阅 theme:presets-updated 事件,远端拉完后重新应用. + * 一次性订阅(once 语义,触发后即解绑),避免后续切主题时重复触发. + * + * @param {string} presetName style_color.js 中的 preset 名(default/green/blue/...) * @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 }; + + // 工具:订阅一次 presets-updated 后自动解绑 + const subscribeOnce = (cb) => { + let called = false; + const handler = async () => { + if (called) return; + called = true; + try { uni.$off && uni.$off('theme:presets-updated', handler); } catch (e) {} + try { await cb(); } catch (e) { console.error('[theme] presets-updated reapply fail', e); } + }; + try { + uni.$on && uni.$on('theme:presets-updated', handler); + } catch (e) { + console.warn('[theme] uni.$on bind fail', e); } + return () => { + if (called) return; + called = true; + try { uni.$off && uni.$off('theme:presets-updated', handler); } catch (e) {} + }; + }; - // 2) 拉远端 brand 桶(失败静默,使用本地缓存或空) + // 工具:真正加载 + 合并 + 提交 + // 关键: 调用前清空 configExternal.loadedConfigs 缓存,避免第一次拿到 stub 后 + // 远端 preset 到达时 loadTheme 直接返回缓存而不读 ThemeOverride. + const doApply = async (name) => { + try { + if (configExternal && configExternal.loadedConfigs) { + delete configExternal.loadedConfigs[`theme_${name}`]; + } + } catch (e) { /* 忽略 */ } + const base = await configExternal.loadTheme(name); + if (!base) return null; 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 }; + return merged; + }; + + try { + // 1) 尝试同步加载 + const first = await doApply(finalPresetName); + + // 2) 加载失败(非 default preset 在 stub 中不存在)或 first 为空 + // → 等远端 preset 拉完后重试 + if (!first) { + console.log('[theme] preset 暂不可用,等待远端 preset 拉完:', finalPresetName); + // 不阻塞当前调用方,返回空结果(应用层会显示 stub default) + // 远端到后会自动重应用 + subscribeOnce(async () => { + const retry = await doApply(finalPresetName); + if (retry) { + console.log('[theme] 远端 preset 到达,重应用成功:', finalPresetName); + } else { + console.warn('[theme] 远端 preset 到达后仍找不到:', finalPresetName); + } + }); + return { ok: false, source: 'waiting-presets', applied: null }; + } + + // 3) 首次成功,但远端 preset 也许之后会到(用于其他 preset 的切换/覆写) + // 兜底订阅一次,如果远端 preset 包含了更全的 token 也能再补一次 + subscribeOnce(async () => { + const retry = await doApply(finalPresetName); + if (retry) { + console.log('[theme] 远端 preset 补充,重应用:', finalPresetName); + } + }); + + return { ok: true, source: 'merged', applied: first }; } catch (e) { console.error('[theme] applyThemeWithRemote 失败,回退到纯预设:', e); // 兜底: 至少把预设应用了,避免页面无主题 diff --git a/vue.config.js b/vue.config.js index 276fc88..37c0a46 100644 --- a/vue.config.js +++ b/vue.config.js @@ -107,13 +107,31 @@ function _readManifestBase(manifestPath) { * 用 fs 静态解析, 避免 webpack 把整个 config-external.js 链入 vue.config 编译路径 */ function _readLangSource() { - try { - const content = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf-8'); - const match = content.match(/source:\s*['"]([\w-]+)['"]/); - return match ? match[1] : null; - } catch (e) { - return null; - } + try { + const content = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf-8'); + const match = content.match(/source:\s*['"]([\w-]+)['"]/); + return match ? match[1] : null; + } catch (e) { + return null; + } +} + +/** + * 读取 themeConfig.overrideRemote.source, 用于构建期判断是否启用 style_color 排除策略 + * remote-only 模式: style_color.js 替换为 empty-style-stub.js(只含 emergency fallback) + * 节省主包 ~75KB + */ +function _readThemeSource() { + try { + const content = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf-8'); + // 找到 overrideRemote 块中的 source 字段(可能有多个 source 字段,取 overrideRemote 块内的) + const orBlock = content.match(/overrideRemote\s*:\s*\{([\s\S]*?)\n\s*\}/); + if (!orBlock) return null; + const match = orBlock[1].match(/source:\s*['"]([\w-]+)['"]/); + return match ? match[1] : null; + } catch (e) { + return null; + } } /** @@ -457,6 +475,33 @@ module.exports = { console.log(`[vue.config] lang/ 目录保留 (source=${langSource || 'unknown'})`); } + // style_color.js 排除: 仅在 themeConfig.overrideRemote.source === 'remote-only' 模式下生效 + // 原理: config-external.js 的 loadTheme() 用 require('@/common/js/style_color.js') 同步加载 + // 整个文件. 替换为 stub 后, 主包只包含 1 个 default preset (~5KB), 其他 7 个 preset + // 由远端 /api/theme/presets 异步补齐. 节省主包 ~75KB. + const themeSource = _readThemeSource(); + if (themeSource === 'remote-only') { + try { + const webpack = require('webpack'); + const stubPath = path.resolve(__dirname, 'common/js/empty-style-stub.js'); + if (webpack?.NormalModuleReplacementPlugin) { + config.plugins.push( + new NormalModuleReplacementPlugin( + // 精确匹配: 解析后的绝对路径是 .../common/js/style_color.js + // 不能匹配 style_color 目录里其它可能存在的同名文件 + /[\\/]common[\\/]js[\\/]style_color\.js$/, + stubPath + ) + ); + console.log(`[vue.config] ✅ style_color.js 已替换为 stub (source=remote-only) — 节省 ~75KB`); + } + } catch (e) { + console.error('[vue.config] 配置 style_color 排除失败:', e.message); + } + } else { + console.log(`[vue.config] style_color.js 保留 (source=${themeSource || 'unknown'})`); + } + if (config.mode === 'production') { console.log('开始尝试配置极限压缩');