App.vue 12 KB

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