- 新增隐私管理器核心逻辑,支持URL路由规则匹配、远程patch签名校验与融合 - 实现启动期资源等待机制,确保弹窗在i18n、主题配置就绪后触发 - 新增启动进度条组件,展示启动阶段进度与状态 - 重构隐私弹窗组件生命周期,修复事件丢失问题并支持规则动态重评估 - 适配多端平台隐私检查逻辑,支持微信小程序、鸿蒙元服务与H5 - 支持uniacid租户隔离的持久化规则存储
522 lines
15 KiB
Vue
522 lines
15 KiB
Vue
<template>
|
|
<view>
|
|
<view class="privacy-mask" v-if="showPop">
|
|
<view class="privacy-wrap">
|
|
<view class="privacy-title">{{ i18n.title }}</view>
|
|
<view class="privacy-desc">
|
|
{{ i18n.descPrefix }}
|
|
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
|
|
<!-- #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY -->
|
|
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
|
|
<!-- #endif -->
|
|
{{ i18n.descSuffix }}
|
|
</view>
|
|
<view class="privacy-button-flex">
|
|
<button class="privacy-button-btn bg-disagree" @tap="handleDisagree">{{ i18n.disagreeBtn }}</button>
|
|
<!-- #ifdef QUICKAPP-WEBVIEW || MP-WEIXIN -->
|
|
<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>
|
|
<!-- #endif -->
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
// #ifdef QUICKAPP-WEBVIEW
|
|
import has from '@system.has'
|
|
// #endif
|
|
import PrivacyManager from '@/common/js/privacy.js';
|
|
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
|
|
|
const privacyContractPage = '/pages_tool/agreement/contenr?type=0';
|
|
const privacyServicePage = '/pages_tool/agreement/contenr?type=1';
|
|
|
|
export default {
|
|
name: 'PrivacyPopup',
|
|
mixins: [i18nLazyMixin],
|
|
data() {
|
|
return {
|
|
agree: false,
|
|
showPop: false,
|
|
privacyAuthorization: null,
|
|
privacyResolves: new Set(),
|
|
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
|
|
// 弹窗前才参与 i18n.privacyContractName 的填充
|
|
platformContractName: '',
|
|
// 弹窗文案: 模板只读 i18n.xxx
|
|
// 初值为空串, 弹窗前在 popUp() 中**同步**填充
|
|
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
|
|
// Lang.boot() 必然已完成, $lang() 返回真实翻译
|
|
i18n: {
|
|
title: '',
|
|
descPrefix: '',
|
|
descSuffix: '',
|
|
privacyContractName: '',
|
|
privacyServiceName: '',
|
|
disagreeBtn: '',
|
|
agreeBtn: '',
|
|
disagreeTip: '',
|
|
},
|
|
}
|
|
},
|
|
// 关键: 把 listener 注册放到 created (而非 mounted),
|
|
// 提前一个 microtask, 让 PrivacyManager.show() emit 的 privacy:show 不会丢
|
|
// showPrivacyPopup() 内部访问 this.showPop 等响应式属性, 必须等到 mounted,
|
|
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
|
|
created() {
|
|
try {
|
|
// #ifdef MP-WEIXIN
|
|
wx.getPrivacySetting({
|
|
success: (res) => {
|
|
this.platformContractName = res.privacyContractName || ''
|
|
}
|
|
})
|
|
// #endif
|
|
|
|
// #ifdef QUICKAPP-WEBVIEW
|
|
if (typeof has != 'undefined' && has?.getPrivacySetting) {
|
|
has.getPrivacySetting({
|
|
success: (res) => {
|
|
if (res.privacyContractName) {
|
|
this.platformContractName = res.privacyContractName
|
|
}
|
|
},
|
|
})
|
|
}
|
|
// #endif
|
|
|
|
// #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: {
|
|
/**
|
|
* 接收全局隐私弹窗事件
|
|
* 由 App.vue → PrivacyManager.show() → uni.$emit('privacy:show') 触发
|
|
* 多个实例同时监听时, 只有第一个调用 consumePendingShow 成功的实例才弹窗
|
|
*/
|
|
handlePrivacyShow() {
|
|
// 互斥: 只有一个实例能消费成功
|
|
if (!PrivacyManager.consumePendingShow()) {
|
|
return;
|
|
}
|
|
this.showPrivacyPopup()
|
|
},
|
|
|
|
/**
|
|
* 接收其他实例的关闭通知
|
|
* 当另一个 privacy-popup 实例弹窗时, 本实例关闭自己的弹窗
|
|
* 避免多个实例同时弹窗导致需要点击两次
|
|
*/
|
|
handleForceClose() {
|
|
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') 通知其他实例关闭
|
|
*/
|
|
showPrivacyPopup() {
|
|
// 先通知其他实例关闭
|
|
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
|
// 直接弹窗: boot 阶段已经保证 bundle 完整
|
|
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
|
|
// 不完整时 force 重试, 仍不完整则报错不静默回退
|
|
// 所以这里无需再检测/自愈, 信任 boot 已就绪
|
|
this.popUp()
|
|
},
|
|
|
|
/**
|
|
* 根据是否需要授权决定弹窗或 emit agree
|
|
*/
|
|
checkAndShow(needAuthorization) {
|
|
if (uni.getStorageSync('skipPrivacyCheck')) {
|
|
uni.removeStorageSync('skipPrivacyCheck');
|
|
this.$emit('agree');
|
|
return;
|
|
}
|
|
|
|
if (needAuthorization) {
|
|
const privacyAgreed = uni.getStorageSync('privacyAgreed');
|
|
if (privacyAgreed) {
|
|
this.$emit('agree')
|
|
} else {
|
|
this.showPrivacyPopup()
|
|
}
|
|
} else {
|
|
this.$emit('agree')
|
|
}
|
|
},
|
|
|
|
getPrivacySettingByQuickApp() {
|
|
// #ifdef QUICKAPP-WEBVIEW
|
|
if (typeof has != 'undefined' && has?.getPrivacySetting) {
|
|
has.getPrivacySetting({
|
|
success: (res) => {
|
|
this.checkAndShow(res.needAuthorization)
|
|
},
|
|
})
|
|
} else {
|
|
this.checkAndShow(!uni.getStorageSync('privacyAgreed'))
|
|
}
|
|
// #endif
|
|
},
|
|
|
|
/**
|
|
* 监听平台侧的隐私授权事件
|
|
* 微信小程序: wx.onNeedPrivacyAuthorization 由当前页面实例注册
|
|
* 鸿蒙元服务: has.onPrivacySettingChange
|
|
* 与 PrivacyManager 协同:
|
|
* - 若 PrivacyManager 判断需要弹窗 → 弹自定义弹窗, resolve 存入 privacyResolves
|
|
* - 若已同意 → 直接 resolve 放行
|
|
*/
|
|
listenPrivacySettingChange() {
|
|
// #ifdef MP-WEIXIN
|
|
if (wx.onNeedPrivacyAuthorization) {
|
|
wx.onNeedPrivacyAuthorization((resolve) => {
|
|
// 检查是否已同意
|
|
if (!PrivacyManager.needShow()) {
|
|
// 已同意, 直接放行
|
|
resolve({ event: 'agree' })
|
|
} else {
|
|
// 需要弹窗, 存 resolve 等用户操作
|
|
if (typeof this.privacyAuthorization === 'function') {
|
|
this.privacyAuthorization(resolve)
|
|
} else {
|
|
// 兜底: 组件未就绪, 直接放行避免 API 卡死
|
|
resolve({ event: 'agree' })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
// #endif
|
|
|
|
// #ifdef QUICKAPP-WEBVIEW
|
|
if (typeof has != 'undefined' && has?.onPrivacySettingChange) {
|
|
has.onPrivacySettingChange((res) => {
|
|
if (res.needAuthorization && PrivacyManager.needShow()) {
|
|
this.showPrivacyPopup()
|
|
}
|
|
})
|
|
}
|
|
// #endif
|
|
|
|
// #ifdef MP-HARMONY
|
|
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听
|
|
// #endif
|
|
},
|
|
|
|
/**
|
|
* 初始化监听程序(页面级)
|
|
*/
|
|
curPageShow() {
|
|
this.privacyAuthorization = resolve => {
|
|
this.privacyResolves.add(resolve)
|
|
this.showPrivacyPopup()
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 弹窗时关掉其他页面的隐私弹窗,避免重叠
|
|
*/
|
|
closeOtherPagePopUp(closePopUp) {
|
|
// 通过全局事件通知所有页面关闭弹窗
|
|
try { uni.$emit('privacy:closeOthers', this._uid); } catch (e) {}
|
|
},
|
|
|
|
openPrivacyContract() {
|
|
// #ifdef MP-WEIXIN
|
|
wx.openPrivacyContract({
|
|
success(res) {},
|
|
fail(err) {}
|
|
});
|
|
// #endif
|
|
|
|
// #ifdef QUICKAPP-WEBVIEW
|
|
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
|
has.openPrivacySetting({
|
|
success: (res) => {},
|
|
fail: (err) => {}
|
|
});
|
|
} else {
|
|
this.$util.redirectTo(privacyContractPage);
|
|
}
|
|
// #endif
|
|
|
|
// #ifdef MP-HARMONY
|
|
// 鸿蒙元服务: 使用应用内协议页面
|
|
this.$util.redirectTo(privacyContractPage);
|
|
// #endif
|
|
|
|
// #ifdef H5
|
|
this.$util.redirectTo(privacyContractPage);
|
|
// #endif
|
|
},
|
|
|
|
openPrivacyService() {
|
|
// #ifdef QUICKAPP-WEBVIEW
|
|
if (typeof has != 'undefined' && has?.openPrivacySetting) {
|
|
has.openPrivacySetting({
|
|
success: (res) => {},
|
|
fail: (err) => {}
|
|
});
|
|
} else {
|
|
this.$util.redirectTo(privacyServicePage);
|
|
}
|
|
// #endif
|
|
|
|
// #ifdef MP-HARMONY
|
|
this.$util.redirectTo(privacyServicePage);
|
|
// #endif
|
|
|
|
// #ifdef H5
|
|
this.$util.redirectTo(privacyServicePage);
|
|
// #endif
|
|
},
|
|
|
|
handleDisagree() {
|
|
this.privacyResolves.forEach(resolve => {
|
|
resolve({ event: 'disagree' })
|
|
})
|
|
this.privacyResolves.clear()
|
|
|
|
// 先通知其他实例关闭, 再关闭自己, 避免叠层
|
|
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
|
this.disPopUp()
|
|
|
|
// 修复: 拒绝时不写 privacyAgreed, 允许下次再弹
|
|
// 由 PrivacyManager 管理 storage
|
|
PrivacyManager.disagree()
|
|
|
|
uni.showModal({
|
|
content: this.i18n.disagreeTip,
|
|
success: () => {
|
|
this.$emit('disagree')
|
|
}
|
|
})
|
|
},
|
|
|
|
handleAgree() {
|
|
this.privacyResolves.forEach(resolve => {
|
|
resolve({
|
|
event: 'agree',
|
|
buttonId: 'agree-btn'
|
|
})
|
|
})
|
|
this.privacyResolves.clear()
|
|
|
|
// 先通知其他实例关闭, 再关闭自己
|
|
try { uni.$emit('privacy:forceClose'); } catch (e) {}
|
|
this.disPopUp()
|
|
|
|
// 由 PrivacyManager 管理 storage
|
|
PrivacyManager.agree()
|
|
this.$emit('agree')
|
|
},
|
|
|
|
/**
|
|
* mixin i18n-lazy 要求的方法
|
|
* 返回一个对象, key 与 data().i18n 字段一一对应
|
|
* 调用时机在 popUp() 中(此时 boot 已完成, $lang() 返回真实翻译)
|
|
*
|
|
* 这里**不做任何兜底**:
|
|
* - 无 hardcode fallback
|
|
* - 不依赖本地 lang
|
|
* - 直接信任 boot 阶段的完整性校验结果
|
|
* - 若 boot 阶段校验失败, $lang() 会返回空串, UI 显示空文案(按钮可点)
|
|
*/
|
|
i18nPrepareFn() {
|
|
let appName, privacyContractName, privacyServiceName;
|
|
|
|
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
|
|
// #ifdef H5 || MP-HARMONY
|
|
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
|
|
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
|
|
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
|
|
// #endif
|
|
// #ifdef MP-WEIXIN
|
|
appName = this.$lang('components.privacyPopup.defaultAppNameMp');
|
|
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameMp');
|
|
privacyServiceName = '';
|
|
// #endif
|
|
|
|
const result = {
|
|
title: this.$lang('components.privacyPopup.title'),
|
|
descPrefix: this.$lang('components.privacyPopup.descPrefix', { appName }),
|
|
descSuffix: this.$lang('components.privacyPopup.descSuffix', { appName }),
|
|
privacyContractName,
|
|
privacyServiceName,
|
|
disagreeBtn: this.$lang('components.privacyPopup.disagreeBtn'),
|
|
agreeBtn: this.$lang('components.privacyPopup.agreeBtn'),
|
|
disagreeTip: this.$lang('components.privacyPopup.disagreeTip'),
|
|
};
|
|
return result;
|
|
},
|
|
|
|
popUp() {
|
|
// 弹窗前**一次性**计算所有 i18n 文案
|
|
// boot 阶段已用 isBundleComplete() 保证 bundle 完整
|
|
// $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() {
|
|
this.privacyResolves.clear()
|
|
// 移除全局事件监听
|
|
uni.$off('privacy:show', this.handlePrivacyShow)
|
|
uni.$off('privacy:forceClose', this.handleForceClose)
|
|
uni.$off('privacy:re-evaluate', this.handleReEvaluate)
|
|
},
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.privacy-mask {
|
|
position: fixed;
|
|
z-index: 5000;
|
|
top: 0;
|
|
right: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.privacy-wrap {
|
|
width: 632rpx;
|
|
padding: 48rpx 30rpx;
|
|
box-sizing: border-box;
|
|
background: #fff;
|
|
border-radius: 16rpx;
|
|
}
|
|
|
|
.privacy-title {
|
|
padding: 0rpx 30rpx 40rpx 30rpx;
|
|
font-weight: 700;
|
|
font-size: 36rpx;
|
|
text-align: center;
|
|
}
|
|
|
|
.privacy-desc {
|
|
font-size: 30rpx;
|
|
color: #555;
|
|
line-height: 2;
|
|
text-align: left;
|
|
padding: 0 40rpx;
|
|
}
|
|
|
|
.privacy-link {
|
|
color: #2f80ed;
|
|
}
|
|
|
|
.privacy-button-flex {
|
|
display: flex;
|
|
padding: 20rpx 40rpx;
|
|
}
|
|
|
|
.privacy-button-btn {
|
|
color: #FFF;
|
|
font-size: 30rpx;
|
|
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
|
|
font-size: 28rpx;
|
|
/* #endif */
|
|
font-weight: 500;
|
|
line-height: 100rpx;
|
|
text-align: center;
|
|
height: 100rpx;
|
|
border-radius: 20rpx;
|
|
border: none;
|
|
background: #07c160;
|
|
flex: 1;
|
|
margin-right: 30rpx;
|
|
justify-content: center;
|
|
}
|
|
|
|
.privacy-button-btn::after {
|
|
border: none;
|
|
}
|
|
|
|
.bg-disagree {
|
|
color: #07c160;
|
|
background: #f2f2f2;
|
|
}
|
|
|
|
.bg-agree {
|
|
margin-right: 0rpx;
|
|
}
|
|
</style>
|