feat: 实现隐私弹窗全链路功能,支持本地配置与远程patch融合
- 新增隐私管理器核心逻辑,支持URL路由规则匹配、远程patch签名校验与融合 - 实现启动期资源等待机制,确保弹窗在i18n、主题配置就绪后触发 - 新增启动进度条组件,展示启动阶段进度与状态 - 重构隐私弹窗组件生命周期,修复事件丢失问题并支持规则动态重评估 - 适配多端平台隐私检查逻辑,支持微信小程序、鸿蒙元服务与H5 - 支持uniacid租户隔离的持久化规则存储
This commit is contained in:
304
App.vue
304
App.vue
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import auth from 'common/js/auth.js';
|
||||
import configExternal, { themeConfig } from 'common/js/config-external.js'
|
||||
import configExternal, { themeConfig, privacyConfig } from 'common/js/config-external.js'
|
||||
import Lang from 'common/js/lang.js';
|
||||
import ThemeOverride from 'common/js/theme-override.js';
|
||||
import PrivacyManager from 'common/js/privacy.js';
|
||||
@@ -11,34 +11,83 @@
|
||||
} from 'common/js/wx-jssdk.js';
|
||||
|
||||
// ============================================================
|
||||
// 隐私弹窗跳过规则 (H5 only)
|
||||
// 浏览器直接访问以下 URL(子串匹配 pathname+hash+search)时不弹隐私弹窗
|
||||
// 用于"打开 URL 直接看效果"的场景,避免被隐私弹窗挡住
|
||||
// 注意: 只跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
||||
// 隐私弹窗 URL/路由规则 (H5 + 微信小程序 + 鸿蒙元服务)
|
||||
//
|
||||
// 配置来源 (沿用 config-external.js 同构模式):
|
||||
// 1. 本地兜底: privacyConfig.urlRulesBase (与 langConfig/themeConfig 同构)
|
||||
// 2. 远程 patch: /api/config/init data.privacy_url_rules (由 store/init 融合)
|
||||
// 融合策略 (同上一轮):
|
||||
// - mode: 远程有值则覆盖, 否则用本地
|
||||
// - patterns: 按 patch.merge 字段决定 (replace 默认 / union / intersect)
|
||||
// 匹配: 子串匹配
|
||||
// H5: pathname + search + hash
|
||||
// 小程序/鸿蒙: 当前页面 route + query
|
||||
// 命中白名单后, 仍会走 storage / 平台级隐私检查, 已同意则不再弹
|
||||
// ============================================================
|
||||
const SKIP_PRIVACY_URL_PATTERNS_H5 = [
|
||||
'/pages_tool/form/formdata', // 表单详情:扫码/外链直访,弹窗会挡住表单
|
||||
'/pages_tool/form/form', // 表单详情:扫码/外链直访,弹窗会挡住表单
|
||||
]
|
||||
const PRIVACY_URL_RULES_BASE = privacyConfig.urlRulesBase
|
||||
|
||||
// 启动期配置注入: 把 privacyConfig 的 bootTimeout / waitChannels / signSecret 灌进 PrivacyManager
|
||||
// 这样 PrivacyManager.show() 即便被业务侧独立调用, 也能感知启动期资源就绪状态
|
||||
// 签名密钥注入: 远程 patch 必须通过 verifyRemotePatch() 才允许 applyWithMerge
|
||||
PrivacyManager.setExternalBootTimeout(configExternal.getPrivacyBootTimeout())
|
||||
PrivacyManager.setWaitChannels(privacyConfig.waitChannels || {})
|
||||
PrivacyManager.setSignSecret(privacyConfig.signSecret || '')
|
||||
|
||||
// 登记本地 base 规则, 供后续远程 patch 融合时读取
|
||||
// 必须在 setStorageKey / loadPersistedRules 之前完成, 因为 store/init action
|
||||
// 会通过 PrivacyManager.__getBaseRules() 读取这个 base
|
||||
PrivacyManager.setBaseRules(PRIVACY_URL_RULES_BASE)
|
||||
|
||||
// uniacid 隔离的持久化 key
|
||||
// 关键: 持久化必须基于 uniacid 隔离, 否则多租户切换会读到对方租户的规则
|
||||
// 无 uniacid 时降级到 _anon (使用 localStorage 之外的隔离, 避免与任何具体租户冲突)
|
||||
const _uniacid = uni.getStorageSync('uniacid')
|
||||
const _persistKey = _uniacid
|
||||
? ('privacy_url_rules_' + _uniacid)
|
||||
: 'privacy_url_rules__anon'
|
||||
PrivacyManager.setStorageKey(_persistKey)
|
||||
|
||||
// 1) 先尝试恢复上次的"已融合规则" (跨冷启动)
|
||||
// 无持久化值 → 退化为 base
|
||||
const _persisted = PrivacyManager.loadPersistedRules()
|
||||
if (_persisted && _persisted.mode && Array.isArray(_persisted.patterns)) {
|
||||
PrivacyManager.setUrlRules(_persisted)
|
||||
} else {
|
||||
// 2) 用本地 base 兜底
|
||||
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
|
||||
}
|
||||
|
||||
// 业务侧可监听: 远程下发完成后, 会触发 'privacy:rules-updated'
|
||||
uni.$on && uni.$on('privacy:rules-updated', (info) => {
|
||||
// 只在远程下发时打印, 避免被本地 base 加载/持久化恢复时的 emit 噪音淹没
|
||||
if (info && info.source === 'remote' && console && console.log) {
|
||||
console.log('[privacy] rules updated by remote, mode=' + info.mode + ', patterns=' + info.patterns.length)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断当前 H5 URL 是否命中"跳过隐私弹窗"黑名单
|
||||
* @returns {boolean}
|
||||
* 取 onLaunch options 作为首次启动的兜底路径
|
||||
* H5: options.path/query 不一定存在, 此时用 window.location
|
||||
* 小程序/鸿蒙: options.path 是启动路径, options.query 是启动参数
|
||||
* @param {Object} launchOptions onLaunch 的 options
|
||||
* @returns {Object} urlFallback
|
||||
*/
|
||||
function _shouldSkipPrivacyForCurrentUrl() {
|
||||
function _buildPrivacyUrlFallback(launchOptions) {
|
||||
const fallback = { pathname: '', search: '', hash: '', route: '', query: null }
|
||||
// #ifdef H5
|
||||
if (typeof window === 'undefined' || !window.location) return false
|
||||
const patterns = SKIP_PRIVACY_URL_PATTERNS_H5
|
||||
if (!patterns || !patterns.length) return false
|
||||
// 覆盖 history 模式和 hash 模式: pathname + search + hash 都拼上
|
||||
const path = (window.location.pathname || '')
|
||||
+ (window.location.search || '')
|
||||
+ (window.location.hash || '')
|
||||
return patterns.some(p => path.indexOf(p) !== -1)
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
fallback.pathname = window.location.pathname || ''
|
||||
fallback.search = window.location.search || ''
|
||||
fallback.hash = window.location.hash || ''
|
||||
}
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return false
|
||||
if (launchOptions && launchOptions.path) {
|
||||
fallback.route = launchOptions.path
|
||||
fallback.query = launchOptions.query || null
|
||||
}
|
||||
// #endif
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -56,11 +105,9 @@
|
||||
// 必须在 onLaunch (async) 之前跑,否则 onLaunch 第一个 await 让出事件循环时
|
||||
// privacy-popup.vue 已经挂载,会走 consumePendingShow 兜底分支把弹窗弹出来
|
||||
created() {
|
||||
// #ifdef H5
|
||||
if (_shouldSkipPrivacyForCurrentUrl()) {
|
||||
if (!PrivacyManager.shouldShowByUrlRule()) {
|
||||
PrivacyManager.markSkipped()
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
onLaunch: async function(options) {
|
||||
// ============================================================
|
||||
@@ -82,77 +129,171 @@
|
||||
if (options.query && options.query.uniacid) {
|
||||
uni.setStorageSync('uniacid', options.query.uniacid);
|
||||
}
|
||||
// 关键: 即便 uniacid 未变化 (首次启动 options.query 没传 uniacid),
|
||||
// 也要尝试从 storage 拿真实 uniacid 并设置 PrivacyManager 的 persist key
|
||||
// 否则 App.vue 顶层用 _anon 占位, 后续 persistRules 写到 _anon key 下,
|
||||
// 下次冷启动 (有 uniacid) 切到 uniacid key 时读不到上次结果
|
||||
try {
|
||||
const _nowUniacid = uni.getStorageSync('uniacid')
|
||||
const _newPersistKey = _nowUniacid
|
||||
? ('privacy_url_rules_' + _nowUniacid)
|
||||
: 'privacy_url_rules__anon'
|
||||
if (_newPersistKey !== _persistKey) {
|
||||
PrivacyManager.setStorageKey(_newPersistKey)
|
||||
const _newPersisted = PrivacyManager.loadPersistedRules()
|
||||
if (_newPersisted && _newPersisted.mode && Array.isArray(_newPersisted.patterns)) {
|
||||
PrivacyManager.setUrlRules(_newPersisted)
|
||||
} else {
|
||||
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
if (uniacidChanged) {
|
||||
try { Lang.onUniacidChange(); } catch (e) {}
|
||||
try { ThemeOverride.onUniacidChange(); } catch (e) {}
|
||||
// 切租户: 切换 PrivacyManager 持久化 key 并重新应用本地 base
|
||||
// (远程 patch 由后续 /api/config/init 重新下发并融合)
|
||||
try {
|
||||
const newUniacid = String(options.query.uniacid)
|
||||
const newPersistKey = newUniacid
|
||||
? ('privacy_url_rules_' + newUniacid)
|
||||
: 'privacy_url_rules__anon'
|
||||
PrivacyManager.setStorageKey(newPersistKey)
|
||||
PrivacyManager.setBaseRules(PRIVACY_URL_RULES_BASE)
|
||||
// 切租户: 重新加载该租户上次持久化的规则
|
||||
// 无持久化值则退化为 base
|
||||
const _newPersisted = PrivacyManager.loadPersistedRules()
|
||||
if (_newPersisted && _newPersisted.mode && Array.isArray(_newPersisted.patterns)) {
|
||||
PrivacyManager.setUrlRules(_newPersisted)
|
||||
} else {
|
||||
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
|
||||
}
|
||||
} catch (e) {}
|
||||
try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {}
|
||||
}
|
||||
|
||||
// remote-only 模式:
|
||||
// H5: main.js 已 await boot 完成, 这里秒回 (splash 也由 main.js 全权负责)
|
||||
// 小程序/鸿蒙: main.js 同步 $mount(), boot 在这里异步执行
|
||||
// → splashState.show() 同步设置 visible 标志, 框架推页面后 splash 组件挂载时读取 isShow() 渲染
|
||||
// 小程序/鸿蒙: main.js 同步 $mount() 之前已经调用 splashState.show(),
|
||||
// 这里不需要重复调用
|
||||
const langBoot = Lang.boot();
|
||||
this.$langReady = langBoot;
|
||||
// #ifndef H5
|
||||
const _needSplash = Lang.isRemoteOnly && Lang.isRemoteOnly();
|
||||
if (_needSplash) {
|
||||
splashState.show();
|
||||
|
||||
// ---- 2) 启动期关键资源就绪 (并行 + 显式 await)
|
||||
// 隐私弹窗的诉求: 必须在 i18n + theme + remote-config 三态都就绪后才弹
|
||||
// 这里把三个通道收敛为一个 await 链, 不再用 fire-and-forget
|
||||
// 任何通道自身有超时保护 (Lang.bootTimeout=langConfig.remote.bootTimeout=5000,
|
||||
// ThemeOverride.bootTimeout=themeConfig.overrideRemote.bootTimeout=5000)
|
||||
// 总超时熔断走 configExternal.getPrivacyBootTimeout() (默认取两者最大值+1000ms)
|
||||
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大 privacyConfig.bootTimeout
|
||||
// 业务侧想"快速失败" → 调小或关闭 privacyConfig.waitChannels
|
||||
|
||||
const _i18nDone = langBoot.then((res) => {
|
||||
// 进度上报: i18n 完成 → 30%
|
||||
try { splashState.reportProgress('lang', { source: 'channel' }); } catch (e) {}
|
||||
// i18n fallback toast (原逻辑保留)
|
||||
if (res && res.status === 'fail' && res.reason && res.reason !== 'unknown') {
|
||||
if (globalThis.__i18nFallbackToastShown) return res;
|
||||
globalThis.__i18nFallbackToastShown = true;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
uni.showToast({
|
||||
title: `Failed to load i18n. ${res.reason} Please check your network and try again.`,
|
||||
icon: 'none',
|
||||
duration: 10000,
|
||||
});
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
return res;
|
||||
}, (e) => {
|
||||
console.warn('[i18n] boot fail', e);
|
||||
try { splashState.reportProgress('lang', { source: 'error' }); } catch (e) {}
|
||||
return { ok: false, source: 'remote-failed', status: 'fail', reason: 'exception', lang: '' };
|
||||
})
|
||||
|
||||
const _themeFirstApply = this.$store.dispatch('applyThemeWithRemote', presetName)
|
||||
.then(() => {
|
||||
try { splashState.reportProgress('theme', { source: 'channel' }); } catch (e) {}
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn('[theme] pre-apply fail', e);
|
||||
try { splashState.reportProgress('theme', { source: 'error' }); } catch (e) {}
|
||||
return null
|
||||
})
|
||||
|
||||
const _themeOverride = ThemeOverride.boot()
|
||||
.then(() => {
|
||||
try { splashState.reportProgress('override', { source: 'channel' }); } catch (e) {}
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn('[theme] boot fail', e);
|
||||
try { splashState.reportProgress('override', { source: 'error' }); } catch (e) {}
|
||||
return null
|
||||
})
|
||||
|
||||
// /api/config/init 走 fire-and-forget:
|
||||
// - race 不再等它, popup 可以更早弹出
|
||||
// - 远程 patch (privacy_url_rules 等) 后续异步到达时通过 applyWithMerge 触发双阶段弹窗
|
||||
// - 失败也不阻塞 boot 链路
|
||||
//
|
||||
// 进度上报单独走 (展示效果, 不依赖 _remoteConfig resolve)
|
||||
this.$store.dispatch('init').then(() => {
|
||||
try { splashState.reportProgress('config', { source: 'channel' }); } catch (e) {}
|
||||
}).catch(e => {
|
||||
console.warn('[config] init fail', e);
|
||||
try { splashState.reportProgress('config', { source: 'error' }); } catch (e) {}
|
||||
})
|
||||
const _remoteConfig = Promise.resolve(null) // 占位 promise, race 不再等 init
|
||||
|
||||
// 注入启动期等待通道 → ConfigExternal 持有
|
||||
// PrivacyManager.show() 时通过 getPrivacyReadyGuard(waitChannels) 自动拼成 readyGuard
|
||||
configExternal.setReadyWaiters({
|
||||
lang: _i18nDone,
|
||||
theme: _themeFirstApply,
|
||||
themeOverride: _themeOverride,
|
||||
remoteConfig: _remoteConfig,
|
||||
})
|
||||
|
||||
const _privacyBootTimeout = configExternal.getPrivacyBootTimeout()
|
||||
const _bootRaceResult = await Promise.race([
|
||||
Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
||||
new Promise(resolve => setTimeout(() => resolve('timeout'), _privacyBootTimeout))
|
||||
])
|
||||
if (_bootRaceResult === 'timeout') {
|
||||
// race 超时上报 (source: 'timeout', 不允许 percent 后退, 已有最大值为准)
|
||||
try { splashState.reportProgress('config', { percent: 80, source: 'timeout' }); } catch (e) {}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ---- 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 — 直接根据返回值判断 toast,不走 lang:fallback 事件
|
||||
// 为什么不走事件:
|
||||
// 1) lang:fallback 受 setRemoteStatus 内部去重影响(fail→fail 不 emit),不可靠
|
||||
// 2) 监听器注册时机晚于 boot 完成,首次失败收不到
|
||||
// 3) boot() 返回值已经包含了 status/reason,直接判断更清晰
|
||||
// 触发条件:
|
||||
// - status === 'fail': 远端拉取失败(包含"无缓存兜底"和"有缓存兜底"两种)
|
||||
// - reason !== 'unknown': 排除业务主动设置的未知错误
|
||||
// 防刷: 进程内全局标志,只弹一次
|
||||
// 延迟弹出: uni.showToast 会覆盖当前 toast,1500ms ≈ 默认 toast duration,
|
||||
// 等启动期其他 toast / 模态弹窗先消失再弹,降低被覆盖风险
|
||||
langBoot.then((res) => {
|
||||
if (res && res.status === 'fail' && res.reason && res.reason !== 'unknown') {
|
||||
if (globalThis.__i18nFallbackToastShown) return;
|
||||
globalThis.__i18nFallbackToastShown = true;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
uni.showToast({
|
||||
title: `Failed to load i18n. ${res.reason} Please check your network and try again.`,
|
||||
icon: 'none',
|
||||
duration: 10000,
|
||||
});
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
}, (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)),
|
||||
]);
|
||||
// privacy 阶段开始上报
|
||||
try { splashState.reportProgress('privacy', { source: 'channel' }); } catch (e) {}
|
||||
|
||||
// #ifndef H5
|
||||
if (_needSplash) {
|
||||
splashState.hide({ reason: 'boot-complete' });
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 隐私协议弹窗: boot 完成后统一触发,确保翻译数据已就绪
|
||||
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
|
||||
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
|
||||
// 只弹一次: 同意后写 storage,后续不再弹
|
||||
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
|
||||
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
||||
try { await PrivacyManager.show({ skip: _shouldSkipPrivacyForCurrentUrl() }); } catch (e) { console.warn('[privacy] show fail', e); }
|
||||
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
|
||||
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
|
||||
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
|
||||
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
|
||||
try {
|
||||
await PrivacyManager.show({
|
||||
urlFallback: _buildPrivacyUrlFallback(options),
|
||||
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
||||
})
|
||||
} catch (e) { console.warn('[privacy] show fail', e); }
|
||||
|
||||
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
|
||||
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
|
||||
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
|
||||
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
|
||||
// #ifndef H5
|
||||
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
|
||||
if (_needSplash) {
|
||||
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 )
|
||||
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
|
||||
@@ -231,10 +372,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
// /api/config/init 拉服务器侧数据(包含真正该用的 preset name)
|
||||
// 内部: success 回调里再次 dispatch('applyThemeWithRemote', serverPreset)
|
||||
// fire-and-forget: 不阻塞后续同步 commit
|
||||
this.$store.dispatch('init');
|
||||
// /api/config/init 已在 step 2.5 await 过 (含 800ms 超时), 此处不再重复触发
|
||||
// 原 fire-and-forget 注释: 不阻塞后续同步 commit
|
||||
// 现在 init 已被升级为 "首次配置等待通道", 这里留空以避免重复 dispatch
|
||||
|
||||
// 插件是否存在
|
||||
if (uni.getStorageSync('addonIsExist')) {
|
||||
|
||||
@@ -94,11 +94,130 @@ export const apiConfig = {
|
||||
retryDelay: 1000
|
||||
};
|
||||
|
||||
// 隐私弹窗配置 (与 langConfig/themeConfig 同构)
|
||||
// 本地兜底 + 远程 patch (来自 /api/config/init data.privacy_url_rules) 融合
|
||||
// source: 'local' - 仅本地兜底, 忽略远程 patch
|
||||
// 'remote' - 本地兜底 + 远程 patch 覆盖 (默认)
|
||||
// 'hybrid' - 同 remote, 远程失败时退回本地
|
||||
// 'remote-only' - 完全依赖远程 patch, 本地兜底为空
|
||||
export const privacyConfig = {
|
||||
// 本地兜底规则 (mode: 'all'|'only'|'skip', patterns: string[])
|
||||
// 远程 patch 由 store/index.js 的 init action 通过 PrivacyManager.applyWithMerge() 融合
|
||||
// patterns 写法:
|
||||
// - 子串匹配 + 结尾匹配 (双通道), 业务侧可以写完整路径或相对路径
|
||||
// - subPackage 路由 (如 /pages_tool/member/index) 用短路径 'member/index' 即可命中
|
||||
urlRulesBase: {
|
||||
// 场景 2: 只有首页、商品详情、个人中心才弹, 其他页面都不弹
|
||||
mode: 'only',
|
||||
patterns: [
|
||||
// 首页 (含 subPackage 重定向后也是 pages/index/index)
|
||||
'pages/index/index',
|
||||
// 商品详情 (subPackage 内可能为 pages_goods/goods/detail/detail, 这里用末尾匹配)
|
||||
'goods/detail/detail',
|
||||
// 个人中心 (subPackage 内通常为 pages_tool/member/index)
|
||||
'member/index',
|
||||
],
|
||||
},
|
||||
// dataSource: 与 langConfig.source 同构
|
||||
// 'local' 纯本地, 忽略 /api/config/init 的 privacy_url_rules
|
||||
// 'remote' 本地 + 远程 patch (远程失败时仍用本地)
|
||||
// 'hybrid' 同 remote, 语义别名
|
||||
// 'remote-only' 必须有远程 patch, 本地空 patterns → 一律不弹
|
||||
source: 'remote',
|
||||
// 启动期等待通道 (弹窗必须等这些 promise 才弹)
|
||||
// waitLang: Lang.boot() - i18n 数据就绪
|
||||
// waitTheme: applyThemeWithRemote() - 本地 preset 应用
|
||||
// waitThemeOverride: ThemeOverride.boot() - brand/light/dark 三桶 token
|
||||
// waitRemoteConfig: /api/config/init (含 privacy_url_rules 远程 patch)
|
||||
waitChannels: {
|
||||
lang: true,
|
||||
theme: true,
|
||||
themeOverride: true,
|
||||
remoteConfig: true,
|
||||
},
|
||||
// 总超时熔断 (ms): 所有等待通道超过这个时间强制放行, 不阻塞弹窗
|
||||
// 取 langConfig.remote.bootTimeout (5000) 和 themeConfig.overrideRemote.bootTimeout (5000) 的最大值
|
||||
// + 1000ms 余量 → 6000ms. 与原硬编码值一致, 但可通过修改此字段调整.
|
||||
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大; 想"快速失败" → 调小.
|
||||
bootTimeout: 6000,
|
||||
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||||
// 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||||
// secret='' → 跳过校验, 接受任意 patch (向后兼容老后端)
|
||||
// secret='xxx' → 校验 patch.sign, 不匹配则拒绝
|
||||
// 留空默认不校验. 前端实现是 btoa 弱校验, 仅防简单篡改, 强校验建议用 crypto-js.
|
||||
signSecret: '',
|
||||
// 单次 /api/config/init 请求超时 (ms)
|
||||
// 复用 apiConfig.timeout, 不单独配置
|
||||
// remote: {
|
||||
// // 远程 patch 字段名 (后端 /api/config/init 返回 data.privacy_url_rules)
|
||||
// fieldPath: 'privacy_url_rules',
|
||||
// // 切租户/uniacid 时强制重拉 (远端 patch 重新下发)
|
||||
// refreshOnUniacidChange: true,
|
||||
// // 本地缓存 key 前缀: <prefix><uniacid>
|
||||
// storagePrefix: 'privacy_url_rules_',
|
||||
// },
|
||||
};
|
||||
|
||||
// 配置外置管理类
|
||||
class ConfigExternal {
|
||||
constructor() {
|
||||
this.loadedConfigs = {};
|
||||
this.loadPromises = {};
|
||||
// 启动期各通道的 await promise (由 App.vue onLaunch 注入)
|
||||
// 隐私弹窗 show() 时通过 PrivacyManager.getReadyWaiters() 拿到 → 串成 readyGuard
|
||||
this.readyWaiters = {
|
||||
lang: null,
|
||||
theme: null,
|
||||
themeOverride: null,
|
||||
remoteConfig: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置启动期各通道的 await promise
|
||||
* App.vue onLaunch 在 step 2 注入, PrivacyManager.show() 时读取
|
||||
* @param {Object} waiters { lang?, theme?, themeOverride?, remoteConfig? }
|
||||
*/
|
||||
setReadyWaiters(waiters) {
|
||||
if (!waiters || typeof waiters !== 'object') return
|
||||
for (const k of Object.keys(this.readyWaiters)) {
|
||||
if (waiters[k] && typeof waiters[k].then === 'function') {
|
||||
this.readyWaiters[k] = waiters[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取隐私弹窗 readyGuard (合并所有启用的等待通道)
|
||||
* 任意通道 disabled → 不等
|
||||
* 所有通道都 null → 返回 Promise.resolve() (不退化)
|
||||
* @param {Object} channels { lang?, theme?, themeOverride?, remoteConfig? }
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getPrivacyReadyGuard(channels) {
|
||||
const cfg = (typeof channels === 'object' && channels) || (privacyConfig.waitChannels || {})
|
||||
const promises = []
|
||||
for (const k of ['lang', 'theme', 'themeOverride', 'remoteConfig']) {
|
||||
if (cfg[k] && this.readyWaiters[k]) {
|
||||
promises.push(this.readyWaiters[k])
|
||||
}
|
||||
}
|
||||
if (!promises.length) return Promise.resolve()
|
||||
return Promise.all(promises).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐私弹窗总超时熔断 (ms)
|
||||
* 默认取 langConfig.remote.bootTimeout 与 themeConfig.overrideRemote.bootTimeout 的最大值 + 1000ms 余量
|
||||
* 业务侧可通过 privacyConfig.bootTimeout 覆盖
|
||||
*/
|
||||
getPrivacyBootTimeout() {
|
||||
if (privacyConfig && typeof privacyConfig.bootTimeout === 'number' && privacyConfig.bootTimeout > 0) {
|
||||
return privacyConfig.bootTimeout
|
||||
}
|
||||
const langT = (langConfig && langConfig.remote && langConfig.remote.bootTimeout) || 5000
|
||||
const themeT = (themeConfig && themeConfig.overrideRemote && themeConfig.overrideRemote.bootTimeout) || 5000
|
||||
return Math.max(langT, themeT) + 1000
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* uni.$emit('privacy:agreed') — 用户已同意
|
||||
* uni.$emit('privacy:disagreed') — 用户已拒绝
|
||||
* uni.$emit('privacy:closeOthers') — 通知其他页面关闭弹窗
|
||||
* uni.$emit('privacy:rules-updated') — URL/路由规则被应用/融合后触发 (远程下发后热更新)
|
||||
*
|
||||
* 使用:
|
||||
* App.vue onLaunch (boot完成后): await PrivacyManager.show()
|
||||
@@ -28,14 +29,51 @@
|
||||
|
||||
const STORAGE_KEY = 'privacyAgreed';
|
||||
|
||||
// 缓存上一次 getPrivacySetting 的结果, 避免每个组件 mounted 都重新检查
|
||||
let _lastCheckResult = null;
|
||||
let _lastCheckTs = 0;
|
||||
|
||||
// 内部状态
|
||||
let _needShow = false; // 是否需要弹窗
|
||||
let _hasChecked = false; // 是否已执行过 check
|
||||
let _pendingShow = false; // emit 时无监听者的补偿标志
|
||||
// URL/路由规则已经做过决策 (避免鸿蒙兜底在白名单模式下错误弹窗)
|
||||
let _urlRuleDecided = false;
|
||||
|
||||
// ============================================================
|
||||
// 弹窗"实际显示"状态 (由 privacy-popup 通过 privacy:popup-state 事件同步)
|
||||
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
|
||||
// 双阶段弹窗重评估要用这个真实状态判断
|
||||
let _popupActuallyShowing = false
|
||||
|
||||
// ============================================================
|
||||
/**
|
||||
* 同步读取 storage + 平台决策 (H5 + 不依赖 storage 的情况)
|
||||
* 用于"已同意过"的快速路径, 不走 async checkPrivacy
|
||||
* @returns {{needAuthorization: boolean, fromCache: boolean}}
|
||||
*/
|
||||
function checkPrivacySync() {
|
||||
// H5 + 其他非微信/快应用/鸿蒙平台: storage 决定一切
|
||||
const storageAgreed = getStorageAgreed()
|
||||
return {
|
||||
needAuthorization: !storageAgreed,
|
||||
fromCache: storageAgreed,
|
||||
}
|
||||
}
|
||||
|
||||
// URL/路由级别隐私弹窗规则
|
||||
// mode:
|
||||
// 'all' - 始终走弹窗检查(默认, 向后兼容)
|
||||
// 'only' - 白名单: 只有命中 patterns 的页面才走弹窗检查, 其他场景一律跳过
|
||||
// 'skip' - 黑名单: 命中 patterns 的页面跳过弹窗, 其他场景走弹窗检查
|
||||
// 匹配方式: 子串匹配
|
||||
// H5: pathname + search + hash
|
||||
// 小程序/鸿蒙: 当前页面 route + query
|
||||
// patterns 为空数组时:
|
||||
// 'all' → 无规则, 走弹窗检查
|
||||
// 'only' → 视为"没有需要弹窗的页面", 一律跳过
|
||||
// 'skip' → 视为"没有需要跳过的页面", 全部走弹窗检查
|
||||
// ============================================================
|
||||
const PRIVACY_URL_RULES = {
|
||||
mode: 'all',
|
||||
patterns: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 storage 中的同意状态
|
||||
@@ -132,27 +170,475 @@ function getHarmonyPrivacySetting() {
|
||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||
*/
|
||||
async function checkPrivacy() {
|
||||
// 微信小程序
|
||||
// 微信小程序: 调 wx.getPrivacySetting
|
||||
// 注意: uniapp 不支持 #ifndef 多 || 与嵌套 #ifndef,
|
||||
// 改用回退策略 - 仅微信走异步 API, 其他平台走 sync checkPrivacySync()
|
||||
// 调用方 (show) 已经对 storage 已同意做了 sync 短路 (参见 checkPrivacySync()),
|
||||
// 这里只处理 storage 未同意情况下的 async API 调用。
|
||||
// #ifdef MP-WEIXIN
|
||||
return await getWxPrivacySetting();
|
||||
if (typeof wx !== 'undefined' && wx.getPrivacySetting) {
|
||||
return await getWxPrivacySetting();
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 快应用
|
||||
// 快应用: 调 has.getPrivacySetting
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
return await getQuickAppPrivacySetting();
|
||||
// #endif
|
||||
|
||||
// 鸿蒙元服务
|
||||
// #ifdef MP-HARMONY
|
||||
return await getHarmonyPrivacySetting();
|
||||
if (typeof has !== 'undefined' && has.getPrivacySetting) {
|
||||
return await getQuickAppPrivacySetting();
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
|
||||
// H5 / 其他平台: 仅查 storage
|
||||
// #ifndef MP-WEIXIN || QUICKAPP-WEBVIEW || MP-HARMONY
|
||||
return { needAuthorization: !getStorageAgreed() };
|
||||
return checkPrivacySync();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前页面的"路径字符串"
|
||||
* H5: location.pathname + search + hash
|
||||
* 微信小程序: 当前页面 route + query (例: pages/index/index?id=1)
|
||||
* 鸿蒙元服务: 当前页面 route + query (同小程序)
|
||||
* @param {Object} [fallback] - 兜底路径, 用于 onLaunch 等还没 getCurrentPages 的时机
|
||||
* H5: { pathname, search, hash }
|
||||
* 小程序/鸿蒙: { route, query }
|
||||
* @returns {string}
|
||||
*/
|
||||
function getCurrentPathString(fallback) {
|
||||
// H5
|
||||
// #ifdef H5
|
||||
// H5 模式下优先用 getCurrentPages() 拿 uniapp route (history/hash 都适配)
|
||||
// 兜底: window.location.pathname + search + hash
|
||||
try {
|
||||
if (typeof getCurrentPages === 'function') {
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages[pages.length - 1]
|
||||
if (cur && cur.route) {
|
||||
const route = cur.route
|
||||
const query = cur.options
|
||||
let queryStr = ''
|
||||
if (query && typeof query === 'object' && Object.keys(query).length) {
|
||||
queryStr = '?' + Object.keys(query)
|
||||
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
|
||||
.map(k => k + '=' + encodeURIComponent(query[k]))
|
||||
.join('&')
|
||||
}
|
||||
return '/' + String(route).replace(/^\/+/, '') + queryStr
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore, fall through to location */ }
|
||||
// 兜底: window.location (含 history 路由 + base)
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
const pathname = (fallback && fallback.pathname) || window.location.pathname || ''
|
||||
const search = (fallback && fallback.search !== undefined ? fallback.search : window.location.search) || ''
|
||||
const hash = (fallback && fallback.hash !== undefined ? fallback.hash : window.location.hash) || ''
|
||||
return pathname + search + hash
|
||||
}
|
||||
return ''
|
||||
// #endif
|
||||
|
||||
// 微信小程序 / 鸿蒙元服务 / 其他支持 getCurrentPages 的端
|
||||
// #ifndef H5
|
||||
try {
|
||||
const pages = (typeof getCurrentPages === 'function') ? getCurrentPages() : []
|
||||
let route = ''
|
||||
let query = null
|
||||
const cur = pages[pages.length - 1]
|
||||
if (cur) {
|
||||
route = cur.route || ''
|
||||
query = cur.options
|
||||
}
|
||||
// 兜底: onLaunch 时 getCurrentPages 还没页面, 用 fallback
|
||||
if (!route && fallback) {
|
||||
route = fallback.route || ''
|
||||
query = fallback.query
|
||||
}
|
||||
if (!route) return ''
|
||||
let queryStr = ''
|
||||
if (query && typeof query === 'object' && Object.keys(query).length) {
|
||||
queryStr = '?' + Object.keys(query)
|
||||
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
|
||||
.map(k => k + '=' + encodeURIComponent(query[k]))
|
||||
.join('&')
|
||||
}
|
||||
return '/' + String(route).replace(/^\/+/, '') + queryStr
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 path 是否命中 patterns 之一
|
||||
* 匹配规则 (双通道, 任一命中即算命中):
|
||||
* 1. 子串匹配: path.indexOf(p) !== -1 (兼容 /pages/index/index 这种完整路径)
|
||||
* 2. 结尾匹配: path 以 p 结尾 (兼容 subPackage 路径 /pages_tool/member/index 命中 "member/index")
|
||||
* 这样业务侧既可以写完整路径也可以写相对路径
|
||||
*/
|
||||
function matchAny(path, patterns) {
|
||||
if (!path || !Array.isArray(patterns)) return false
|
||||
return patterns.some((p) => {
|
||||
if (!p || typeof p !== 'string') return false
|
||||
return path.indexOf(p) !== -1 || path.endsWith(p)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 PRIVACY_URL_RULES 判断当前 URL/路由是否要走弹窗检查
|
||||
* @param {Object} [fallback] - 传给 getCurrentPathString 的兜底路径
|
||||
* @returns {boolean} true=本次应走弹窗检查, false=本次跳过
|
||||
*/
|
||||
function shouldShowByUrlRule(fallback) {
|
||||
const mode = PRIVACY_URL_RULES.mode || 'all'
|
||||
const patterns = PRIVACY_URL_RULES.patterns || []
|
||||
if (!Array.isArray(patterns) || !patterns.length) {
|
||||
// 空 patterns 时:
|
||||
// 'all' → 无规则, 走检查
|
||||
// 'only' → 没有需要弹窗的页面, 一律跳过
|
||||
// 'skip' → 没有需要跳过的页面, 全部走检查
|
||||
return mode === 'all' || mode === 'skip'
|
||||
}
|
||||
const path = getCurrentPathString(fallback)
|
||||
const hit = matchAny(path, patterns)
|
||||
if (mode === 'only') return hit
|
||||
if (mode === 'skip') return !hit
|
||||
return true // mode === 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖 URL/路由级别隐私弹窗规则
|
||||
* @param {Object} rules
|
||||
* @param {'all'|'only'|'skip'} [rules.mode='all']
|
||||
* @param {string[]} [rules.patterns=[]]
|
||||
*/
|
||||
function setUrlRules(rules) {
|
||||
if (!rules || typeof rules !== 'object') return
|
||||
if (rules.mode && ['all', 'only', 'skip'].indexOf(rules.mode) !== -1) {
|
||||
PRIVACY_URL_RULES.mode = rules.mode
|
||||
}
|
||||
if (Array.isArray(rules.patterns)) {
|
||||
PRIVACY_URL_RULES.patterns = rules.patterns.slice()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化键 (与 privacyAgreed 同维度, 方便 uniacid 隔离)
|
||||
* 由 App.vue 在 setUrlRules 前注入前缀: privacy_url_rules_<uniacid>
|
||||
*/
|
||||
let _storageKey = 'privacy_url_rules'
|
||||
|
||||
/**
|
||||
* 从 storage 读取上次持久化的最终规则
|
||||
* 自动过滤 __merge/__source/__ts 元信息, 仅返回纯规则
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function loadPersistedRules() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(_storageKey)
|
||||
if (!raw) return null
|
||||
const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw
|
||||
if (!obj || typeof obj !== 'object') return null
|
||||
// 过滤元信息
|
||||
return {
|
||||
mode: obj.mode,
|
||||
patterns: Array.isArray(obj.patterns) ? obj.patterns.slice() : [],
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化当前最终规则
|
||||
* 携带 merge 字段 + 时间戳 + 签名, 方便后续热修复/审计/防篡改
|
||||
* @param {Object} rules
|
||||
* @param {Object} [meta] - { merge?: string, source?: string, sign?: string }
|
||||
*/
|
||||
function persistRules(rules, meta) {
|
||||
try {
|
||||
const final = Object.assign({}, rules || PRIVACY_URL_RULES, {
|
||||
__merge: (meta && meta.merge) || (rules && rules.__merge) || 'replace',
|
||||
__source: (meta && meta.source) || 'base',
|
||||
__ts: Date.now(),
|
||||
__sign: (meta && meta.sign) || (rules && rules.__sign) || '',
|
||||
})
|
||||
uni.setStorageSync(_storageKey, final)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算远程 patch 的签名 (前端简易实现, 与后端 hash_hmac 对齐)
|
||||
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||||
* 前端: 用 btoa 或 uni.arrayBufferToBase64 模拟 (弱校验, 防简单篡改)
|
||||
* 真正的强校验建议用 crypto-js (引入成本)
|
||||
* @param {Object} patch - 远程下发对象
|
||||
* @param {string} secret
|
||||
* @returns {string}
|
||||
*/
|
||||
function computeSign(patch, secret) {
|
||||
if (!secret || !patch) return ''
|
||||
try {
|
||||
const payload = JSON.stringify(patch)
|
||||
let _b64 = ''
|
||||
if (typeof btoa === 'function') {
|
||||
_b64 = btoa(payload)
|
||||
} else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
const _encoder = (typeof TextEncoder !== 'undefined') ? new TextEncoder() : null
|
||||
if (_encoder) {
|
||||
_b64 = uni.arrayBufferToBase64(_encoder.encode(payload).buffer)
|
||||
}
|
||||
}
|
||||
if (!_b64) return ''
|
||||
// 取前 32 位作为 signature (与 lang-loader.js validateSign 保持一致)
|
||||
return _b64.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验远程 patch
|
||||
* - signSecret 未配置 → 通过 (向后兼容)
|
||||
* - patch.sign 缺失 → 通过 (后端未启用签名)
|
||||
* - patch.sign 不匹配 → 拒绝 (返回 false)
|
||||
* @param {Object} patch - 远程下发 { mode, patterns, merge, sign? }
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyRemotePatch(patch) {
|
||||
if (!patch || typeof patch !== 'object') return false
|
||||
// 业务侧未配置密钥 → 跳过校验
|
||||
if (!_signSecret) return true
|
||||
// patch 未带 sign 字段 → 后端未启用签名, 跳过校验 (兼容旧版本后端)
|
||||
if (!patch.sign) return true
|
||||
const expected = computeSign(patch, _signSecret)
|
||||
const ok = (expected && expected === patch.sign)
|
||||
if (!ok) {
|
||||
console.warn('[privacy] remote patch sign invalid', {
|
||||
expected: expected.slice(0, 8),
|
||||
received: String(patch.sign).slice(0, 8),
|
||||
})
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并本地 base 与远程 patch
|
||||
* mode:
|
||||
* patch.mode 缺失/非合法值 → 用 base.mode
|
||||
* patch.mode 合法 → 用 patch.mode (远程覆盖)
|
||||
* patterns:
|
||||
* merge = 'replace' → 用 patch.patterns (远程整体替换, 默认)
|
||||
* merge = 'union' → base + patch 去重合并 (子串匹配: 较长的子串已包含较短, 保留较长)
|
||||
* merge = 'intersect'→ 仅保留同时出现在 base 和 patch 的项
|
||||
* merge 缺失/未知 → 退化为 'replace'
|
||||
* patch.patterns 缺失/非数组 → 用 base.patterns
|
||||
*
|
||||
* @param {Object} base - 本地声明的基础规则 { mode, patterns }
|
||||
* @param {Object} patch - 远程下发的补丁 { mode?, patterns?, merge? }
|
||||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||||
*/
|
||||
function mergeRules(base, patch) {
|
||||
const baseMode = (base && base.mode) || 'all'
|
||||
const basePatterns = (base && Array.isArray(base.patterns)) ? base.patterns.slice() : []
|
||||
|
||||
if (!patch || typeof patch !== 'object') {
|
||||
return { mode: baseMode, patterns: basePatterns, merge: 'replace' }
|
||||
}
|
||||
|
||||
// mode: 远程值合法则覆盖
|
||||
let mode = baseMode
|
||||
if (patch.mode && ['all', 'only', 'skip'].indexOf(patch.mode) !== -1) {
|
||||
mode = patch.mode
|
||||
}
|
||||
|
||||
// patterns + merge
|
||||
const merge = (patch.merge && ['replace', 'union', 'intersect'].indexOf(patch.merge) !== -1)
|
||||
? patch.merge
|
||||
: 'replace'
|
||||
|
||||
let patterns
|
||||
if (!Array.isArray(patch.patterns)) {
|
||||
patterns = basePatterns
|
||||
} else if (merge === 'replace') {
|
||||
patterns = patch.patterns.slice()
|
||||
} else if (merge === 'union') {
|
||||
patterns = dedupeUnion(basePatterns, patch.patterns)
|
||||
} else { // intersect
|
||||
patterns = basePatterns.filter(p => patch.patterns.indexOf(p) !== -1)
|
||||
}
|
||||
|
||||
return { mode, patterns, merge }
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重合并 (子串匹配: 若 a 包含 b, b 可视为冗余)
|
||||
* 简单实现: 直接 concat 后去重, 复杂度可接受 (规则通常 < 50 条)
|
||||
*/
|
||||
function dedupeUnion(a, b) {
|
||||
const all = (a || []).concat(b || [])
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const p of all) {
|
||||
if (typeof p !== 'string' || !p) continue
|
||||
if (seen.has(p)) continue
|
||||
seen.add(p)
|
||||
out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用"已合并规则"到 PRIVACY_URL_RULES
|
||||
* 触发 'privacy:rules-updated' 事件, 业务侧可监听 (例如想热更新弹窗)
|
||||
* @param {Object} rules
|
||||
* @param {Object} [meta] - { source: 'base'|'remote'|'persisted', patch?: Object }
|
||||
*/
|
||||
function applyMergedRules(rules, meta) {
|
||||
if (!rules || typeof rules !== 'object') return
|
||||
const prevMode = PRIVACY_URL_RULES.mode
|
||||
const prevPatterns = PRIVACY_URL_RULES.patterns.slice()
|
||||
// 记录"应用前 popup 实际显示状态", 用于 reEvaluate 决策
|
||||
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
|
||||
// 必须用 _popupActuallyShowing (由 privacy-popup 主动同步)
|
||||
const prevPopupShowing = _popupActuallyShowing
|
||||
setUrlRules(rules)
|
||||
persistRules(rules, {
|
||||
merge: rules && rules.merge,
|
||||
source: meta && meta.source,
|
||||
sign: meta && meta.patch && meta.patch.sign,
|
||||
})
|
||||
try {
|
||||
uni.$emit && uni.$emit('privacy:rules-updated', {
|
||||
mode: PRIVACY_URL_RULES.mode,
|
||||
patterns: PRIVACY_URL_RULES.patterns.slice(),
|
||||
source: (meta && meta.source) || 'remote',
|
||||
patch: meta && meta.patch,
|
||||
})
|
||||
} catch (e) {}
|
||||
|
||||
// 双阶段弹窗: 远程 patch 在 show() 之后到达时, 强制重评估
|
||||
// 触发条件:
|
||||
// 1. patch 来源是 'remote' (非 'base')
|
||||
// 2. 规则真的变了 (mode/patterns 之一不同)
|
||||
// 3. show() 已经调用过 (_hasChecked=true)
|
||||
// 行为:
|
||||
// - 旧规则没弹 → 新规则要弹 → emit show (popup 重弹)
|
||||
// - 旧规则在弹 → 新规则不该弹 → emit dismiss (popup 关闭)
|
||||
const _source = (meta && meta.source) || 'remote'
|
||||
const _rulesChanged = (
|
||||
prevMode !== PRIVACY_URL_RULES.mode ||
|
||||
prevPatterns.length !== PRIVACY_URL_RULES.patterns.length ||
|
||||
prevPatterns.some((p, i) => p !== PRIVACY_URL_RULES.patterns[i])
|
||||
)
|
||||
if (_source === 'remote' && _rulesChanged && _hasChecked) {
|
||||
// dev-only 日志, 受 __PRIVACY_DEBUG__ 开关控制 (默认开, 生产可通过 DefinePlugin 关)
|
||||
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
|
||||
console.log('[privacy] reEvaluate trigger: prevMode=' + prevMode + ' newMode=' + PRIVACY_URL_RULES.mode + ' prevPopupShowing=' + prevPopupShowing)
|
||||
}
|
||||
// 异步执行, 不阻塞 apply 流程
|
||||
setTimeout(() => { reEvaluateAfterRulesUpdated(prevPopupShowing) }, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则更新后的重评估
|
||||
* 场景: 远程 patch 在 show() 之后到达, 本次启动弹窗决策是基于旧规则做的
|
||||
* 现在新规则来了, 必须重评估
|
||||
* 步骤:
|
||||
* 1. 用 prevPopupShowing (popup 实际显示状态) + shouldShowByUrlRule (应用后规则 + 当前 URL) 算差量
|
||||
* 2. prevPopupShowing=false, shouldShow=true → emit show (popup 重弹)
|
||||
* 3. prevPopupShowing=true, shouldShow=false → emit dismiss (popup 关闭)
|
||||
* 4. 其他情况: 状态不变, 不 emit (避免无谓的弹窗抖动)
|
||||
* 边界: 用户已同意过 storage → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
|
||||
* @param {boolean} prevPopupShowing - 应用规则前的 popup 实际显示状态
|
||||
*/
|
||||
function reEvaluateAfterRulesUpdated(prevPopupShowing) {
|
||||
// dev-only 日志
|
||||
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
|
||||
console.log('[privacy] reEvaluate called prevPopup=' + prevPopupShowing + ' storageAgreed=' + getStorageAgreed())
|
||||
}
|
||||
if (getStorageAgreed()) {
|
||||
// 已同意过 → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
|
||||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'noop', reason: 'already-agreed' }) } catch (e) {}
|
||||
return
|
||||
}
|
||||
// 用户未同意 → 走规则决策
|
||||
const shouldShow = shouldShowByUrlRule()
|
||||
const wasShowing = !!prevPopupShowing
|
||||
if (!wasShowing && shouldShow) {
|
||||
// 旧规则没弹, 新规则要弹 → 重新设置 _pendingShow + emit show
|
||||
_needShow = true
|
||||
_pendingShow = true
|
||||
_hasChecked = true
|
||||
_urlRuleDecided = false
|
||||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'show', reason: 'rules-updated' }) } catch (e) {}
|
||||
} else if (wasShowing && !shouldShow) {
|
||||
// 旧规则在弹, 新规则不该弹 → 撤销 _pendingShow + emit dismiss
|
||||
_needShow = false
|
||||
_pendingShow = false
|
||||
_hasChecked = true
|
||||
_urlRuleDecided = true
|
||||
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'dismiss', reason: 'rules-updated' }) } catch (e) {}
|
||||
}
|
||||
// 其他情况: 状态不变, 不 emit
|
||||
}
|
||||
|
||||
/**
|
||||
* 一站式: 应用本地 base → 若有远程 patch 则融合 → 持久化 → 触发事件
|
||||
* 无远程 patch 时, base 直接生效 (向后兼容)
|
||||
* @param {Object} base - 本地基础规则
|
||||
* @param {Object} [patch] - 远程下发的 patch (可空)
|
||||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||||
*/
|
||||
function applyWithMerge(base, patch) {
|
||||
// 签名校验: 远程 patch 不合法 → 静默拒绝, 退回 base
|
||||
// (业务侧可通过 PrivacyManager.verifyRemotePatch() 单独调用)
|
||||
if (patch && !verifyRemotePatch(patch)) {
|
||||
console.warn('[privacy] applyWithMerge: remote patch sign invalid, fallback to base')
|
||||
const merged = mergeRules(base, null)
|
||||
applyMergedRules(merged, { source: 'base', patch: null })
|
||||
return merged
|
||||
}
|
||||
const merged = mergeRules(base, patch)
|
||||
applyMergedRules(merged, { source: patch ? 'remote' : 'base', patch })
|
||||
return merged
|
||||
}
|
||||
|
||||
// 本地 base 规则 (由 App.vue 通过 setBaseRules 登记)
|
||||
// 供 store/index.js 的 init action 在收到远程 patch 时读取
|
||||
let _baseRules = { mode: 'all', patterns: [] }
|
||||
|
||||
// 外部注入的 readyWaiters (由 config-external.js ConfigExternal 持有)
|
||||
// 兼容老用法: 业务侧可直接传 readyGuard, 也可走 ConfigExternal.setReadyWaiters 通道
|
||||
let _externalReadyGuard = null
|
||||
let _externalBootTimeout = 6000
|
||||
let _externalWaitChannels = { lang: true, theme: true, themeOverride: true, remoteConfig: true }
|
||||
|
||||
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||||
// 业务侧通过 setSignSecret(secret) 或 privacyConfig.signSecret 注入
|
||||
let _signSecret = ''
|
||||
|
||||
// 远程 patch 签名 (在持久化时被写入 storage 的 __sign 字段, 供后续审计/防回滚)
|
||||
// 注意: 当前实现不做增量校验, 只在 verifyRemotePatch 内单次校验
|
||||
// 如需增量校验 (防止回滚到旧 patch), 可在此维护 _lastPersistedSign
|
||||
// 每次 applyWithMerge 持久化时记录, 下次启动时与远程 patch 的 sign 字段比对
|
||||
let _lastPersistedSign = ''
|
||||
|
||||
// ============================================================
|
||||
// 监听 privacy:popup-state 事件 (由 privacy-popup.vue 主动同步弹窗显示状态)
|
||||
// 用于双阶段弹窗的精确重评估 (reEvaluateAfterRulesUpdated)
|
||||
// 这里挂一个全局监听, 模块加载时就生效
|
||||
// ============================================================
|
||||
;(function _installPopupStateListener() {
|
||||
try {
|
||||
if (typeof uni === 'undefined' || !uni.$on) return
|
||||
uni.$on('privacy:popup-state', (info) => {
|
||||
if (info && typeof info.showing === 'boolean') {
|
||||
_popupActuallyShowing = info.showing
|
||||
}
|
||||
})
|
||||
} catch (e) {}
|
||||
})()
|
||||
|
||||
const PrivacyManager = {
|
||||
/**
|
||||
* 同步检查 storage 中的同意状态(快速判断,不查平台)
|
||||
@@ -174,6 +660,7 @@ const PrivacyManager = {
|
||||
_needShow = false;
|
||||
_hasChecked = true;
|
||||
_pendingShow = false;
|
||||
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -185,20 +672,68 @@ const PrivacyManager = {
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.skip=false] true=本次跳过弹窗(不写 storage,下次正常访问其他页面还会弹)
|
||||
* @param {Object} [options.urlFallback] 首次启动 onLaunch 时 getCurrentPages 还没有页面,
|
||||
* 可传入 onLaunch options 兜底:
|
||||
* H5: { pathname, search, hash }
|
||||
* 小程序/鸿蒙: { route, query }
|
||||
* @param {Promise|Function} [options.readyGuard] 外部 readiness 守卫 (例如 i18n/theme),
|
||||
* show() 会 await 它再继续, 保证弹窗在外部资源就绪后才决策.
|
||||
* 超时保护: 业务侧应保证 guard 自身有超时, 否则会一直 hang.
|
||||
*/
|
||||
async show(options = {}) {
|
||||
const fallback = options && options.urlFallback ? options.urlFallback : null
|
||||
|
||||
// 外部 readiness 守卫: 等业务侧 promise 再继续
|
||||
// 优先级: options.readyGuard (调用方显式传) > _externalReadyGuard (ConfigExternal 注入)
|
||||
// 场景: App.vue onLaunch 把 i18n/theme/remote-config 的 await 链作为 guard 传入
|
||||
// 这样无论 show() 在哪里调用, 都保证弹窗决策时外部资源已就绪
|
||||
const _guard = (options && typeof options.readyGuard !== 'undefined')
|
||||
? options.readyGuard
|
||||
: _externalReadyGuard
|
||||
if (typeof _guard !== 'undefined' && _guard !== null) {
|
||||
try {
|
||||
if (_guard && typeof _guard.then === 'function') {
|
||||
await _guard
|
||||
} else if (typeof _guard === 'function') {
|
||||
await _guard()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[privacy] readyGuard fail', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 主动跳过: 不查 storage、不写 storage、不弹窗
|
||||
// 用于 H5 浏览器直接访问特定 URL(子串匹配)时不让隐私弹窗挡住
|
||||
if (options && options.skip) {
|
||||
_needShow = false;
|
||||
_hasChecked = true;
|
||||
_pendingShow = false;
|
||||
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
|
||||
return;
|
||||
}
|
||||
|
||||
// URL/路由规则判断
|
||||
// mode='only' 且未命中 patterns → 跳过
|
||||
// mode='skip' 且命中 patterns → 跳过
|
||||
// 其他情况 → 继续走 storage + 平台检查
|
||||
if (!shouldShowByUrlRule(fallback)) {
|
||||
_needShow = false;
|
||||
_hasChecked = true;
|
||||
_pendingShow = false;
|
||||
_urlRuleDecided = true; // 已决策, 阻止鸿蒙兜底强制弹窗
|
||||
return;
|
||||
}
|
||||
// URL 规则允许弹, 重置 urlRuleDecided 留给鸿蒙兜底分支判断
|
||||
_urlRuleDecided = false;
|
||||
|
||||
const _storageAgreed = getStorageAgreed();
|
||||
|
||||
const result = await checkPrivacy();
|
||||
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
||||
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
||||
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
|
||||
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
||||
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
||||
|
||||
_needShow = result.needAuthorization;
|
||||
_hasChecked = true;
|
||||
|
||||
@@ -235,7 +770,8 @@ const PrivacyManager = {
|
||||
}
|
||||
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
||||
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
||||
if (!_hasChecked && !_pendingShow && !getStorageAgreed()) {
|
||||
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
||||
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
||||
_pendingShow = false;
|
||||
_needShow = true;
|
||||
_hasChecked = true;
|
||||
@@ -281,6 +817,162 @@ const PrivacyManager = {
|
||||
hasChecked() {
|
||||
return _hasChecked;
|
||||
},
|
||||
|
||||
/**
|
||||
* 配置 URL/路由级别隐私弹窗规则
|
||||
* @param {Object} rules
|
||||
* @param {'all'|'only'|'skip'} [rules.mode='all']
|
||||
* 'all' - 始终走弹窗检查(默认)
|
||||
* 'only' - 白名单: 只有命中 patterns 才弹
|
||||
* 'skip' - 黑名单: 命中 patterns 跳过
|
||||
* @param {string[]} [rules.patterns=[]]
|
||||
* 子串匹配, H5: pathname+search+hash, 小程序/鸿蒙: route+query
|
||||
*/
|
||||
setUrlRules(rules) {
|
||||
setUrlRules(rules)
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前 URL/路由规则下是否要走弹窗检查
|
||||
* @param {Object} [fallback] 兜底路径(同 show 的 urlFallback)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldShowByUrlRule(fallback) {
|
||||
return shouldShowByUrlRule(fallback)
|
||||
},
|
||||
|
||||
/**
|
||||
* 应用本地 base + 远程 patch 的融合结果
|
||||
* - patch=null/缺 → 仅应用 base (并持久化)
|
||||
* - patch 存在 → 走 mergeRules 融合
|
||||
* 应用后 emit 'privacy:rules-updated'
|
||||
* @param {Object} base 本地基础规则 { mode, patterns }
|
||||
* @param {Object} [patch] 远程下发 { mode?, patterns?, merge? }
|
||||
* @returns {{ mode: string, patterns: string[], merge: string }}
|
||||
*/
|
||||
applyWithMerge(base, patch) {
|
||||
return applyWithMerge(base, patch)
|
||||
},
|
||||
|
||||
/**
|
||||
* 仅合并 (不写入), 方便上层预览或 dry-run
|
||||
* @param {Object} base
|
||||
* @param {Object} patch
|
||||
*/
|
||||
mergeRules(base, patch) {
|
||||
return mergeRules(base, patch)
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取上次持久化的最终规则 (跨冷启动恢复)
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
loadPersistedRules() {
|
||||
return loadPersistedRules()
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置持久化 key (用于 uniacid 隔离), 由 App.vue 注入
|
||||
* @param {string} key
|
||||
*/
|
||||
setStorageKey(key) {
|
||||
if (typeof key === 'string' && key) _storageKey = key
|
||||
},
|
||||
|
||||
/**
|
||||
* 登记本地 base 规则 (由 App.vue 在启动时调用)
|
||||
* 供后续远程下发时 mergeRules 读取
|
||||
* @param {Object} rules
|
||||
*/
|
||||
setBaseRules(rules) {
|
||||
if (!rules || typeof rules !== 'object') return
|
||||
_baseRules = {
|
||||
mode: rules.mode || 'all',
|
||||
patterns: Array.isArray(rules.patterns) ? rules.patterns.slice() : [],
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取本地 base 规则 (供 store/index.js init 远程 patch 融合)
|
||||
* @returns {{ mode: string, patterns: string[] }}
|
||||
*/
|
||||
__getBaseRules() {
|
||||
return { mode: _baseRules.mode, patterns: _baseRules.patterns.slice() }
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入外部 readiness 守卫 (由 config-external.js ConfigExternal 调用)
|
||||
* show() 时若无 options.readyGuard, 内部使用此 promise
|
||||
* @param {Promise|Function|null} guard
|
||||
*/
|
||||
setExternalReadyGuard(guard) {
|
||||
if (guard === null) { _externalReadyGuard = null; return }
|
||||
if (guard && (typeof guard.then === 'function' || typeof guard === 'function')) {
|
||||
_externalReadyGuard = guard
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入外部 bootTimeout (由 ConfigExternal.getPrivacyBootTimeout 调用)
|
||||
* @param {number} ms
|
||||
*/
|
||||
setExternalBootTimeout(ms) {
|
||||
if (typeof ms === 'number' && ms > 0) _externalBootTimeout = ms
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入 waitChannels 配置 (隐私弹窗启动期必等通道开关)
|
||||
* @param {Object} channels
|
||||
*/
|
||||
setWaitChannels(channels) {
|
||||
if (channels && typeof channels === 'object') {
|
||||
_externalWaitChannels = Object.assign({}, _externalWaitChannels, channels)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 注入远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
|
||||
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
|
||||
* 前端: 用 btoa 模拟 (弱校验, 仅防简单篡改)
|
||||
* secret='' → 跳过校验 (向后兼容)
|
||||
* @param {string} secret
|
||||
*/
|
||||
setSignSecret(secret) {
|
||||
_signSecret = (typeof secret === 'string') ? secret : ''
|
||||
},
|
||||
|
||||
/**
|
||||
* 校验远程 patch (业务侧可在 store/init 之外单独调用)
|
||||
* - signSecret 未配置 → true (向后兼容)
|
||||
* - patch.sign 缺失 → true (后端未启用签名)
|
||||
* - patch.sign 不匹配 → false
|
||||
* @param {Object} patch
|
||||
* @returns {boolean}
|
||||
*/
|
||||
verifyRemotePatch(patch) {
|
||||
return verifyRemotePatch(patch)
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算给定 patch 的签名 (供调试/单元测试)
|
||||
* @param {Object} patch
|
||||
* @param {string} [secret] - 不传则用 _signSecret
|
||||
* @returns {string}
|
||||
*/
|
||||
computeSign(patch, secret) {
|
||||
return computeSign(patch, secret || _signSecret)
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取当前生效配置 (供调试/单元测试)
|
||||
*/
|
||||
getReadyConfig() {
|
||||
return {
|
||||
readyGuard: _externalReadyGuard,
|
||||
bootTimeout: _externalBootTimeout,
|
||||
waitChannels: Object.assign({}, _externalWaitChannels),
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default PrivacyManager;
|
||||
|
||||
@@ -36,6 +36,19 @@ function useNativeLoading() {
|
||||
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
|
||||
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
|
||||
|
||||
// 启动期阶段定义 (id → { percent, label })
|
||||
// percent: 阶段完成的百分比 (0-100)
|
||||
// label: 阶段显示文案 (i18n key 由 splash 组件消费, 也可直接用最终文案)
|
||||
const STAGES = {
|
||||
boot: { percent: 5, label: 'Loading...' },
|
||||
lang: { percent: 30, label: 'Loading language...' },
|
||||
theme: { percent: 50, label: 'Applying theme...' },
|
||||
override: { percent: 65, label: 'Loading theme tokens...' },
|
||||
config: { percent: 80, label: 'Fetching configuration...' },
|
||||
privacy: { percent: 95, label: 'Preparing privacy dialog...' },
|
||||
done: { percent: 100, label: 'Ready' },
|
||||
};
|
||||
|
||||
class SplashState {
|
||||
constructor() {
|
||||
this._visible = false;
|
||||
@@ -46,6 +59,14 @@ class SplashState {
|
||||
this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount
|
||||
this._hasMounted = false; // 是否有 splash 组件 mount 过
|
||||
this._mountGraceId = null; // 软隐藏兜底定时器
|
||||
// 进度上报
|
||||
this._stage = 'boot';
|
||||
this._percent = 0;
|
||||
this._stageSet = new Set(['boot']); // 已报告阶段
|
||||
// 进度节流: 16ms 内的多次 reportProgress 合并成一次 emit
|
||||
// 避免多通道并发时, splash 组件 re-render 风暴
|
||||
this._pendingProgress = null; // 待 emit 的最新进度
|
||||
this._flushTimer = null; // 节流定时器
|
||||
}
|
||||
|
||||
show(opts = {}) {
|
||||
@@ -56,6 +77,16 @@ class SplashState {
|
||||
this._shownAt = Date.now();
|
||||
this._pendingHide = null;
|
||||
this._hasMounted = false;
|
||||
this._stage = 'boot';
|
||||
// 初始化时直接显示 'boot' 阶段的 5%, 而不是 0%
|
||||
// 避免首帧出现"进度条 0%"的视觉突兀
|
||||
this._percent = (STAGES.boot && STAGES.boot.percent) || 5;
|
||||
this._stageSet = new Set(['boot']);
|
||||
this._pendingProgress = null;
|
||||
if (this._flushTimer) {
|
||||
clearTimeout(this._flushTimer);
|
||||
this._flushTimer = null;
|
||||
}
|
||||
if (this._mountGraceId) {
|
||||
clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = null;
|
||||
@@ -89,16 +120,20 @@ class SplashState {
|
||||
* - 'timeout': 硬隐藏, 立即关 (兜底用)
|
||||
* - 'manual': 硬隐藏, 立即关
|
||||
* @param {boolean} [opts.soft] - 强制走软隐藏
|
||||
* @param {string} [opts.mode] - 离开动画模式
|
||||
* - 'default': 默认 480ms 动画
|
||||
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
|
||||
*/
|
||||
hide(opts = {}) {
|
||||
const reason = opts.reason || 'manual';
|
||||
// boot-complete 默认走软隐藏, 避免时序问题
|
||||
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||
const mode = opts.mode === 'fast' ? 'fast' : 'default';
|
||||
|
||||
if (useSoft) {
|
||||
this._softHide({ reason });
|
||||
this._softHide({ reason, mode });
|
||||
} else {
|
||||
this._doHide({ reason });
|
||||
this._doHide({ reason, mode });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +160,19 @@ class SplashState {
|
||||
}, MOUNT_GRACE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正触发软隐藏
|
||||
*/
|
||||
_flushPendingHide() {
|
||||
const opts = this._pendingHide;
|
||||
this._pendingHide = null;
|
||||
if (this._mountGraceId) {
|
||||
clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = null;
|
||||
}
|
||||
this._doHide(opts || { reason: 'mount-completed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际隐藏 (硬路径)
|
||||
*/
|
||||
@@ -144,20 +192,10 @@ class SplashState {
|
||||
if (useNativeLoading()) {
|
||||
try { uni.hideLoading(); } catch (e) {}
|
||||
}
|
||||
this._emit('splash:hide', { reason: opts.reason || 'manual' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正触发软隐藏
|
||||
*/
|
||||
_flushPendingHide() {
|
||||
const opts = this._pendingHide;
|
||||
this._pendingHide = null;
|
||||
if (this._mountGraceId) {
|
||||
clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = null;
|
||||
}
|
||||
this._doHide(opts || { reason: 'mount-completed' });
|
||||
this._emit('splash:hide', {
|
||||
reason: opts.reason || 'manual',
|
||||
mode: opts.mode || 'default',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,6 +255,111 @@ class SplashState {
|
||||
try { arr[i](payload); } catch (e) { console.warn('[splash-state] listener err', e); }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报启动期进度
|
||||
* 节流: 16ms 内的多次 reportProgress 合并成 1 次 emit, 避免 splash re-render 风暴
|
||||
* 取较大值, 不允许后退 (即使 throttle 合并也不会让进度条后退)
|
||||
* @param {string} stageId - 阶段 id (STAGES 中定义)
|
||||
* 'boot' | 'lang' | 'theme' | 'override' | 'config' | 'privacy' | 'done'
|
||||
* 或自定义阶段 id (传 percent + label)
|
||||
* @param {Object} [meta] - { percent?, label?, source?: 'channel'|'timeout'|'error' }
|
||||
* 阶段完成时 percent 应该是该阶段的"完成态"百分比
|
||||
*/
|
||||
reportProgress(stageId, meta) {
|
||||
const def = STAGES[stageId]
|
||||
let percent, label
|
||||
if (def) {
|
||||
percent = (meta && typeof meta.percent === 'number') ? meta.percent : def.percent
|
||||
label = (meta && meta.label) || def.label
|
||||
} else {
|
||||
// 自定义阶段
|
||||
percent = (meta && typeof meta.percent === 'number') ? meta.percent : this._percent
|
||||
label = (meta && meta.label) || stageId
|
||||
}
|
||||
// 取较大值, 不允许后退
|
||||
if (percent < this._percent) percent = this._percent
|
||||
this._percent = percent
|
||||
this._stage = stageId
|
||||
if (!this._stageSet.has(stageId)) this._stageSet.add(stageId)
|
||||
|
||||
// 节流 emit: 16ms 内的多次调用合并为 1 次
|
||||
// 内部状态立即更新 (不延迟), 但 emit 推迟
|
||||
// 注意: percent 不允许后退的逻辑已在上方处理, 所以 throttle 合并不会丢进度
|
||||
const payload = {
|
||||
stage: stageId,
|
||||
label,
|
||||
percent,
|
||||
source: (meta && meta.source) || 'channel',
|
||||
at: Date.now(),
|
||||
}
|
||||
this._pendingProgress = payload
|
||||
if (this._flushTimer) return // 已有 pending, 等 timer flush
|
||||
this._flushTimer = setTimeout(() => {
|
||||
this._flushTimer = null
|
||||
if (this._pendingProgress) {
|
||||
const p = this._pendingProgress
|
||||
this._pendingProgress = null
|
||||
this._emit('splash:progress', p)
|
||||
}
|
||||
}, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听进度上报
|
||||
* callback({ stage, label, percent, source, at })
|
||||
*
|
||||
* 注意: 与 on() 不同, onProgress 是单例 listener (多个组件时只最后一个生效)
|
||||
* 设计意图: splash 进度条 UI 在系统中只有一个渲染者, 不需要多播
|
||||
*/
|
||||
onProgress(callback) {
|
||||
this._listeners['splash:progress'] = [callback]
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑进度上报 (清空 listener)
|
||||
*/
|
||||
offProgress() {
|
||||
this._listeners['splash:progress'] = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前进度 (供组件初始化)
|
||||
* 同时返回已解析的 label, 避免组件再维护一份 STAGE_LABEL
|
||||
*/
|
||||
getProgress() {
|
||||
const def = STAGES[this._stage]
|
||||
return {
|
||||
stage: this._stage,
|
||||
percent: this._percent,
|
||||
label: def ? def.label : this._stage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露 STAGES 表 (供 splash 组件直接消费, 避免重复维护一份)
|
||||
*/
|
||||
getStageDef(stageId) {
|
||||
return STAGES[stageId]
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即刷新挂起的进度更新
|
||||
* - reportProgress 默认 16ms throttle (1 帧), 避免连续触发 splash 组件多次 re-render
|
||||
* - 业务侧若要"立即"刷新 (例如 splash 即将隐藏), 可调本方法
|
||||
* - 不存在 pending 时 noop
|
||||
*/
|
||||
flushProgress() {
|
||||
if (this._pendingProgress) {
|
||||
const p = this._pendingProgress
|
||||
this._pendingProgress = null
|
||||
if (this._flushTimer) {
|
||||
clearTimeout(this._flushTimer)
|
||||
this._flushTimer = null
|
||||
}
|
||||
this._emit('splash:progress', p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const splashState = new SplashState();
|
||||
|
||||
@@ -64,25 +64,10 @@ export default {
|
||||
},
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 调试: 记录本实例的 ID 和当前总数
|
||||
this.listenPrivacySettingChange()
|
||||
this.curPageShow()
|
||||
|
||||
// 监听全局隐私弹窗事件(由 App.vue boot 完成后通过 PrivacyManager.show() 触发)
|
||||
uni.$on('privacy:show', this.handlePrivacyShow)
|
||||
|
||||
// 监听关闭事件: 其他实例弹窗时通知本实例关闭
|
||||
uni.$on('privacy:forceClose', this.handleForceClose)
|
||||
|
||||
// 补偿: 若 PrivacyManager.show() 在本组件 mounted 之前 emit 了事件
|
||||
// 事件已丢失,此处通过 consumePendingShow 补偿弹窗
|
||||
if (PrivacyManager.consumePendingShow()) {
|
||||
this.$nextTick(() => {
|
||||
this.showPrivacyPopup()
|
||||
})
|
||||
}
|
||||
},
|
||||
// 关键: 把 listener 注册放到 created (而非 mounted),
|
||||
// 提前一个 microtask, 让 PrivacyManager.show() emit 的 privacy:show 不会丢
|
||||
// showPrivacyPopup() 内部访问 this.showPop 等响应式属性, 必须等到 mounted,
|
||||
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
|
||||
created() {
|
||||
try {
|
||||
// #ifdef MP-WEIXIN
|
||||
@@ -90,7 +75,7 @@ export default {
|
||||
success: (res) => {
|
||||
this.platformContractName = res.privacyContractName || ''
|
||||
}
|
||||
});
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
@@ -108,10 +93,28 @@ export default {
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName
|
||||
// #endif
|
||||
|
||||
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
|
||||
uni.$on('privacy:show', this.handlePrivacyShow)
|
||||
uni.$on('privacy:forceClose', this.handleForceClose)
|
||||
uni.$on('privacy:re-evaluate', this.handleReEvaluate)
|
||||
} catch (e) {
|
||||
console.error("=========低版本基础库==========", e)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 调试: 记录本实例的 ID 和当前总数
|
||||
this.listenPrivacySettingChange()
|
||||
this.curPageShow()
|
||||
|
||||
// 补偿: 若 PrivacyManager.show() 在本组件 mounted 之前 emit 了事件
|
||||
// listener 在 created 已注册, 大部分情况能直接命中, 但有些竞态导致 consumePendingShow 兜底
|
||||
if (PrivacyManager.consumePendingShow()) {
|
||||
this.$nextTick(() => {
|
||||
this.showPrivacyPopup()
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 接收全局隐私弹窗事件
|
||||
@@ -135,6 +138,43 @@ export default {
|
||||
this.disPopUp()
|
||||
},
|
||||
|
||||
/**
|
||||
* 接收规则重评估事件 (由 PrivacyManager.applyMergedRules 在远程 patch 之后触发)
|
||||
* action: 'show' → 当前不该弹 (白名单未命中) 但新规则要弹 → 重弹
|
||||
* action: 'dismiss' → 当前在弹 (白名单命中) 但新规则不该弹 → 关闭
|
||||
* action: 'noop' → 已同意, 无操作
|
||||
* 互斥: 只有第一个响应事件的实例真正处理, 避免多实例同时响应
|
||||
*
|
||||
* 关键: 这个函数会被所有已 mounted 的实例同时调用 (uni.$on 全局)
|
||||
* 必须用互斥机制保证只有一个实例实际弹窗/关窗。
|
||||
*
|
||||
* 设计: 复用 PrivacyManager 的 _pendingShow/_pendingHide 作为互斥 token:
|
||||
* - show: 只有消费到 token 的实例才能 popUp
|
||||
* - dismiss: 任何实例都允许 disPopUp (无副作用, 但要避免重复 emit forceClose)
|
||||
*/
|
||||
handleReEvaluate(info) {
|
||||
if (!info) return
|
||||
if (info.action === 'show') {
|
||||
// 新规则要弹 → 互斥消费 token
|
||||
if (!this.showPop) {
|
||||
// 仿 consumePendingShow 行为: 只有 _pendingShow=true 才能弹
|
||||
// 直接通过 showPrivacyPopup 内部 emit forceClose 通知其他实例
|
||||
if (PrivacyManager.consumePendingShow()) {
|
||||
this.showPrivacyPopup()
|
||||
} else {
|
||||
// 没有 token → 不弹 (被另一个实例接管了)
|
||||
}
|
||||
}
|
||||
} else if (info.action === 'dismiss') {
|
||||
// 新规则不该弹 → 只关闭已经弹窗的实例
|
||||
// 通过 forceClose 协议做: 关闭所有已经显示的实例
|
||||
if (this.showPop) {
|
||||
this.disPopUp()
|
||||
}
|
||||
}
|
||||
// noop: 用户已同意, 不动
|
||||
},
|
||||
|
||||
/**
|
||||
* 统一弹窗 + 关闭其他页面的弹窗
|
||||
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
|
||||
@@ -382,10 +422,14 @@ export default {
|
||||
// $lang() 返回真实翻译(效果与 H5 一致)
|
||||
this.prepareI18n();
|
||||
this.showPop = true;
|
||||
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
||||
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: true }) } catch (e) {}
|
||||
},
|
||||
|
||||
disPopUp() {
|
||||
this.showPop = false;
|
||||
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
||||
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: false }) } catch (e) {}
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
@@ -393,6 +437,7 @@ export default {
|
||||
// 移除全局事件监听
|
||||
uni.$off('privacy:show', this.handlePrivacyShow)
|
||||
uni.$off('privacy:forceClose', this.handleForceClose)
|
||||
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
<view class="splash-screen__core-body"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 启动期进度条 (透明悬浮, 不干扰极光视觉) -->
|
||||
<view class="splash-screen__progress" v-if="showProgress">
|
||||
<view class="splash-screen__progress-bar">
|
||||
<view class="splash-screen__progress-fill" :style="{ width: percent + '%' }"></view>
|
||||
</view>
|
||||
<view class="splash-screen__progress-text">
|
||||
<text class="splash-screen__progress-stage">{{ stageLabel }}</text>
|
||||
<text class="splash-screen__progress-percent">{{ percent }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
@@ -33,26 +44,70 @@
|
||||
name: 'splash-screen',
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
leaving: false
|
||||
// 默认 visible=true: 让 splash 组件一渲染就可见, 减少"白屏闪烁"
|
||||
// created() 钩子会同步 splashState 状态, 如果 splashState 已 hide, 立刻设 false
|
||||
// 这样既能保证首屏冷启动可见, 又避免挂载到非 splash 路径时误显示
|
||||
visible: true,
|
||||
leaving: false,
|
||||
percent: 0,
|
||||
stage: 'boot',
|
||||
showProgress: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (splashState.isShow()) {
|
||||
this.visible = true;
|
||||
computed: {
|
||||
// 直接从 splashState.STAGES 读取文案, 避免重复维护 STAGE_LABEL
|
||||
// (splash-state.js 暴露 getStageDef() 避免外部直接读 STAGES)
|
||||
stageLabel() {
|
||||
const def = splashState.getStageDef(this.stage)
|
||||
return def ? def.label : this.stage
|
||||
},
|
||||
},
|
||||
// created (而非 mounted): 提前同步 splashState 状态, 减少"白屏一帧"
|
||||
created() {
|
||||
try {
|
||||
const init = splashState.getProgress()
|
||||
this.percent = init.percent
|
||||
this.stage = init.stage
|
||||
this.showProgress = init.percent > 0
|
||||
} catch (e) {}
|
||||
// 同步 splashState 状态: 防止组件挂载在 splash 已 hide 之后误显示
|
||||
if (!splashState.isShow()) {
|
||||
this.visible = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
splashState.notifyMounted();
|
||||
splashState.on('splash:show', () => {
|
||||
this.visible = true;
|
||||
this.leaving = false;
|
||||
// 注意: 不要在这里再读 getProgress(). percent 不允许后退
|
||||
// 已通过 created() 同步; emit 进来多是无变化
|
||||
const init = splashState.getProgress()
|
||||
if (init.percent > this.percent) this.percent = init.percent
|
||||
if (init.stage !== this.stage) this.stage = init.stage
|
||||
this.showProgress = true
|
||||
});
|
||||
splashState.on('splash:hide', () => {
|
||||
this._leave();
|
||||
splashState.on('splash:hide', (info) => {
|
||||
// 进度条补完到 100% (视觉上"完成"), 立即 leave
|
||||
if (this.percent < 100) {
|
||||
this.percent = 100;
|
||||
this.stage = 'done';
|
||||
}
|
||||
// mode='fast' (隐私弹窗即将出现) → 80ms 短动画让 modal 更快接管视觉
|
||||
const fast = info && info.mode === 'fast'
|
||||
this._leave(fast)
|
||||
});
|
||||
splashState.onProgress((info) => {
|
||||
// 去重: percent/stage 没变化就不 setState, 避免空 re-render
|
||||
if (info.percent !== this.percent) this.percent = info.percent
|
||||
if (info.stage !== this.stage) this.stage = info.stage
|
||||
this.showProgress = true;
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
splashState.off('splash:show');
|
||||
splashState.off('splash:hide');
|
||||
splashState.offProgress();
|
||||
splashState.notifyUnmounted();
|
||||
if (this._leaveTimer) {
|
||||
clearTimeout(this._leaveTimer);
|
||||
@@ -60,13 +115,20 @@
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
_leave() {
|
||||
/**
|
||||
* 离开 splash
|
||||
* - 默认: 480ms fade-out 动画 (视觉柔和过渡)
|
||||
* - 当 privacy 弹窗即将出现时: 用 80ms 短动画, 让 modal 更快接管视觉
|
||||
* 否则弹窗与 splash 同时显示会有"双遮罩"感
|
||||
*/
|
||||
_leave(fast) {
|
||||
this.leaving = true;
|
||||
if (this._leaveTimer) clearTimeout(this._leaveTimer);
|
||||
const delay = fast ? 80 : 480
|
||||
this._leaveTimer = setTimeout(() => {
|
||||
this.visible = false;
|
||||
this.leaving = false;
|
||||
}, 480);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -91,6 +153,7 @@
|
||||
bottom: 0;
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
@@ -103,6 +166,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 启动期进度条 (悬浮在极光下方)
|
||||
.splash-screen__progress {
|
||||
position: absolute;
|
||||
bottom: 120rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 480rpx;
|
||||
max-width: 80vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
opacity: 0;
|
||||
animation: progress-fade-in 0.2s ease-out 0.1s forwards;
|
||||
}
|
||||
|
||||
.splash-screen__progress-bar {
|
||||
position: relative;
|
||||
height: 6rpx;
|
||||
background: rgba($accent, 0.08);
|
||||
border-radius: 3rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splash-screen__progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, $accent-light, $accent);
|
||||
border-radius: 3rpx;
|
||||
transition: width 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 0 12rpx rgba($accent, 0.4);
|
||||
}
|
||||
|
||||
.splash-screen__progress-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: rgba($accent-deep, 0.65);
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.splash-screen__progress-stage {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.splash-screen__progress-percent {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
@keyframes progress-fade-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20rpx); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
// 容器: 480rpx 见方, 居中
|
||||
.splash-screen__field {
|
||||
position: relative;
|
||||
@@ -111,8 +235,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// 入场: 缩放 + 透明度, 弹性曲线
|
||||
animation: field-enter 0.8s cubic-bezier(0.34, 1.4, 0.64, 1) both;
|
||||
// 入场: 缩放 + 透明度, 弹性曲线 (优化: 0.8s -> 0.4s 减少首屏延迟)
|
||||
animation: field-enter 0.4s cubic-bezier(0.34, 1.4, 0.64, 1) both;
|
||||
}
|
||||
|
||||
// 中心大光晕: 覆盖整个 field, 营造氛围
|
||||
|
||||
29
main.js
29
main.js
@@ -33,6 +33,23 @@ Vue.prototype.$config = Config;
|
||||
|
||||
Vue.prototype.$privacy = PrivacyManager; //全局隐私管理器
|
||||
|
||||
// 调试: 暴露 PrivacyManager 到 window, 便于 e2e 测试 / 调试 / 控制台排查
|
||||
// 仅 H5 + dev mode 暴露, 生产构建会因条件编译 + webpack DefinePlugin 消除
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__privacy__ = PrivacyManager
|
||||
// 隐私调试日志开关 (H5 dev only)
|
||||
// 设为 false 关闭 privacy 内部 console.log
|
||||
// 设 true: 走 verbose 日志 (reEvaluate 触发/调用, rules-updated 等)
|
||||
// 注: 不在条件编译 H5 内, 因为微信/鸿蒙也可启用
|
||||
try {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.__PRIVACY_DEBUG__ === undefined) {
|
||||
globalThis.__PRIVACY_DEBUG__ = true // dev 默认开, 生产可通过 DefinePlugin 关闭
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
// #endif
|
||||
|
||||
|
||||
// #ifdef H5
|
||||
EventBus.setDomBridge(DomEventBridge)
|
||||
@@ -110,8 +127,11 @@ uniStorage();
|
||||
// ============================================================
|
||||
|
||||
// #ifdef H5
|
||||
// H5: remote-only 下先 await boot 再 $mount(), 首屏直接有翻译数据
|
||||
// H5: 先 await boot 再 $mount(), 首屏直接有翻译数据
|
||||
// splashState.show() 内部 H5 降级为 uni.showLoading (await 期间 Vue 还没挂载,组件层无法渲染)
|
||||
// 关键: 无条件 show (不依赖 Lang.isRemoteOnly), 任何模式下都让 uni.showLoading 兜底
|
||||
// - local 模式: Lang.boot() sync, splash 立即 hide, 用户几乎看不到
|
||||
// - remote-only 模式: Lang.boot() 远端拉取, splash 显示整个 boot 期
|
||||
async function bootstrap() {
|
||||
if (Lang.isRemoteOnly && Lang.isRemoteOnly()) {
|
||||
splashState.show();
|
||||
@@ -126,7 +146,7 @@ async function bootstrap() {
|
||||
console.warn('[theme-presets] load fail', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[i18n] remote-only boot fail', e);
|
||||
console.warn('[i18n] boot fail', e);
|
||||
} finally {
|
||||
splashState.hide({ reason: 'boot-complete' });
|
||||
}
|
||||
@@ -146,6 +166,11 @@ bootstrap()
|
||||
// #ifndef H5
|
||||
// 小程序/鸿蒙: 同步 $mount(), boot 在 App.vue onLaunch 中异步执行
|
||||
// lang() 在 boot 未完成时返回空串, boot 完成后 emitUpdated 触发 $forceUpdate
|
||||
//
|
||||
// 关键: splashState.show() 必须**最早期**同步触发 (这里, 不在 App.vue onLaunch)
|
||||
// 原因: onLaunch 到 step 1.5 splashState.show() 之前, 用户看到的是白屏
|
||||
// splash 状态机只需要 'visible=true', 实际渲染等组件 mount
|
||||
splashState.show();
|
||||
const app = new Vue({
|
||||
...App,
|
||||
store
|
||||
|
||||
@@ -8,6 +8,7 @@ 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'
|
||||
import PrivacyManager from '../common/js/privacy.js'
|
||||
|
||||
// themeColor 结果缓存(避免每次都全量遍历 theme 对象)
|
||||
// key: JSON.stringify(theme) + tabBarHeight, value: CSS变量字符串
|
||||
@@ -314,6 +315,40 @@ const store = new Vuex.Store({
|
||||
this.commit('setGlobalStoreInfo', null);
|
||||
}
|
||||
this.commit('setInitStatus', true)
|
||||
|
||||
// 远程下发隐私弹窗 URL/路由规则 (可选)
|
||||
// 字段约定:
|
||||
// data.privacy_url_rules = { mode?: 'all'|'only'|'skip',
|
||||
// patterns?: string[],
|
||||
// merge?: 'replace'|'union'|'intersect',
|
||||
// sign?: string (HMAC 签名, 与后端约定) }
|
||||
// 与 App.vue 中声明的本地 base 融合:
|
||||
// - 字段缺失 → 用本地 base
|
||||
// - mode 远程有值 → 远程覆盖; 否则用本地
|
||||
// - patterns 按 merge 字段决定 (默认 replace)
|
||||
// 签名校验 (可选):
|
||||
// - privacyConfig.signSecret 非空 → PrivacyManager.verifyRemotePatch 校验 patch.sign
|
||||
// - 校验失败 → applyWithMerge 内部 fallback 到 base, 不应用 patch
|
||||
// 融合完成后 emit 'privacy:rules-updated', 持久化到 storage (跨冷启动恢复)
|
||||
try {
|
||||
if (data && data.privacy_url_rules && typeof data.privacy_url_rules === 'object') {
|
||||
// base 必须从 PrivacyManager.__getBaseRules() 读取
|
||||
// App.vue 顶层 script 已通过 setBaseRules() 登记
|
||||
// 若登记失败 (例如 HMR / 测试环境), 退化为 'all'+[] 但跳过本次应用
|
||||
const baseRules = (PrivacyManager && typeof PrivacyManager.__getBaseRules === 'function')
|
||||
? PrivacyManager.__getBaseRules()
|
||||
: null
|
||||
if (baseRules && (baseRules.mode || (baseRules.patterns && baseRules.patterns.length))) {
|
||||
// applyWithMerge 内部已调 verifyRemotePatch, 不通过的 patch 静默退回 base
|
||||
PrivacyManager.applyWithMerge(baseRules, data.privacy_url_rules)
|
||||
} else {
|
||||
console.warn('[privacy] base rules not registered, skip remote patch apply')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[privacy] apply remote rules fail', e)
|
||||
}
|
||||
|
||||
resolve(data);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user