App.vue 12 KB

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