Files
biz-mobile-cloud-zax/src/App.vue
T
2026-09-08 11:03:04 +08:00

431 lines
16 KiB
Vue

<script>
import { adApi, userApi } from "@@/api"
import { storageConstant } from "@@/constant"
import pinia from "@@/stores/index"
import { LOCALE_CHANGED_EVENT, useLocaleStore } from "@@/stores/locale"
import { useRiderPushBannerStore } from "@@/stores/rider-push-banner"
import { useAppMessageStore } from "@@/stores/app-message"
import { t } from "@@/utils/i18n-text"
import { globalWebSocket } from "@/sheep/utils/global-websocket"
import { handleCanteenDeliveryWebSocketFrame } from "@/sheep/utils/canteen-delivery-ws-push"
import jgPush from "@/sheep/utils/jg-push"
import { consumePendingPushRoute } from "@/sheep/utils/push-route"
import { showPermissionUsage } from "@/sheep/utils/permission"
import { getCurrentFullRoute, isPublicPage } from "@@/router"
/**
* 当前未授权周期的通知权限用途说明已处理标记。
* v4 会忽略旧版缺少设置页返回复查的记录;权限恢复后删除该标记,使用户后续再次关闭通知时可重新收到一次提示。
*/
const PUSH_PERMISSION_USAGE_HANDLED_STORAGE_KEY = "push_permission_usage_handled_v4"
/** 消息列表页自行提供通知授权入口,全局流程在该页面前台时不重复展示弹框。 */
const MESSAGE_LIST_PAGE_ROUTE = "pages/message/list"
/** iOS 原生授权框关闭后等待界面恢复稳定的时间,避免紧接着展示的业务弹框被系统转场吞掉。 */
const IOS_PERMISSION_GUIDE_DELAY_MS = 400
/** 通知权限状态同步任务,避免 onLaunch 与 onShow 并发时重复展示用途说明。 */
let pushPermissionSyncPromise = null
/** 是否正在等待用户从 iOS 通知设置页返回;仅当前 App 进程内有效,不会造成跨启动重复提示。 */
let pushNotificationSettingsAwaitingReturn = false
export default {
data() {
return {
isVisibleAd: false,
system: uni.getSystemInfoSync(),
}
},
/** 初始化全局导航、App 原生 Push、未读角标和业务 WebSocket。 */
async onLaunch() {
const localeStore = useLocaleStore(pinia)
localeStore.init()
await localeStore.loadRemote()
if (!this._ensureLocaleSelected()) {
return
}
try {
uni.$on(LOCALE_CHANGED_EVENT, () => {
this._syncTabbar()
})
} catch {
// ignore
}
this._initTabbar()
// #ifdef APP-PLUS
this.reportSplashAd()
// 极光属于附属能力,插件或权限初始化失败不能中断 App 启动并造成白屏。
try {
await jgPush.initialize({
debug: true,
autoInitService: true,
enableCrashHandler: true,
requestAndroidPermission: false,
requestIOSNotificationPermission: false,
})
await this._syncPushPermissionUsage()
// 收到通知时刷新未读角标;只有点击通知时才消费服务层预先写入的路由队列。
jgPush.on("notification", (event) => {
const eventType = event?.payload?.notificationEventType
if (eventType === "notificationArrived" || eventType === "notificationOpened") {
this._syncAppMessageState()
}
if (eventType === "notificationOpened") {
this._consumePendingPushRoute()
}
})
} catch (error) {
console.warn("[JGPush] App 启动时初始化失败,不影响主流程:", error)
}
// #endif
await this._syncAppMessageState()
await this._consumePendingPushRoute()
this._initGlobalWs()
},
/** App 回到前台时刷新通知权限、导航和未读数,并重试消费尚未满足条件的通知路由。 */
async onShow() {
this._syncTabbar()
this._initGlobalWs()
// #ifdef APP-PLUS
try {
const hadActivePushPermissionSync = Boolean(pushPermissionSyncPromise)
const shouldCheckPushSettingsReturn =
this.system.platform === "ios" && pushNotificationSettingsAwaitingReturn
await this._syncPushPermissionUsage()
// 打开设置的原生回调若延迟到回到前台才结束,首次同步只会等待原任务;此时补做返回复查。
if (hadActivePushPermissionSync && shouldCheckPushSettingsReturn && pushNotificationSettingsAwaitingReturn) {
await this._syncPushPermissionUsage()
}
} catch (error) {
console.warn("[JGPush] App 回到前台时同步通知权限失败,不影响主流程:", error)
}
// #endif
await this._syncAppMessageState()
await this._consumePendingPushRoute()
},
onHide() {
},
methods: {
/**
* 合并并发的通知权限状态同步任务。
* App 首次启动与回到前台可能同时触发,共用同一任务可避免连续执行两次并出现重复授权弹框。
*/
async _syncPushPermissionUsage() {
if (!jgPush.isInitialized()) return
if (pushPermissionSyncPromise) return pushPermissionSyncPromise
const currentSyncPromise = this._performPushPermissionUsageSync()
pushPermissionSyncPromise = currentSyncPromise
try {
await currentSyncPromise
} finally {
if (pushPermissionSyncPromise === currentSyncPromise) pushPermissionSyncPromise = null
}
},
/**
* 读取系统通知权限并按当前状态选择授权方式。
* Android 使用通知设置页保证已关闭总开关时仍有可操作入口;iOS 仅在系统尚未询问时直接请求授权。
*/
async _performPushPermissionUsageSync() {
let pushStatus = jgPush.getStatus()
const iosPermissionAlreadyRequested =
this.system.platform === "ios" &&
(pushStatus.notificationPermission === "granted" ||
pushStatus.notificationPermission === "provisional" ||
pushStatus.notificationPermission === "denied")
if (this.system.platform === "android") {
pushStatus = await jgPush.refreshStatus()
} else if (iosPermissionAlreadyRequested) {
// iOS 已经完成过系统询问后再次调用只会刷新状态,不会越过用途说明主动弹出首次授权框。
pushStatus = await jgPush.refreshStatus(true)
}
const pushPermissionGranted =
pushStatus.notificationPermission === "granted" ||
pushStatus.notificationPermission === "provisional" ||
pushStatus.notificationEnabled === true
if (pushPermissionGranted) {
// 每次恢复授权都重新开启下一次“由开启变为关闭”的提示机会。
pushNotificationSettingsAwaitingReturn = false
uni.removeStorageSync(PUSH_PERMISSION_USAGE_HANDLED_STORAGE_KEY)
return
}
const pages = getCurrentPages()
if (pages[pages.length - 1]?.route === MESSAGE_LIST_PAGE_ROUTE) {
pushNotificationSettingsAwaitingReturn = false
return
}
if (this.system.platform === "ios" && pushNotificationSettingsAwaitingReturn) {
pushNotificationSettingsAwaitingReturn = false
const shouldRetrySettings = await this._showPushPermissionDeniedGuide()
if (shouldRetrySettings) await this._openPushNotificationSettings()
return
}
if (uni.getStorageSync(PUSH_PERMISSION_USAGE_HANDLED_STORAGE_KEY)) return
const shouldOpenSettings =
this.system.platform === "android" ||
(this.system.platform === "ios" &&
pushStatus.notificationPermission !== "unknown" &&
pushStatus.notificationPermission !== "not_determined")
const shouldRequestPushPermission = await showPermissionUsage(
"通知",
{
feature: "消息通知",
purpose: "用于接收订单、配送、核销和系统服务状态等重要消息提醒。",
},
shouldOpenSettings ? "去设置" : "去授权",
)
// 确认或取消都视为本次未授权周期已处理;恢复授权后会删除该标记。
uni.setStorageSync(PUSH_PERMISSION_USAGE_HANDLED_STORAGE_KEY, true)
if (!shouldRequestPushPermission) return
try {
if (shouldOpenSettings) {
await this._openPushNotificationSettings()
return
}
if (this.system.platform === "ios") {
await jgPush.requestNotificationAuthorization()
const updatedStatus = jgPush.getStatus()
const notificationPermissionGranted =
updatedStatus.notificationPermission === "granted" ||
updatedStatus.notificationPermission === "provisional" ||
updatedStatus.notificationEnabled === true
if (notificationPermissionGranted) {
uni.removeStorageSync(PUSH_PERMISSION_USAGE_HANDLED_STORAGE_KEY)
return
}
if (updatedStatus.notificationPermission === "denied") {
const shouldOpenSettingsAfterDenied = await this._showPushPermissionDeniedGuide()
if (shouldOpenSettingsAfterDenied) await this._openPushNotificationSettings()
}
}
} catch (error) {
console.warn("[JGPush] 打开通知授权入口失败:", error)
uni.showToast({ title: "无法打开通知设置,请在系统设置中手动开启", icon: "none" })
}
},
/**
* iOS 首次系统授权被拒绝后提供一次设置入口。
* 系统授权框无法再次主动拉起,因此这里只做单次补救引导,取消后继续沿用本轮已提示标记。
*
* @returns 用户选择前往系统设置时返回 true,取消或弹框失败时返回 false。
*/
async _showPushPermissionDeniedGuide() {
await new Promise((resolve) => setTimeout(resolve, IOS_PERMISSION_GUIDE_DELAY_MS))
return new Promise((resolve) => {
uni.showModal({
title: "通知权限未开启",
content: "请前往系统设置开启通知权限,以便接收订单、配送、核销和系统服务状态等重要消息。",
confirmText: "去设置",
cancelText: "取消",
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
},
/**
* 打开系统通知设置页,并记录 iOS 返回前台后需要重新检查权限。
* 插件明确返回失败时会恢复等待状态并提示用户,避免按钮点击后静默无反馈。
*
* @returns 系统设置页成功打开时返回 true,失败时返回 false。
*/
async _openPushNotificationSettings() {
if (this.system.platform === "ios") pushNotificationSettingsAwaitingReturn = true
try {
const result = await jgPush.openSettingsForNotification()
if (result?.code != null && result.code !== 0) throw new Error(`打开通知设置失败,错误码:${result.code}`)
return true
} catch (error) {
pushNotificationSettingsAwaitingReturn = false
console.warn("[JGPush] 打开通知设置页失败:", error)
uni.showToast({ title: "无法打开通知设置,请在系统设置中手动开启", icon: "none" })
return false
}
},
/** 刷新登录用户未读数并同步原生角标;未登录时清理上一账号的角标。 */
async _syncAppMessageState() {
const tokenStorage = uni.getStorageSync(storageConstant.TOKEN)
const token = tokenStorage?.access_token || tokenStorage?.accessToken || tokenStorage?.token
const messageStore = useAppMessageStore(pinia)
if (!token) {
messageStore.reset()
return
}
await messageStore.refreshUnreadCount()
},
/** 尝试消费通知点击路由;页面栈或登录态未就绪时队列会保留到下一次重试。 */
async _consumePendingPushRoute() {
await consumePendingPushRoute()
},
_initGlobalWs() {
try {
const tokenStorage = uni.getStorageSync(storageConstant.TOKEN)
const token = tokenStorage?.access_token || tokenStorage?.accessToken || tokenStorage?.token
if (!token) return
const riderBanner = useRiderPushBannerStore(pinia)
globalWebSocket.start({
uri: import.meta.env.VITE_WS_URI || "/admin/ws/info",
heartbeat: import.meta.env.VITE_WS_HEARTBEAT !== "false",
maxReconnect: -1,
onMessage: (text) => {
try {
uni.vibrateShort()
} catch { }
const results = handleCanteenDeliveryWebSocketFrame(text)
if (results.length === 0) {
uni.showToast({ title: t("common.newMessage", "收到新消息"), icon: "none", duration: 3500 })
} else {
for (const r of results) {
if (
r.handled &&
(r.kind === "order_assigned" ||
r.kind === "merchant_new_order" ||
r.kind === "merchant_scheduled_remind")
) {
riderBanner.showFromBanner(r.banner)
} else if (r.handled && r.kind === "other_biz") {
uni.showToast({ title: r.toastTitle, icon: "none", duration: 4000 })
}
}
}
console.log("[WS] message:", text)
},
})
} catch (e) {
console.log("[WS] init failed", e)
}
},
async _getUserInfo() {
const { data: userData } = await userApi.userInfo()
uni.setStorageSync(storageConstant.USERINFO, userData)
this.isVisibleAd = true
if (userData && userData.userTags) {
const vip = userData.userTags.find((item) => item.bizType == "10")
if (vip) {
this.isVisibleAd = false
}
}
return userData
},
async _initTabbar() {
const tabbarStore = await this._getTabbarStore()
tabbarStore?.init()
},
async _syncTabbar() {
const tabbarStore = await this._getTabbarStore()
tabbarStore?.syncByCurrentRoute()
tabbarStore?.syncTabbarI18n?.()
},
_ensureLocaleSelected() {
const selected = uni.getStorageSync(storageConstant.LOCALE_SELECTED)
if (selected) {
return true
}
if (uni.getStorageSync(storageConstant.LOCALE)) {
uni.setStorageSync(storageConstant.LOCALE_SELECTED, "1")
return true
}
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
const route = cur?.route || ""
if (route.includes("pages/root/language")) {
return true
}
// 保留邀请注册等深链,选完语言后回跳,避免未登录用户被送到登录页
let languageUrl = "/pages/root/language"
try {
const full = getCurrentFullRoute()
if (full && isPublicPage(full.split("?")[0]) && !full.includes("pages/root/language")) {
languageUrl = `/pages/root/language?redirect=${encodeURIComponent(full)}`
}
} catch {
// ignore
}
uni.reLaunch({ url: languageUrl })
return false
},
async _getTabbarStore() {
// #ifdef APP-NVUE
return null
// #endif
const { useTabbarStore } = await import("@@/stores/tabbar")
return useTabbarStore(pinia)
},
async reportSplashAd() {
try {
const userInfo = uni.getStorageSync(storageConstant.USERINFO)
if (!userInfo || !userInfo.sysUser) {
return
}
const platform = uni.getSystemInfoSync()?.platform
const { code } = await adApi.reportAdView({
userId: userInfo.sysUser.userId,
adId: "",
adType: "80",
visitPlatform: platform,
visitStartTime: "",
visitEndTime: "",
extra: "",
})
if (code == 0) {
console.log("|上报开屏广告|成功的回调code:", code)
}
} catch (error) {
console.log("|上报开屏广告|失败的回调error:", error)
}
},
},
}
</script>
<style lang="scss">
// tm-ui
@import url(@/uni_modules/tm-ui/css/remixicon.min.css);
@import url(@/uni_modules/tm-ui/css/tmui.css);
// 全局
@import "@/sheep/scss/global.scss";
$theme-green: #06be36;
$theme-blue: #0094ff;
$theme-gray: #999;
.botder_btn {
border: 1px solid #999;
color: #999;
font-weight: 500;
font-size: 28rpx;
border-radius: 10rpx;
padding: 8rpx 15rpx;
}
.primary_btn {
border: 1px solid white;
background: $theme-blue;
color: white;
font-weight: 500;
font-size: 28rpx;
border-radius: 10rpx;
padding: 8rpx 15rpx;
}
.prefix_title {
font-size: 32rpx;
font-weight: 500;
color: #333;
&::before {
content: "";
display: inline-block;
width: 8rpx;
height: 24rpx;
background: $theme-blue;
border-radius: 4rpx;
margin-right: 10rpx;
}
}
</style>