refactor(鸿蒙元服务): 适配鸿蒙平台隐私托管与启动屏时序优化
1. 适配鸿蒙元服务平台隐私托管,由系统自动弹出隐私弹窗,应用层不再弹出自建弹窗 2. 新增启动屏strict模式,鸿蒙场景下严格等待首页DIY渲染完成再关闭启动屏,避免白屏 3. 新增splash-state单元测试覆盖新旧模式与鸿蒙场景 4. 清理隐私相关冗余代码与注释,统一平台逻辑分支
This commit is contained in:
32
App.vue
32
App.vue
@@ -271,27 +271,57 @@
|
||||
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
|
||||
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
|
||||
// 只弹一次: 同意后写 storage,后续不再弹
|
||||
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
|
||||
// 异步查询平台隐私状态(微信/快应用), H5 同步返回
|
||||
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
|
||||
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
|
||||
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
|
||||
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
|
||||
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
|
||||
//
|
||||
// 鸿蒙元服务(双轨制):
|
||||
// 已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗.
|
||||
// 应用层不调 PrivacyManager.show() (会和系统弹窗叠加被驳回).
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙: 走平台隐私托管, markSkipped 写 storage
|
||||
try {
|
||||
await Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig])
|
||||
PrivacyManager.markSkipped()
|
||||
} catch (e) { console.warn('[privacy] MP-HARMONY markSkipped fail', e); }
|
||||
// #endif
|
||||
// #ifndef MP-HARMONY
|
||||
try {
|
||||
await PrivacyManager.show({
|
||||
urlFallback: _buildPrivacyUrlFallback(options),
|
||||
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
|
||||
})
|
||||
} catch (e) { console.warn('[privacy] show fail', e); }
|
||||
// #endif
|
||||
|
||||
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
|
||||
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
|
||||
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
|
||||
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
|
||||
//
|
||||
// 关键 (鸿蒙 + 其他平台): 这里只发"软隐藏"信号, splash 实际关闭要等
|
||||
// splashState._softHide() 看到 _hasMounted && _pageReady 才真关。
|
||||
// _pageReady 由首页 diy-group 渲染完时调 notifyPageReady() 触发。
|
||||
// 这样保证 splash **一直显示**, 直到首页 diy 内容真的渲染好,
|
||||
// 避免 splash 关闭后到 diy 内容出现之间出现白屏。
|
||||
//
|
||||
// 鸿蒙端 (mode='strict'):
|
||||
// 鸿蒙系统弹窗是用户视觉锚点, 应用层 splash 必须**真等到**diy 渲染好
|
||||
// 才能关, 不走 MOUNT_GRACE_MS/PAGE_READY_GRACE_MS 软隐藏兜底
|
||||
// (软隐藏兜底 1+2=3s 经常不够用, 会导致 splash 提前 hide → 白屏)
|
||||
// strict 模式只受 DEFAULT_TIMEOUT=8s 总超时兜底, 实际由 notifyPageReady() 触发
|
||||
// #ifndef H5
|
||||
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
|
||||
if (_needSplash) {
|
||||
// #ifdef MP-HARMONY
|
||||
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
|
||||
// #endif
|
||||
// #ifndef MP-HARMONY
|
||||
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
|
||||
// #endif
|
||||
}
|
||||
// #endif
|
||||
|
||||
|
||||
@@ -147,20 +147,13 @@ function getQuickAppPrivacySetting() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 鸿蒙元服务: 查询平台级隐私设置
|
||||
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
|
||||
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
|
||||
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
|
||||
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
|
||||
* @returns {Promise<{needAuthorization: boolean}>}
|
||||
*/
|
||||
function getHarmonyPrivacySetting() {
|
||||
return new Promise((resolve) => {
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
|
||||
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
// #endif
|
||||
// #ifndef MP-HARMONY
|
||||
resolve({ needAuthorization: !getStorageAgreed() });
|
||||
// #endif
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,7 +181,7 @@ async function checkPrivacy() {
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
|
||||
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
|
||||
// H5 / 其他平台: 仅查 storage
|
||||
return checkPrivacySync();
|
||||
}
|
||||
@@ -730,8 +723,9 @@ const PrivacyManager = {
|
||||
|
||||
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
|
||||
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
|
||||
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
|
||||
// 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
|
||||
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
|
||||
// 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
|
||||
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
|
||||
|
||||
_needShow = result.needAuthorization;
|
||||
@@ -741,7 +735,18 @@ const PrivacyManager = {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置待弹窗标志
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗
|
||||
// 应用层不再 emit 'privacy:show' 触发自建 privacy-popup.vue
|
||||
// 标记已决策, 防止 consumePendingShow 兜底分支在 page mounted 时再次弹自建弹窗
|
||||
_needShow = false;
|
||||
_pendingShow = false;
|
||||
_urlRuleDecided = true;
|
||||
console.log('[privacy] MP-HARMONY: 已委托鸿蒙平台隐私托管, 不触发自建弹窗');
|
||||
return;
|
||||
// #endif
|
||||
|
||||
// 其他平台: 设置待弹窗标志 + emit 触发自建弹窗
|
||||
// 已 mounted 的页面组件通过事件接收
|
||||
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
|
||||
_pendingShow = true;
|
||||
@@ -757,18 +762,28 @@ const PrivacyManager = {
|
||||
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
|
||||
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
|
||||
*
|
||||
* 鸿蒙元服务兼容:
|
||||
* 历史兼容:
|
||||
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
|
||||
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
|
||||
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
|
||||
*
|
||||
* 鸿蒙元服务(双轨制):
|
||||
* 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
|
||||
* 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与
|
||||
* 鸿蒙系统级弹窗叠加出现"多次弹出"。
|
||||
* @returns {boolean} true=需要弹窗,false=不需要
|
||||
*/
|
||||
consumePendingShow() {
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗
|
||||
return false;
|
||||
// #endif
|
||||
|
||||
if (_pendingShow && _needShow) {
|
||||
_pendingShow = false;
|
||||
return true;
|
||||
}
|
||||
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
||||
// 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
|
||||
// 同一次进程内只补一次,避免隐私弹窗被反复触发
|
||||
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
|
||||
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
|
||||
|
||||
@@ -136,15 +136,22 @@ class SplashState {
|
||||
* @param {string} [opts.mode] - 离开动画模式
|
||||
* - 'default': 默认 480ms 动画
|
||||
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
|
||||
* - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底,
|
||||
* 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
|
||||
* 适用于: 已接入平台托管的系统弹窗 (鸿蒙),
|
||||
* splash 必须真等到首页 diy 内容渲染好才能关
|
||||
*/
|
||||
hide(opts = {}) {
|
||||
const reason = opts.reason || 'manual';
|
||||
const useStrict = opts.mode === 'strict' || opts._strict === true;
|
||||
// boot-complete 默认走软隐藏, 避免时序问题
|
||||
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||
const mode = opts.mode === 'fast' ? 'fast' : 'default';
|
||||
const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
|
||||
// strict 模式复用 fast 动画
|
||||
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
|
||||
|
||||
if (useSoft) {
|
||||
this._softHide({ reason, mode });
|
||||
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
|
||||
this._softHide(opts);
|
||||
} else {
|
||||
this._doHide({ reason, mode });
|
||||
}
|
||||
@@ -154,10 +161,17 @@ class SplashState {
|
||||
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
|
||||
* 如果组件 1s 内没 mount, 兜底自动 hide
|
||||
* 如果页面 2s 内没就绪, 兜底自动 hide
|
||||
*
|
||||
* strict 模式 (鸿蒙已接入平台托管):
|
||||
* 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底
|
||||
* 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
|
||||
* 保证 splash 一定等到首页 diy 内容真的渲染好
|
||||
*/
|
||||
_softHide(opts) {
|
||||
if (!this._visible) return; // 已经隐藏了
|
||||
this._pendingHide = opts;
|
||||
// strict 模式识别: mode='strict' 或 _strict=true
|
||||
const useStrict = opts && (opts.mode === 'strict' || opts._strict === true);
|
||||
|
||||
// 如果组件已经 mount 且页面已就绪, 立即隐藏
|
||||
if (this._hasMounted && this._pageReady) {
|
||||
@@ -165,6 +179,12 @@ class SplashState {
|
||||
return;
|
||||
}
|
||||
|
||||
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
|
||||
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
|
||||
if (useStrict) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底: 1s 内没 mount, 强制隐藏
|
||||
if (this._mountGraceId) clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = setTimeout(() => {
|
||||
@@ -177,15 +197,23 @@ class SplashState {
|
||||
/**
|
||||
* 真正触发软隐藏
|
||||
* 如果页面还未就绪, 等待页面就绪后再 hide
|
||||
* strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady()
|
||||
*/
|
||||
_flushPendingHide() {
|
||||
if (this._mountGraceId) {
|
||||
clearTimeout(this._mountGraceId);
|
||||
this._mountGraceId = null;
|
||||
}
|
||||
const _opts = this._pendingHide || {}
|
||||
const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true)
|
||||
|
||||
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
|
||||
if (!this._pageReady) {
|
||||
// strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady()
|
||||
// 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s)
|
||||
if (useStrict) {
|
||||
return;
|
||||
}
|
||||
// 兜底: 2s 内没就绪, 强制隐藏
|
||||
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
|
||||
this._pageReadyGraceId = setTimeout(() => {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<template>
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<!--
|
||||
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
|
||||
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
|
||||
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
|
||||
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
|
||||
-->
|
||||
<!-- #endif-->
|
||||
<!-- #ifndef MP-HARMONY -->
|
||||
<view>
|
||||
<view class="privacy-mask" v-if="showPop">
|
||||
<view class="privacy-wrap">
|
||||
@@ -6,7 +15,7 @@
|
||||
<view class="privacy-desc">
|
||||
{{ i18n.descPrefix }}
|
||||
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
|
||||
<!-- #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY -->
|
||||
<!-- #ifdef QUICKAPP-WEBVIEW || H5 -->
|
||||
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
|
||||
<!-- #endif -->
|
||||
{{ i18n.descSuffix }}
|
||||
@@ -17,13 +26,15 @@
|
||||
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
|
||||
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 || MP-HARMONY -->
|
||||
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn }}</button>
|
||||
<!-- #ifdef H5 -->
|
||||
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
|
||||
}}</button>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -48,6 +59,12 @@ export default {
|
||||
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
|
||||
// 弹窗前才参与 i18n.privacyContractName 的填充
|
||||
platformContractName: '',
|
||||
// 平台协议名称就绪 Promise:
|
||||
// created() 中发起异步查询, success/fail 时 resolve
|
||||
// popUp() 中 await 此 Promise 后再调 prepareI18n(), 保证 $lang() 与
|
||||
// platformContractName 都已经准备好, 不会出现"空串被永久缓存"的问题
|
||||
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"是同源契约)
|
||||
platformContractNameReady: null,
|
||||
// 弹窗文案: 模板只读 i18n.xxx
|
||||
// 初值为空串, 弹窗前在 popUp() 中**同步**填充
|
||||
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
|
||||
@@ -70,10 +87,22 @@ export default {
|
||||
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
|
||||
created() {
|
||||
try {
|
||||
// 创建平台协议名称就绪 Promise
|
||||
// 后续 wx/has/uni.* 三端的异步 API 都会挂到这个 promise 上 resolve,
|
||||
// popUp() 中 await 后再 prepareI18n(), 保证 platformContractName 已就绪
|
||||
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"契约同源)
|
||||
this.platformContractNameReady = new Promise((resolve) => {
|
||||
this._resolvePlatformContractName = resolve
|
||||
})
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
wx.getPrivacySetting({
|
||||
success: (res) => {
|
||||
this.platformContractName = res.privacyContractName || ''
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
},
|
||||
fail: () => {
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
@@ -85,13 +114,28 @@ export default {
|
||||
if (res.privacyContractName) {
|
||||
this.platformContractName = res.privacyContractName
|
||||
}
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
},
|
||||
fail: () => {
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 无 API 时立即 resolve (避免 await 永久阻塞)
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName
|
||||
// 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
|
||||
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
|
||||
// 立即 resolve, 让 popUp 的 await 不永久阻塞
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
// H5 无平台隐私 API, 立即 resolve
|
||||
this._resolvePlatformContractName && this._resolvePlatformContractName()
|
||||
// #endif
|
||||
|
||||
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
|
||||
@@ -179,14 +223,14 @@ export default {
|
||||
* 统一弹窗 + 关闭其他页面的弹窗
|
||||
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
|
||||
*/
|
||||
showPrivacyPopup() {
|
||||
async showPrivacyPopup() {
|
||||
// 先通知其他实例关闭
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
||||
// 直接弹窗: boot 阶段已经保证 bundle 完整
|
||||
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
|
||||
// 不完整时 force 重试, 仍不完整则报错不静默回退
|
||||
// 所以这里无需再检测/自愈, 信任 boot 已就绪
|
||||
this.popUp()
|
||||
await this.popUp()
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -265,7 +309,8 @@ export default {
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听
|
||||
// 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
|
||||
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
|
||||
// #endif
|
||||
},
|
||||
|
||||
@@ -284,22 +329,22 @@ export default {
|
||||
*/
|
||||
closeOtherPagePopUp(closePopUp) {
|
||||
// 通过全局事件通知所有页面关闭弹窗
|
||||
try { uni.$emit('privacy:closeOthers', this._uid); } catch (e) {}
|
||||
try { uni.$emit('privacy:closeOthers', this._uid); } catch (e) { }
|
||||
},
|
||||
|
||||
openPrivacyContract() {
|
||||
// #ifdef MP-WEIXIN
|
||||
wx.openPrivacyContract({
|
||||
success(res) {},
|
||||
fail(err) {}
|
||||
success(res) { },
|
||||
fail(err) { }
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
||||
has.openPrivacySetting({
|
||||
success: (res) => {},
|
||||
fail: (err) => {}
|
||||
success: (res) => { },
|
||||
fail: (err) => { }
|
||||
});
|
||||
} else {
|
||||
this.$util.redirectTo(privacyContractPage);
|
||||
@@ -307,8 +352,8 @@ export default {
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
// 鸿蒙元服务: 使用应用内协议页面
|
||||
this.$util.redirectTo(privacyContractPage);
|
||||
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
||||
// (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
@@ -320,8 +365,8 @@ export default {
|
||||
// #ifdef QUICKAPP-WEBVIEW
|
||||
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
||||
has.openPrivacySetting({
|
||||
success: (res) => {},
|
||||
fail: (err) => {}
|
||||
success: (res) => { },
|
||||
fail: (err) => { }
|
||||
});
|
||||
} else {
|
||||
this.$util.redirectTo(privacyServicePage);
|
||||
@@ -329,7 +374,7 @@ export default {
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
this.$util.redirectTo(privacyServicePage);
|
||||
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
@@ -344,7 +389,7 @@ export default {
|
||||
this.privacyResolves.clear()
|
||||
|
||||
// 先通知其他实例关闭, 再关闭自己, 避免叠层
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
||||
this.disPopUp()
|
||||
|
||||
// 修复: 拒绝时不写 privacyAgreed, 允许下次再弹
|
||||
@@ -369,7 +414,7 @@ export default {
|
||||
this.privacyResolves.clear()
|
||||
|
||||
// 先通知其他实例关闭, 再关闭自己
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
||||
try { uni.$emit('privacy:forceClose'); } catch (e) { }
|
||||
this.disPopUp()
|
||||
|
||||
// 由 PrivacyManager 管理 storage
|
||||
@@ -390,9 +435,9 @@ export default {
|
||||
*/
|
||||
i18nPrepareFn() {
|
||||
let appName, privacyContractName, privacyServiceName;
|
||||
|
||||
|
||||
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
|
||||
// #ifdef H5 || MP-HARMONY
|
||||
// #ifdef H5
|
||||
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
|
||||
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
|
||||
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
|
||||
@@ -416,20 +461,33 @@ export default {
|
||||
return result;
|
||||
},
|
||||
|
||||
popUp() {
|
||||
async popUp() {
|
||||
// 弹窗前**一次性**计算所有 i18n 文案
|
||||
// boot 阶段已用 isBundleComplete() 保证 bundle 完整
|
||||
// $lang() 返回真实翻译(效果与 H5 一致)
|
||||
// 必须先等两类数据准备好:
|
||||
// 1) Lang.boot() 完成 — 由 i18n-lazy mixin 隐含保证
|
||||
// (调用方 App.vue 在 await PrivacyManager.show() 后才触发弹窗,
|
||||
// PrivacyManager.show() await 了 boot 守卫)
|
||||
// 2) platformContractName 就绪 — 微信/快应用/鸿蒙三端的 API 都是异步的,
|
||||
// success 回调时机不确定, 直接 prepareI18n() 会读到空字符串, 导致
|
||||
// "空串被永久缓存" (与 i18n-lazy mixin 警告的问题同源)
|
||||
// 因此这里 await platformContractNameReady, 然后再 prepareI18n。
|
||||
if (this.platformContractNameReady) {
|
||||
try {
|
||||
await this.platformContractNameReady
|
||||
} catch (e) {
|
||||
// 防御性: 即便 ready 出错也继续 (走 fallback 默认文案)
|
||||
}
|
||||
}
|
||||
this.prepareI18n();
|
||||
this.showPop = true;
|
||||
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
|
||||
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: true }) } catch (e) {}
|
||||
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) {}
|
||||
try { uni.$emit && uni.$emit('privacy:popup-state', { showing: false }) } catch (e) { }
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
@@ -438,6 +496,11 @@ export default {
|
||||
uni.$off('privacy:show', this.handlePrivacyShow)
|
||||
uni.$off('privacy:forceClose', this.handleForceClose)
|
||||
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
|
||||
// 销毁时强制 resolve, 避免 popUp() 中的 await 永久挂起 (例如用户提前离开页面)
|
||||
if (typeof this._resolvePlatformContractName === 'function') {
|
||||
this._resolvePlatformContractName()
|
||||
this._resolvePlatformContractName = null
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -491,7 +554,7 @@ export default {
|
||||
.privacy-button-btn {
|
||||
color: #FFF;
|
||||
font-size: 30rpx;
|
||||
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
|
||||
/* #ifdef QUICKAPP-WEBVIEW || H5 */
|
||||
font-size: 28rpx;
|
||||
/* #endif */
|
||||
font-weight: 500;
|
||||
|
||||
320
tests/splash-state.test.js
Normal file
320
tests/splash-state.test.js
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* splash-state.js 单元测试 — 启动屏时序控制
|
||||
*
|
||||
* 跑法:
|
||||
* node --test tests/splash-state.test.js
|
||||
*
|
||||
* 测试覆盖:
|
||||
* - 默认 boot-complete 软隐藏 (MOUNT_GRACE_MS=1s + PAGE_READY_GRACE_MS=2s 兜底)
|
||||
* - strict 模式: 不走 1+2s 软隐藏兜底, 只等 notifyMounted + notifyPageReady
|
||||
* (鸿蒙端已接入平台托管, splash 必须真等到首页 diy 渲染好)
|
||||
* - DEFAULT_TIMEOUT=8s 总超时兜底仍然生效
|
||||
* - 重复 hide() 防重入
|
||||
*
|
||||
* 实现策略:
|
||||
* splash-state.js 用了 ESM export, vm 不能直接跑.
|
||||
* 用一段包装代码: 把 export 转成 module.exports, 提取 SplashState 类在沙箱里实例化.
|
||||
*
|
||||
* 业务背景:
|
||||
* 鸿蒙元服务已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗.
|
||||
* 应用层 splash 不能"提前"关闭 (否则出现 splash 关闭 → diy 数据还没到 → 白屏 时段).
|
||||
* strict 模式确保 splash 真等到 notifyPageReady (首页 diy 渲染完毕回调).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SPLASH_SRC = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'common', 'js', 'splash-state.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
/**
|
||||
* 在沙箱里加载 splash-state.js, 提取 SplashState 类
|
||||
* 关键: 要 mock uni 全局 API, 否则沙箱里会抛 ReferenceError
|
||||
*/
|
||||
function loadSplashStateInSandbox() {
|
||||
const moduleObj = { exports: {} };
|
||||
// 包装: export default 改为 module.exports
|
||||
const wrapped = SPLASH_SRC
|
||||
.replace(/^import\s+.*$/gm, '// (import)')
|
||||
.replace(/^export\s+default\s+splashState;?$/gm, '')
|
||||
.replace(/^export\s+\{[\s\S]*?\};?$/gm, '')
|
||||
+ '\nmodule.exports = { SplashState, splashState };\n'
|
||||
const sandbox = {
|
||||
console,
|
||||
globalThis: {},
|
||||
module: moduleObj,
|
||||
exports: moduleObj.exports,
|
||||
// mock uni 全局 (splash-state 内部判断 needSplash / useNativeLoading)
|
||||
uni: {
|
||||
getStorageSync: () => null,
|
||||
setStorageSync: () => {},
|
||||
removeStorageSync: () => {},
|
||||
showLoading: () => {},
|
||||
hideLoading: () => {},
|
||||
},
|
||||
// mock setTimeout/clearTimeout (splash-state 内部用)
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
// mock 平台: MP-HARMONY
|
||||
// (splash-state 内部 typeof uni !== 'undefined' && uni.getStorageSync 检查)
|
||||
}
|
||||
sandbox.globalThis = sandbox
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(wrapped, sandbox, { filename: 'splash-state.js' })
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个 SplashState 实例, 跳过 show() 阶段
|
||||
* (测试 hide/notify 流程, 不需要真实显示 splash)
|
||||
*/
|
||||
function makeSplashState() {
|
||||
const { SplashState } = loadSplashStateInSandbox()
|
||||
if (typeof SplashState !== 'function') {
|
||||
throw new Error('SplashState class not exported')
|
||||
}
|
||||
const s = new SplashState()
|
||||
// 直接设置内部状态模拟 splash 已显示
|
||||
s._visible = true
|
||||
s._shownAt = Date.now()
|
||||
s._stageSet = new Set(['boot'])
|
||||
s._pendingProgress = null
|
||||
s._pendingHide = null
|
||||
// 清除 _timeoutId 等
|
||||
return s
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 1. 默认 boot-complete 软隐藏 (现有行为)
|
||||
// =============================================================
|
||||
test('默认: boot-complete 走软隐藏, MOUNT_GRACE_MS=1s + PAGE_READY_GRACE_MS=2s 兜底', () => {
|
||||
const s = makeSplashState()
|
||||
const events = []
|
||||
// 监听 emit
|
||||
const origEmit = s._emit.bind(s)
|
||||
s._emit = (event, payload) => {
|
||||
events.push({ event, payload })
|
||||
origEmit(event, payload)
|
||||
}
|
||||
|
||||
s.hide({ reason: 'boot-complete', mode: 'fast' })
|
||||
|
||||
// 应该标记 _pendingHide, 但还没真关
|
||||
assert.ok(s._pendingHide, '应该设置 _pendingHide')
|
||||
assert.equal(s._visible, true, '软隐藏时仍 visible=true')
|
||||
// 应该设置 mountGraceId (1s 兜底)
|
||||
assert.ok(s._mountGraceId, '默认模式应该设置 mountGraceId')
|
||||
// 不应该走 strict 路径
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// 2. strict 模式: 鸿蒙端, 不走 MOUNT_GRACE_MS 兜底
|
||||
// =============================================================
|
||||
test('strict 模式: mode="strict" 不设置 mountGraceId (1s 兜底跳过)', () => {
|
||||
const s = makeSplashState()
|
||||
s.hide({ reason: 'boot-complete', mode: 'strict' })
|
||||
|
||||
assert.ok(s._pendingHide, '应该设置 _pendingHide')
|
||||
assert.equal(s._visible, true, '软隐藏时仍 visible=true')
|
||||
assert.equal(s._mountGraceId, null, 'strict 模式不应该设置 mountGraceId')
|
||||
})
|
||||
|
||||
test('strict 模式: _strict: true 兼容形式也支持 (App.vue 当前用法)', () => {
|
||||
const s = makeSplashState()
|
||||
// 鸿蒙调用方: hide({ reason: 'boot-complete', mode: 'strict' })
|
||||
// 验证 mode='strict' 即触发 strict 路径
|
||||
s.hide({ reason: 'boot-complete', mode: 'strict' })
|
||||
assert.equal(s._mountGraceId, null, '_strict 兼容模式正确识别')
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// 3. strict 模式真等 notifyPageReady 不超时
|
||||
// =============================================================
|
||||
test('strict 模式: 没调 notifyPageReady 时, _flushPendingHide 不设 pageReadyGraceId', () => {
|
||||
const s = makeSplashState()
|
||||
s.hide({ reason: 'boot-complete', mode: 'strict' })
|
||||
|
||||
// 模拟组件 mount
|
||||
s.notifyMounted()
|
||||
// 此时 _pageReady=false, _softHide 后会调 _flushPendingHide
|
||||
// strict 模式: 不设 pageReadyGraceId
|
||||
assert.equal(s._pageReadyGraceId, null,
|
||||
'strict 模式: 页面未就绪时, 不设置 pageReadyGraceId (1+2s 兜底跳过)')
|
||||
})
|
||||
|
||||
test('strict 模式: 调 notifyPageReady 立即真关', () => {
|
||||
const s = makeSplashState()
|
||||
const events = []
|
||||
const origEmit = s._emit.bind(s)
|
||||
s._emit = (event, payload) => {
|
||||
events.push({ event, payload })
|
||||
origEmit(event, payload)
|
||||
}
|
||||
|
||||
s.hide({ reason: 'boot-complete', mode: 'strict' })
|
||||
s.notifyMounted() // 组件 mount
|
||||
s.notifyPageReady() // 页面就绪
|
||||
|
||||
// _pageReady=true, _pendingHide 还在 → 应该真关
|
||||
// notifyPageReady 内部 setTimeout 50ms 后调 _flushPendingHide
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
assert.equal(s._visible, false, 'notifyPageReady 后 splash 应真关')
|
||||
const hideEvent = events.find(e => e.event === 'splash:hide')
|
||||
assert.ok(hideEvent, '应该发 splash:hide 事件')
|
||||
resolve()
|
||||
}, 100)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// 4. 默认模式: 仍然有 pageReadyGraceId 兜底 (保持现有行为)
|
||||
// =============================================================
|
||||
test('默认模式: _flushPendingHide 调 _pageReadyGraceId (2s 兜底, 向后兼容)', async () => {
|
||||
const s = makeSplashState()
|
||||
s.hide({ reason: 'boot-complete', mode: 'fast' })
|
||||
s.notifyMounted() // mount 后, 50ms 后 _flushPendingHide 被调
|
||||
// 给 100ms 让 microtask 跑完
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 默认模式: 应该设置 pageReadyGraceId
|
||||
assert.ok(s._pageReadyGraceId,
|
||||
'默认模式: 页面未就绪时, 应该设置 pageReadyGraceId (2s 兜底)')
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// 5. DEFAULT_TIMEOUT=8s 总超时仍然兜底
|
||||
// =============================================================
|
||||
test('DEFAULT_TIMEOUT: 总超时由 show() 设置的 _timeoutId 兜底, strict 模式也走这个', () => {
|
||||
const s = makeSplashState()
|
||||
// 模拟 show() 后 _timeoutId 被设置
|
||||
s._timeoutId = setTimeout(() => {}, 1000)
|
||||
s.hide({ reason: 'boot-complete', mode: 'strict' })
|
||||
|
||||
// strict 模式: 不取消 _timeoutId (总超时仍然兜底)
|
||||
// _timeoutId 仍然存在
|
||||
assert.ok(s._timeoutId, 'strict 模式不取消 _timeoutId, 总超时兜底保留')
|
||||
clearTimeout(s._timeoutId)
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// 6. 重复 hide() 防重入
|
||||
// =============================================================
|
||||
test('hide() 重复调用: boot-complete reason 走软隐藏, _pendingHide 保持', () => {
|
||||
const s = makeSplashState()
|
||||
s.hide({ reason: 'boot-complete', mode: 'fast' })
|
||||
const firstPending = s._pendingHide
|
||||
assert.ok(firstPending, '第一次 hide 后 _pendingHide 有值')
|
||||
assert.equal(firstPending.reason, 'boot-complete')
|
||||
|
||||
// 第二次同 reason 调用: 仍走软隐藏, _pendingHide 被重置为最新 opts
|
||||
s.hide({ reason: 'boot-complete', mode: 'fast' })
|
||||
assert.ok(s._pendingHide, '第二次 hide (boot-complete) 后 _pendingHide 仍有值')
|
||||
assert.equal(s._pendingHide.reason, 'boot-complete', 'reason 仍 boot-complete')
|
||||
})
|
||||
|
||||
test('hide() 重复调用: 非 boot-complete reason 走 _doHide 强关 (设计如此)', () => {
|
||||
const s = makeSplashState()
|
||||
s.hide({ reason: 'boot-complete', mode: 'fast' })
|
||||
assert.ok(s._pendingHide, 'boot-complete 走软隐藏')
|
||||
|
||||
// 非 boot-complete reason 走强隐藏分支
|
||||
s.hide({ reason: 'manual', mode: 'fast' })
|
||||
// 强关后 _pendingHide=null, _visible=false
|
||||
assert.equal(s._pendingHide, null, 'manual reason 强关后 _pendingHide 被清')
|
||||
assert.equal(s._visible, false, 'manual reason 强关后 _visible=false')
|
||||
})
|
||||
|
||||
test('hide() 重复调用: visible=false 后是 noop', () => {
|
||||
const s = makeSplashState()
|
||||
s.hide({ reason: 'boot-complete', mode: 'fast' })
|
||||
// 强制 hide
|
||||
s._doHide({ reason: 'forced' })
|
||||
assert.equal(s._visible, false)
|
||||
|
||||
const pendingBefore = s._pendingHide
|
||||
s.hide({ reason: 'manual', mode: 'fast' }) // visible=false, 应该 noop
|
||||
// 强制 hide 后 _pendingHide 被清 (看 _doHide: this._pendingHide = null)
|
||||
// 再次 hide 是 noop, _pendingHide 仍是 null
|
||||
assert.equal(s._pendingHide, pendingBefore, 'visible=false 后 hide 是 noop')
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// 7. 业务场景: 鸿蒙完整时序模拟
|
||||
// =============================================================
|
||||
test('鸿蒙场景: 模拟 onLaunch → boot 完成 → splash hide(strict) → mount → diy 加载 → notifyPageReady', () => {
|
||||
const s = makeSplashState()
|
||||
const events = []
|
||||
const origEmit = s._emit.bind(s)
|
||||
s._emit = (event, payload) => {
|
||||
events.push({ event, payload })
|
||||
origEmit(event, payload)
|
||||
}
|
||||
|
||||
// t=0: onLaunch 启动, splash 已显示
|
||||
// t=2s: boot 完成, App.vue 调 hide({ mode: 'strict' })
|
||||
s.hide({ reason: 'boot-complete', mode: 'strict' })
|
||||
assert.equal(s._visible, true, 'hide 后仍 visible (软隐藏)')
|
||||
assert.equal(s._mountGraceId, null, 'strict 不设 mountGraceId')
|
||||
assert.equal(s._pageReadyGraceId, null, 'strict 不设 pageReadyGraceId')
|
||||
|
||||
// t=2.5s: Vue 首页 mount
|
||||
s.notifyMounted()
|
||||
// strict 模式: 不设 pageReadyGraceId, 不会"提前"关
|
||||
assert.equal(s._pageReadyGraceId, null, 'strict: mount 后仍不设 pageReadyGraceId')
|
||||
|
||||
// t=4s: diy-group 渲染完, 调 notifyPageReady
|
||||
s.notifyPageReady()
|
||||
// setTimeout 50ms 后 _flushPendingHide
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// 此时应该真关
|
||||
assert.equal(s._visible, false, 'notifyPageReady 后 splash 真关')
|
||||
const hideEvent = events.find(e => e.event === 'splash:hide')
|
||||
assert.ok(hideEvent, '发 splash:hide 事件')
|
||||
assert.equal(hideEvent.payload.reason, 'boot-complete', 'reason 应是 boot-complete')
|
||||
// 总耗时 4s, splash 一直显示 (没有"提前"关)
|
||||
resolve()
|
||||
}, 100)
|
||||
})
|
||||
})
|
||||
|
||||
test('鸿蒙场景对比: 默认模式会"提前"关 splash (演示 bug)', () => {
|
||||
const s = makeSplashState()
|
||||
const events = []
|
||||
const origEmit = s._emit.bind(s)
|
||||
s._emit = (event, payload) => {
|
||||
events.push({ event, payload })
|
||||
origEmit(event, payload)
|
||||
}
|
||||
|
||||
// t=0: hide({ mode: 'fast' }) 默认模式
|
||||
s.hide({ reason: 'boot-complete', mode: 'fast' })
|
||||
// 1s 内没 mount, 兜底: 设置 mountGraceId
|
||||
assert.ok(s._mountGraceId, '默认模式: 1s 内没 mount 兜底已设置 (这是 bug 来源)')
|
||||
|
||||
// 模拟 mount 在 1s 之前发生 (用 setTimeout 加速)
|
||||
// 实际场景: t=0.5s 时 Vue mount 完成
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// t=0.5s: 模拟 mount (此时 1s 兜底还没到, 但我们立即调 notifyMounted 模拟)
|
||||
s.notifyMounted()
|
||||
// notifyMounted 内部 setTimeout 50ms 后调 _flushPendingHide
|
||||
setTimeout(() => {
|
||||
// 此时 _pageReady=false, 默认模式会设 _pageReadyGraceId
|
||||
assert.ok(s._pageReadyGraceId,
|
||||
'默认模式会设 pageReadyGraceId (2s 兜底) - 这是 bug: 1+2=3s 后强关')
|
||||
resolve()
|
||||
}, 100)
|
||||
}, 50)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user