431 lines
16 KiB
Vue
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>
|