topfans/frontend/App.vue

417 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { getGlobalSocket } from "@/utils/socket";
import { emitAppReturnFromBackground } from "@/utils/backgroundRefreshBus.js";
import { registerDeviceApi, unregisterDeviceApi } from "@/utils/api.js";
import { getDeviceFingerprint } from "@/utils/deviceFingerprint.js";
import { initPreloadApi, setPreloadApi, getPreloadApi } from '@/utils/preloadApi/index'
import { preloadConfig } from '@/config/preload.config'
// 记录上次隐藏时间的 storage key
const HIDE_TIME_KEY = "app_last_hide_time";
export default {
// 全局内存级标记,配合 uni.storage 中的 needs_welcome 一起判定
// TopfansWelcome 是否展示。onLaunch 触发时重置为 false冷启动 = 新会话),
// 进入 square 后由 handleEnterTopfans 设为 true本次会话内不再展示
globalData: {
welcomeShownThisSession: false,
// 冷启动时是否需要跳转一键登录onLaunch 设置onShow 消费后立即清除)
shouldShowQuickLogin: false,
},
onLaunch: function () {
console.log("App Launch");
// 冷启动时检查本地 token标记是否需要跳转到一键登录页
// 仅 onLaunch 触发(冷启动/app 销毁后重新打开),后台切回前台不触发
// 通过 HIDE_TIME_KEY 判断 App 是否短时间内被系统重启(卡顿/网络切换/内存回收):
// 若切后台 5 分钟内又触发 onLaunch说明是系统被动重启跳过 quickLogin 保持原页面
const token = uni.getStorageSync("access_token");
const lastHideTime = uni.getStorageSync(HIDE_TIME_KEY) || 0;
const RECENT_RESTART_THRESHOLD = 5 * 60 * 1000; // 5 分钟
const isRecentRestart = Date.now() - lastHideTime < RECENT_RESTART_THRESHOLD;
this.globalData.shouldShowQuickLogin = !!token && !isRecentRestart;
// 系统被动重启时,清除 needs_welcome防止回到 square 时误触发 TopfansWelcome
// welcomeShownThisSession 会被重置为 false若 needs_welcome 还在 storage 就会弹欢迎页)
if (isRecentRestart) {
uni.removeStorageSync("needs_welcome");
}
// 【TopfansWelcome 会话判定】
// 触发场景(即用户期望展示欢迎页的入口):
// 1) "后台关闭才会在打开":用户在后台杀掉进程后重新打开 → onLaunch 触发 → 新会话
// 2) "每次第一次启动":冷启动 / 全新安装后首次启动 → onLaunch 触发 → 新会话
// 不触发的场景:
// - 仅按 home 键前后台切换onShow 而非 onLaunch→ 标记保持原值 → 不展示
// - 用户已经点过"进入 TOPFANS 世界" → 标记为 true → 不展示
// 注storage 中的 needs_welcome 仍保留,由登录/注册流程负责写入,
// 只有 app 销毁(卸载/清缓存)才会随本地存储一起丢失。
this.globalData.welcomeShownThisSession = false;
// 启动早期 bootstrap 设备指纹spec §9.1 v2.3 — X-Device-Fingerprint
// getDeviceFingerprint 内部自包含 setStorageSync但显式调一次可避免首次网络请求走临时生成
getDeviceFingerprint();
// 不在这里初始化 AI Chat 连接,由各页面自行管理
// ★ 新增:初始化预加载 API
try {
const api = initPreloadApi(preloadConfig)
setPreloadApi(api)
api.warmStartup()
} catch (e) {
console.warn('[preload] init failed:', e.message)
}
this.setPermissions();
},
onShow: function () {
console.log("App Show");
// 冷启动时如果有本地 token跳转到一键登录页仅首次 onShow 执行一次)
// 后台切回前台时 shouldShowQuickLogin 为 false不跳转直接回到挂载时的页面
if (this.globalData.shouldShowQuickLogin) {
this.globalData.shouldShowQuickLogin = false;
// 清除 needs_welcome避免 App 重启后 quickLogin → square 误触发 TopfansWelcome
// 首次登录/注册的欢迎页由 login.vue / selectRole.vue 各自负责写入
uni.removeStorageSync("needs_welcome");
// 即使跳转到一键登录,仍需初始化推送服务和清除角标
this.getAllNotice();
this.clearBadgeAndNotifications();
uni.reLaunch({ url: "/pages/login/quickLogin" });
return;
}
this.handleBackgroundReturn();
// ★ 新增idle 预拉
try {
const api = getPreloadApi()
if (api) api.warmIdle()
} catch (e) {
// silent
}
// 从后台切回前台时重新初始化 WebSocketonHide 中已关闭)
this.initWebSocket();
this.getAllNotice();
// 打开 App 时清除图标角标和通知栏
this.clearBadgeAndNotifications();
},
onHide: function () {
console.log("App Hide");
// 关闭所有 WebSocket 连接
this.closeWebSocket();
// 记录切到后台的时间,用于 onShow 时判断是否"从后台返回"
uni.setStorageSync(HIDE_TIME_KEY, Date.now());
// 应用进入后台时创建本地通知
this.getAllNotice();
},
methods: {
initWebSocket() {
const token = uni.getStorageSync("access_token");
if (token) {
console.log("初始化全局 WebSocket 连接");
const globalSocket = getGlobalSocket();
globalSocket.init(token);
}
},
closeWebSocket() {
console.log("关闭全局 WebSocket 连接");
const globalSocket = getGlobalSocket();
globalSocket.closeAll();
},
/**
* 处理"从后台切回前台"事件
* 通过 storage 中的上次隐藏时间判断本次 onShow 是否由后台返回触发
* 若是,则通知所有订阅了 useBackgroundRefresh 的页面执行刷新
*/
handleBackgroundReturn() {
const lastHide = uni.getStorageSync(HIDE_TIME_KEY) || 0;
if (!lastHide) return;
// 清理标记,避免下次普通 onShow如首次启动误触发
uni.removeStorageSync(HIDE_TIME_KEY);
// 时间异常保护
if (Date.now() - lastHide < 0) return;
emitAppReturnFromBackground();
},
// 获取消息列表,刚进页面时,在钩子内触发
getAllNotice() {
// 1 获取客户端推送标识信息 cid , 必须要获取到cid后才能接收推送信息
uni.getPushClientId({
success: (res) => {
// 将获取到的cid存起来方便其它页面从缓存中获取
uni.setStorageSync("cid", res.cid);
console.log("客户端推送标识:", res.cid);
// 1.1 把 cid + 设备信息上报给后端,后端写入 user_devices 表;
// 后续后端推送时按 user_id 查这张表拿到 cid 列表。
// 静默失败即可:即使后端没收到,App 仍能正常接收推送,只是通知中心数据对不齐。
this.reportCidToServer(res.cid);
},
fail: (err) => {
console.warn("getPushClientId failed", err);
},
});
// 2 启动监听推送消息事件
uni.onPushMessage((res) => {
const { type, data } = res;
if (type == "click") {
console.log('"click"-从系统推送服务点击消息启动应用事件;', res);
if (!data?.payload?.url) {
console.log(data)
uni.reLaunch({
// url: "/pagesA/index/index",
});
} else {
setTimeout(() => {
uni.navigateTo({
url: data.payload.url,
});
}, 1000);
}
}
if (type == "receive") {
console.log('"receive"-应用从推送服务器接收到推送消息事件', res);
}
});
},
/**
* 上报 cid 给后端,失败不抛出(纯 best-effort)。
* 仅在已登录时才上报(JWT 在 storage);未登录时跳过,登录后再 onShow 触发一次。
*/
async reportCidToServer(cid) {
if (!cid) return;
const token = uni.getStorageSync("access_token");
if (!token) return;
try {
const sys = uni.getSystemInfoSync();
await registerDeviceApi({
cid,
// plus.os.name 在 APP-PLUS 下存在;其他平台兜底取 sys.platform
platform: (sys.osName || sys.platform || "").toLowerCase(),
appVersion: sys.appVersion || "",
deviceModel: sys.model || ""
});
console.log("cid reported to server ok");
} catch (err) {
console.warn("cid reported to server failed:", err);
}
},
/**
* 登出时注销当前用户所有推送设备(传空 cid)。
* 调用方示例:store/modules/user.js LogoutAction 完成后 await this.unregisterAllDevices()
*/
async unregisterAllDevices() {
try {
await unregisterDeviceApi("");
} catch (err) {
console.warn("unregisterAllDevices failed:", err);
}
},
/**
* 清除 App 图标角标 + 系统通知栏中本应用的通知。
* - iOS:plus.runtime.setBadgeNumber(0) 即可清除角标。
* - Android:setBadgeNumber(0) 对部分厂商(华为/小米/OPPO/vivo/荣耀)有效;
* 不生效的机型由 UniPush SDK 推送时按未读数自行维护;
* 额外调用 NotificationManager.cancelAll() 清掉通知栏遗留的通知。
* 调用时机:onShow(用户进入 App 时)。
*/
clearBadgeAndNotifications() {
// #ifdef APP-PLUS
try {
// 1. 清除桌面图标角标
plus.runtime.setBadgeNumber(0);
} catch (e) {
console.warn("setBadgeNumber(0) failed:", e);
}
// 2. 清除系统通知栏中本应用的所有通知(Android)
if (plus.os.name === "Android") {
try {
const main = plus.android.runtimeMainActivity();
const Context = plus.android.importClass("android.content.Context");
const notificationManager = main.getSystemService(
Context.NOTIFICATION_SERVICE
);
if (notificationManager) {
plus.android.importClass("android.app.NotificationManager");
if (typeof notificationManager.cancelAll === "function") {
notificationManager.cancelAll();
}
}
} catch (e) {
// 部分 Android 运行时未暴露 cancelAll跳过即可避免 onShow 持续报错。
}
}
// #endif
},
setPermissions() {
// #ifdef APP-PLUS
if (plus.os.name == "Android") {
// 判断是Android
var main = plus.android.runtimeMainActivity();
var pkName = main.getPackageName();
var uid = main.getApplicationInfo().plusGetAttribute("uid");
var NotificationManagerCompat = plus.android.importClass(
"android.support.v4.app.NotificationManagerCompat",
);
//android.support.v4升级为androidx
if (NotificationManagerCompat == null) {
NotificationManagerCompat = plus.android.importClass(
"androidx.core.app.NotificationManagerCompat",
);
}
var areNotificationsEnabled =
NotificationManagerCompat.from(main).areNotificationsEnabled();
// 未开通‘允许通知’权限,则弹窗提醒开通,并点击确认后,跳转到系统设置页面进行设置
if (!areNotificationsEnabled) {
uni.showModal({
title: "通知权限开启提醒",
content: "您还没有开启通知权限,无法接受到消息通知,请前往设置!",
showCancel: false,
confirmText: "去设置",
success: function (res) {
if (res.confirm) {
var Intent = plus.android.importClass("android.content.Intent");
var Build = plus.android.importClass("android.os.Build");
//android 8.0引导
if (Build.VERSION.SDK_INT >= 26) {
var intent = new Intent(
"android.settings.APP_NOTIFICATION_SETTINGS",
);
intent.putExtra("android.provider.extra.APP_PACKAGE", pkName);
} else if (Build.VERSION.SDK_INT >= 21) {
//android 5.0-7.0
var intent = new Intent(
"android.settings.APP_NOTIFICATION_SETTINGS",
);
intent.putExtra("app_package", pkName);
intent.putExtra("app_uid", uid);
} else {
//(<21)其他--跳转到该应用管理的详情页
intent.setAction(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
);
var uri = Uri.fromParts(
"package",
mainActivity.getPackageName(),
null,
);
intent.setData(uri);
}
// 跳转到该应用的系统通知设置页
main.startActivity(intent);
}
},
});
}
} else if (plus.os.name == "iOS") {
// 判断是ISO
var isOn = undefined;
var types = 0;
var app = plus.ios.invoke("UIApplication", "sharedApplication");
var settings = plus.ios.invoke(app, "currentUserNotificationSettings");
if (settings) {
types = settings.plusGetAttribute("types");
plus.ios.deleteObject(settings);
} else {
types = plus.ios.invoke(app, "enabledRemoteNotificationTypes");
}
plus.ios.deleteObject(app);
isOn = 0 != types;
if (isOn == false) {
uni.showModal({
title: "通知权限开启提醒",
content: "您还没有开启通知权限,无法接受到消息通知,请前往设置!",
showCancel: false,
confirmText: "去设置",
success: function (res) {
if (res.confirm) {
var app = plus.ios.invoke("UIApplication", "sharedApplication");
var setting = plus.ios.invoke(
"NSURL",
"URLWithString:",
"app-settings:",
);
plus.ios.invoke(app, "openURL:", setting);
plus.ios.deleteObject(setting);
plus.ios.deleteObject(app);
}
},
});
}
}
// #endif
},
},
};
</script>
<template>
<view class="app-container"> </view>
</template>
<style>
/*每个页面公共css */
/* 引入 TheMiladiatorRegular 字体 */
@font-face {
font-family: "TheMiladiatorRegular";
src: url("/static/fonts/The Miladiator Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* 引入 ZaoZiGongFangJianHei-1 字体 */
@font-face {
font-family: "ZaoZiGongFangJianHei-1";
src: url("/static/fonts/ZaoZiGongFangJianHei-1.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* 引入 ZaoZiGongFangJianHei-1 字体 */
@font-face {
font-family: "JDLTYuanTiJian";
src: url("/static/fonts/JDLTYuanTiJian.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* 圆体 JDLTYuanTiJian.ttf 在部分 Android WebView 上报 OTS/cmap 解析失败,暂不 @font-face 加载,避免控制台告警与渲染异常 */
/* 全局字体设置 */
body {
font-family:
"JDLTYuanTiJian",
-apple-system,
BlinkMacSystemFont,
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans SC",
sans-serif;
}
/* App 容器 */
.app-container {
width: 100%;
min-height: 100vh;
position: relative;
}
.page-content {
width: 100%;
min-height: 100vh;
}
</style>