App.vue 10 KB

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