App.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. <script>
  2. import config from '@/config.js'
  3. import Vue from 'vue'
  4. import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin, ssoLogin } from './utils/api.js'
  5. import { syncFontScale } from './utils/fontScale.js'
  6. import { checkAppUpdate } from '@/utils/checkAppUpdate.js'
  7. Vue.prototype.$config = config
  8. export default {
  9. onLaunch: function(options) {
  10. // 尽早检测新版本:applyUpdate 会重启整机,须在任何路由/页面逻辑之前
  11. checkAppUpdate()
  12. console.log('App Launch', options)
  13. // 同步微信系统字体大小
  14. syncFontScale()
  15. // 捕获分享/邀请参数(普通分享链接 ?inviteCode=xxx):未登录写入 storage 供注册登录后绑定推荐人,已登录直接绑定
  16. this.captureReferralParams(options)
  17. // 恢复登录态:必须放在 splash 分支之前。
  18. // 原实现在普通冷启动(无深链)时于 splash 跳转处 return,导致 verifyStoredToken / tryAutoLogin
  19. // 只在「分享/小程序码进入」时才执行,普通用户冷启动拿不到任何续期机会。
  20. // 现在冷启动即异步校验/静默续期,与 2 秒 splash 动画并行,用户落地即已是有效登录态。
  21. // 本方法不做任何页面跳转,导航交由 splash 或当前页面处理,避免与 splash 的 reLaunch 抢路由。
  22. this.restoreSession()
  23. // 分享/深链冷启动(携带分享参数)放行到落地页,不套 splash,避免分享的文章/小程序页面打不开
  24. // inviteCode=推荐码(文章/活动/商品分享)| refCode=推广码 | token/familyInviteCode=家庭邀请令牌 | id=内容详情
  25. var hasDeepLink = options && options.query && (
  26. options.query.inviteCode || options.query.refCode || options.query.token ||
  27. options.query.familyInviteCode || options.query.id || options.query.ssoTicket
  28. )
  29. // 冷启动:跳转到 splash 页展示过渡动画,2秒后自动进入首页
  30. if (options.scene !== 1047 && options.scene !== 1048 && options.scene !== 1049 && !hasDeepLink) {
  31. uni.setStorageSync('_isFirstLaunch', '1')
  32. uni.redirectTo({ url: '/pages/splash/index' })
  33. return
  34. }
  35. // 跨小程序单点登录:携带 ssoTicket 进入 → 换取本地登录态
  36. this.handleSsoLogin(options)
  37. // 处理小程序码场景值(从 wxacode 扫码进入,跳过 splash)
  38. this.handleScene(options)
  39. },
  40. onShow: function(options) {
  41. console.log('App Show', options)
  42. // 每次前台都同步微信系统字体大小
  43. syncFontScale()
  44. // 热启动(含从分享卡重新进入):同样捕获分享参数
  45. this.captureReferralParams(options)
  46. // 跨小程序单点登录:热启动携带 ssoTicket 进入同样处理
  47. this.handleSsoLogin(options)
  48. // 每次 app 展示时也检查场景值(小程序码可能从冷启动后的 onShow 传入)
  49. this.handleScene(options)
  50. // 同步 storage → Vuex(防止 reLaunch/navigateTo 后 Vuex 未更新的问题)
  51. var token = uni.getStorageSync('token')
  52. if (token && token !== this.$store.state.token) {
  53. this.$store.commit('setToken', token)
  54. this.$store.commit('setFamilyId', uni.getStorageSync('familyId'))
  55. }
  56. },
  57. onHide: function() {
  58. console.log('App Hide')
  59. },
  60. methods: {
  61. // 捕获分享/邀请参数:普通分享链接 /pages/xxx?refCode=yyy 冷/热启动进入时,
  62. // 新用户(未登录)写入 storage,登录注册后由 login / user-edit 消费绑定推荐人;
  63. // 已登录用户直接绑定(boundInviteCode 防重,避免重复请求)
  64. captureReferralParams(options) {
  65. if (!options || !options.query) return
  66. var query = options.query
  67. // 推荐码统一为 refCode;inviteCode 兼容历史分享链接(旧 share-mixin 用 inviteCode 承载推荐码)
  68. var code = query.refCode || query.inviteCode
  69. if (!code) return
  70. if (uni.getStorageSync('token')) {
  71. var bound = uni.getStorageSync('boundInviteCode')
  72. if (bound === code) return
  73. bindReferralIfNotSelf(code).then(function(res) {
  74. if (res && (res.code === 200 || res.code === 400)) {
  75. uni.setStorageSync('boundInviteCode', code)
  76. console.log('分享参数推荐人绑定结果', res.code, code)
  77. }
  78. }).catch(function(e) {
  79. console.log('分享参数推荐人绑定失败', e)
  80. })
  81. return
  82. }
  83. uni.setStorageSync('refCode', code)
  84. if (options.query.inviteType) {
  85. uni.setStorageSync('inviteType', options.query.inviteType)
  86. }
  87. },
  88. // 冷启动恢复登录态(异步执行,不做任何页面跳转)
  89. // 1) 有 token:先同步到 Vuex。若 token 已过期/失效,/api/auth/verify 会返回 code=401,
  90. // 由 request 封装统一走 _handle401 → silentRelogin 静默续期并重放,全程用户无感。
  91. // 2) 无 token 但本地有 openid(此前登录过、token 已被清理):走静默续期。
  92. // 3) 两者都没有:本设备从未登录,属新用户,保持未登录态,由登录页引导。
  93. restoreSession() {
  94. // TabBar 始终显示 5 个底签,不因登录状态隐藏
  95. const token = uni.getStorageSync('token')
  96. if (token) {
  97. this.$store.commit('setToken', token)
  98. // 冷启动恢复 familyId(登录时已持久化,hasFamily getter 依赖)
  99. var storedFamilyId = uni.getStorageSync('familyId')
  100. if (storedFamilyId) {
  101. this.$store.commit('setFamilyId', storedFamilyId)
  102. } else {
  103. // 历史用户(8ddc5b55 后未重新登录):本地无 familyId,从接口恢复
  104. this.restoreFamilyIdFromApi()
  105. }
  106. // 启动时校验 token 是否仍有效,过期则触发静默续期
  107. this.verifyStoredToken()
  108. } else {
  109. this.tryAutoLogin()
  110. }
  111. },
  112. // 校验本地 token 是否有效。
  113. // 注意:/api/auth/verify 在 JwtInterceptor 的白名单里,不会被拦截器判 401,而是由
  114. // AuthController 自行解析后返回 code=401 的响应体 —— 同样会被 request 封装识别并续期。
  115. // 早期版本在 develop 环境直接 return,导致开发版完全无法验证续期逻辑,已移除该屏蔽。
  116. verifyStoredToken() {
  117. verifyToken().then(function() {
  118. // token 有效,无需处理
  119. }).catch(function(e) {
  120. // 401:由 request 封装统一触发 _handle401(silentRelogin 静默续期后重放原请求);
  121. // 只有续期也失败时才由 _clearAuthAndRedirect 清空登录态并跳登录页。
  122. // 其它错误(如网络失败)静默忽略,不打扰用户
  123. console.log('[App] token 校验未通过:', e && e.code)
  124. })
  125. },
  126. // 从接口恢复 familyId(历史用户冷启动,本地无 familyId 缓存)
  127. restoreFamilyIdFromApi() {
  128. var self = this
  129. checkUserFamily().then(function(res) {
  130. if (res && res.data && res.data.inFamily && res.data.familyId) {
  131. self.$store.commit('setFamilyId', res.data.familyId)
  132. console.log('[App] familyId 已从接口恢复:', res.data.familyId)
  133. }
  134. }).catch(function(e) {
  135. console.log('[App] familyId 接口恢复失败(页面按无家庭态渲染)', e)
  136. })
  137. },
  138. // 无本地 token 时尝试静默续期。
  139. // 走 uni.login() → /api/auth/silent-login(服务端 code2Session):微信签发的一次性 code
  140. // 能证明调用方持有该微信设备,因此不可伪造;本地 openid 仅用于判断「本设备曾登录过」,
  141. // 绝不作为凭证上送(旧的 autoLogin(openid) 已被后端废弃为 410 Gone)。
  142. // 早期版本在 develop 环境直接 return,开发版无法验证续期,已移除该屏蔽。
  143. // 不做页面跳转:冷启动由 splash 在 2 秒后统一 reLaunch 到首页,
  144. // 此处若再 switchTab 会与其抢路由(splash 先落地时 switchTab 落在已销毁页面上)。
  145. async tryAutoLogin() {
  146. try {
  147. var loginData = await silentRelogin()
  148. if (loginData) {
  149. this.saveLoginInfo(loginData)
  150. console.log('[App] 静默续期成功,已恢复登录态')
  151. } else {
  152. // 本设备从未登录过,或续期失败:保持未登录态,由登录页引导
  153. console.log('[App] 静默续期未成功,需手动登录')
  154. }
  155. } catch (e) {
  156. console.log('[App] 静默续期异常,需手动登录', e)
  157. }
  158. },
  159. saveLoginInfo(data) {
  160. uni.setStorageSync('token', data.token)
  161. uni.setStorageSync('userId', data.userId)
  162. uni.setStorageSync('role', data.role)
  163. // 自动登录后重置到真实登录角色,避免残留切换状态
  164. uni.setStorageSync('currentRole', data.role)
  165. uni.setStorageSync('isSwitchedChild', false)
  166. uni.setStorageSync('isSwitchedTeacher', false)
  167. if (data.openid) {
  168. uni.setStorageSync('openid', data.openid)
  169. }
  170. // familyId 必须持久化:家庭在注册时已由后端创建,缺失会导致页面误判无家庭而重复创建
  171. uni.setStorageSync('userInfo', {
  172. nickname: data.nickname,
  173. userId: data.userId,
  174. familyId: data.familyId
  175. })
  176. this.$store.commit('setToken', data.token)
  177. // 同步 familyId 到 store(hasFamily getter 依赖;静默续期路径)
  178. this.$store.commit('setFamilyId', data.familyId)
  179. },
  180. // 跨小程序单点登录:从 train 跳转进入时携带 ssoTicket → 换取本地登录态。
  181. // 一次性票据(后端原子消费),用 _ssoHandledTicket 去重,避免 onLaunch/onShow 双处理。
  182. handleSsoLogin(options) {
  183. if (!options || !options.query) return
  184. var ticket = options.query.ssoTicket
  185. if (!ticket) return
  186. if (this._ssoHandledTicket === ticket) return
  187. this._ssoHandledTicket = ticket
  188. var self = this
  189. uni.showLoading({ title: '登录中...' })
  190. ssoLogin(ticket).then(function(res) {
  191. uni.hideLoading()
  192. if (res && res.code === 200 && res.data) {
  193. self.saveLoginInfo(res.data)
  194. // 清空路由票据(防转发泄漏)并落地首页;index/index 会按角色重新分流
  195. uni.reLaunch({ url: '/pages/index/index' })
  196. } else {
  197. uni.showToast({ title: (res && res.message) || '单点登录失败', icon: 'none' })
  198. }
  199. }).catch(function(e) {
  200. uni.hideLoading()
  201. uni.showToast({ title: (e && e.message) || '单点登录失败', icon: 'none' })
  202. })
  203. },
  204. // 处理小程序码场景值
  205. handleScene(options) {
  206. if (!options || !options.query) return
  207. var scene = options.query.scene
  208. if (!scene) return
  209. var sceneDecoded = decodeURIComponent(scene)
  210. if (sceneDecoded.indexOf('invite=') === 0) {
  211. var token = sceneDecoded.substring(7)
  212. uni.navigateTo({
  213. url: '/pages/invite/join?token=' + token
  214. })
  215. } else if (sceneDecoded.indexOf('ref=') === 0) {
  216. var refCode = sceneDecoded.substring(4)
  217. if (!refCode) return
  218. // 已登录则自动绑定推荐人;未登录存入 storage 待登录后绑定
  219. if (uni.getStorageSync('token')) {
  220. bindReferralIfNotSelf(refCode).then(function(res) {
  221. if (res && (res.code === 200 || res.code === 400)) {
  222. uni.setStorageSync('boundInviteCode', refCode)
  223. }
  224. }).catch(function() {})
  225. } else {
  226. uni.setStorageSync('refCode', refCode)
  227. }
  228. }
  229. }
  230. }
  231. }
  232. </script>
  233. <style lang="scss">
  234. @import url('./common/uni.css');
  235. page {
  236. background-color: var(--bg, $uni-bg-color);
  237. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
  238. 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
  239. 'Helvetica Neue', Helvetica, Arial, sans-serif;
  240. color: var(--text, $uni-text-color);
  241. font-size: 28rpx;
  242. line-height: 1.6;
  243. -webkit-font-smoothing: antialiased;
  244. /* 全局字号缩放系数默认值:页面根节点用 :style="{'--font-scale': fontScale}" 覆盖 */
  245. --font-scale: 1;
  246. }
  247. /* 页面容器 */
  248. .page {
  249. padding: 0 32rpx 32rpx;
  250. min-height: 100vh;
  251. }
  252. .page--no-padding {
  253. padding: 0;
  254. }
  255. /* 分段标题 */
  256. .section-header {
  257. font-weight: 700;
  258. font-size: 32rpx;
  259. margin: 32rpx 0 16rpx;
  260. color: var(--text, $uni-text-color);
  261. display: flex;
  262. align-items: center;
  263. justify-content: space-between;
  264. border-left: 8rpx solid $uni-warm;
  265. padding-left: 20rpx;
  266. }
  267. .section-header__more {
  268. font-size: 24rpx;
  269. color: var(--muted, $app-text-subtle);
  270. font-weight: 400;
  271. }
  272. /* 安全区底部 */
  273. .safe-area-bottom {
  274. padding-bottom: calc(constant(safe-area-inset-bottom) + 20rpx);
  275. padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
  276. }
  277. </style>