feat: 实现隐私弹窗全链路功能,支持本地配置与远程patch融合

- 新增隐私管理器核心逻辑,支持URL路由规则匹配、远程patch签名校验与融合
- 实现启动期资源等待机制,确保弹窗在i18n、主题配置就绪后触发
- 新增启动进度条组件,展示启动阶段进度与状态
- 重构隐私弹窗组件生命周期,修复事件丢失问题并支持规则动态重评估
- 适配多端平台隐私检查逻辑,支持微信小程序、鸿蒙元服务与H5
- 支持uniacid租户隔离的持久化规则存储
This commit is contained in:
2026-07-11 18:28:36 +08:00
parent a2066285cd
commit a913961be5
8 changed files with 1471 additions and 148 deletions

304
App.vue
View File

@@ -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')) {

View File

@@ -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
}
/**

View File

@@ -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;

View File

@@ -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();

View File

@@ -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>

View File

@@ -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
View File

@@ -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

View File

@@ -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);
}
}