Files
lucky_shop/components/privacy-popup/privacy-popup.vue
2026-08-29 17:44:26 +08:00

585 lines
18 KiB
Vue

<template>
<!-- #ifdef MP-HARMONY -->
<!--
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
-->
<!-- #endif-->
<!-- #ifndef MP-HARMONY -->
<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 -->
<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 -->
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
}}</button>
<!-- #endif -->
</view>
</view>
</view>
</view>
<!-- #endif -->
</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: '',
// 平台协议名称就绪 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() 完成,
// 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 {
// 创建平台协议名称就绪 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
// #ifdef QUICKAPP-WEBVIEW
if (typeof has != 'undefined' && has?.getPrivacySetting) {
has.getPrivacySetting({
success: (res) => {
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
// 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
// 立即 resolve, 让 popUp 的 await 不永久阻塞
this._resolvePlatformContractName && this._resolvePlatformContractName()
// #endif
// #ifdef H5
// H5 无平台隐私 API, 立即 resolve
this._resolvePlatformContractName && this._resolvePlatformContractName()
// #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') 通知其他实例关闭
*/
async showPrivacyPopup() {
// 先通知其他实例关闭
try { uni.$emit('privacy:forceClose'); } catch (e) { }
// 直接弹窗: boot 阶段已经保证 bundle 完整
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
// 不完整时 force 重试, 仍不完整则报错不静默回退
// 所以这里无需再检测/自愈, 信任 boot 已就绪
await 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) => {
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
if (typeof this.privacyAuthorization === 'function') {
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
this.privacyAuthorization(resolve)
} else {
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
// (disagree 无需 buttonId, 不会产生非法 resolve)
resolve({ event: 'disagree' })
}
})
}
// #endif
// #ifdef QUICKAPP-WEBVIEW
if (typeof has != 'undefined' && has?.onPrivacySettingChange) {
has.onPrivacySettingChange((res) => {
if (res.needAuthorization && PrivacyManager.needShow()) {
this.showPrivacyPopup()
}
})
}
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
// #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
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
// (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
// #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
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
// #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
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;
},
async popUp() {
// 弹窗前**一次性**计算所有 i18n 文案
// 必须先等两类数据准备好:
// 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) { }
},
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)
// 销毁时强制 resolve, 避免 popUp() 中的 await 永久挂起 (例如用户提前离开页面)
if (typeof this._resolvePlatformContractName === 'function') {
this._resolvePlatformContractName()
this._resolvePlatformContractName = null
}
},
}
</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 */
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>