Sfoglia il codice sorgente

Merge branch 'main' into login

feige996 8 mesi fa
parent
commit
04ab32a610
10 ha cambiato i file con 153 aggiunte e 20 eliminazioni
  1. 4 1
      env/.env
  2. 9 6
      src/App.vue
  3. 8 0
      src/api/login.ts
  4. 10 0
      src/api/types/login.ts
  5. 2 0
      src/env.d.ts
  6. 71 7
      src/http/http.ts
  7. 1 1
      src/http/interceptor.ts
  8. 5 1
      src/router/interceptor.ts
  9. 37 3
      src/store/user.ts
  10. 6 1
      src/typings.d.ts

+ 4 - 1
env/.env

@@ -18,4 +18,7 @@ VITE_APP_PROXY_ENABLE = true
 VITE_APP_PROXY_PREFIX = '/api'
 
 # 第二个请求地址 (目前alova中可以使用)
-VITE_API_SECONDARY_URL = 'https://ukw0y1.laf.run'
+VITE_API_SECONDARY_URL = 'https://ukw0y1.laf.run'
+
+# TOKEN策略,single:单token,double:双token
+VITE_TOKEN_STRATEGY = 'single'

+ 9 - 6
src/App.vue

@@ -4,15 +4,18 @@ import { navigateToInterceptor } from '@/router/interceptor'
 import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
 
 onLaunch((options) => {
-  // 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
-  // https://github.com/unibest-tech/unibest/issues/192
-  console.log('App Launch options: ', options)
-  const gotoPath = options?.path || ''
-
-  navigateToInterceptor.invoke({ url: gotoPath })
+  console.log('App Launch', options)
 })
 onShow((options) => {
   console.log('App Show', options)
+  // 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
+  // https://github.com/unibest-tech/unibest/issues/192
+  if (options?.path) {
+    navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
+  }
+  else {
+    navigateToInterceptor.invoke({ url: '/' })
+  }
 })
 onHide(() => {
   console.log('App Hide')

+ 8 - 0
src/api/login.ts

@@ -27,6 +27,14 @@ export function login(loginForm: ILoginForm) {
   return http.post<IUserLogin>('/user/login', loginForm)
 }
 
+/**
+ * 刷新token
+ * @param refreshToken 刷新token
+ */
+export function refreshToken(refreshToken: string) {
+  return http.post<IUserLogin>('/user/refreshToken', { refreshToken })
+}
+
 /**
  * 获取用户信息
  */

+ 10 - 0
src/api/types/login.ts

@@ -5,7 +5,15 @@ export interface IUserInfoVo {
   id: number
   username: string
   avatar: string
+}
+
+/**
+ * 用户token
+ */
+export interface IUserTokenVo {
   token: string
+  refreshToken?: string
+  refreshExpire?: number
 }
 
 /**
@@ -15,6 +23,8 @@ export interface IUserLogin {
   id: string
   username: string
   token: string
+  refreshToken?: string
+  refreshExpire?: number
 }
 
 /**

+ 2 - 0
src/env.d.ts

@@ -19,6 +19,8 @@ interface ImportMetaEnv {
   readonly VITE_APP_PROXY_ENABLE: 'true' | 'false'
   /** H5是否需要代理,需要的话有个前缀 */
   readonly VITE_APP_PROXY_PREFIX: string // 一般是/api
+  /** TOKEN策略,single:单token,double:双token */
+  readonly VITE_TOKEN_STRATEGY: 'single' | 'double'
   /** 上传图片地址 */
   readonly VITE_UPLOAD_BASEURL: string
   /** 是否清除console */

+ 71 - 7
src/http/http.ts

@@ -1,4 +1,13 @@
 import type { CustomRequestOptions } from '@/http/types'
+import { nextTick } from 'vue'
+import { useUserStore } from '@/store/user'
+
+// 刷新 token 状态管理
+let refreshing = false // 防止重复刷新 token 标识
+let taskQueue: (() => void)[] = [] // 刷新 token 请求队列
+
+// token 刷新策略: single-不刷新,double-无感刷新(需后端配合)
+const sessionMode = import.meta.env.VITE_TOKEN_STRATEGY === 'double' ? 'double' : 'single'
 
 export function http<T>(options: CustomRequestOptions) {
   // 1. 返回 Promise 对象
@@ -10,17 +19,72 @@ export function http<T>(options: CustomRequestOptions) {
       responseType: 'json',
       // #endif
       // 响应成功
-      success(res) {
+      success: async (res) => {
         // 状态码 2xx,参考 axios 的设计
         if (res.statusCode >= 200 && res.statusCode < 300) {
           // 2.1 提取核心数据 res.data
-          resolve(res.data as IResData<T>)
+          return resolve(res.data as IResData<T>)
         }
-        else if (res.statusCode === 401) {
-          // 401错误  -> 清理用户信息,跳转到登录页
-          // userStore.clearUserInfo()
-          // uni.navigateTo({ url: '/pages/login/login' })
-          reject(res)
+        const resData: IResData<T> = res.data as IResData<T>
+        if ((res.statusCode === 401) || (resData.code === 401)) {
+          const store = useUserStore()
+          if (sessionMode === 'single') {
+            // 未启用双token策略,清理用户信息,跳转到登录页
+            store.logout()
+            uni.navigateTo({ url: '/pages/login/login' })
+            return reject(res)
+          }
+          /* -------- 无感刷新 token ----------- */
+          const { refreshToken } = store.userToken || {}
+          // token 失效的,且有刷新 token 的,才放到请求队列里
+          if ((res.statusCode === 401 || resData.code === 401) && refreshToken) {
+            taskQueue.push(() => {
+              resolve(http<T>(options))
+            })
+          }
+          // 如果有 refreshToken 且未在刷新中,发起刷新 token 请求
+          if ((res.statusCode === 401 || resData.code === 401) && refreshToken && !refreshing) {
+            refreshing = true
+            try {
+              // 发起刷新 token 请求(使用 store 的 refreshToken 方法)
+              await store.refreshToken()
+              // 刷新 token 成功
+              refreshing = false
+              nextTick(() => {
+                // 关闭其他弹窗
+                uni.hideToast()
+                uni.showToast({
+                  title: 'token 刷新成功',
+                  icon: 'none',
+                })
+              })
+              // 将任务队列的所有任务重新请求
+              taskQueue.forEach(task => task())
+            }
+            catch (refreshErr) {
+              console.error('刷新 token 失败:', refreshErr)
+              refreshing = false
+              // 刷新 token 失败,跳转到登录页
+              nextTick(() => {
+                // 关闭其他弹窗
+                uni.hideToast()
+                uni.showToast({
+                  title: '登录已过期,请重新登录',
+                  icon: 'none',
+                })
+              })
+              // 清除用户信息
+              await store.logout()
+              // 跳转到登录页
+              setTimeout(() => {
+                uni.navigateTo({ url: '/pages/login/login' })
+              }, 2000)
+            }
+            finally {
+              // 不管刷新 token 成功与否,都清空任务队列
+              taskQueue = []
+            }
+          }
         }
         else {
           // 其他错误 -> 根据后端错误信息轻提示

+ 1 - 1
src/http/interceptor.ts

@@ -48,7 +48,7 @@ const httpInterceptor = {
     }
     // 3. 添加 token 请求头标识
     const userStore = useUserStore()
-    const { token } = userStore.userInfo as unknown as IUserInfo
+    const { token } = userStore.userToken as unknown as IUserToken
     if (token) {
       options.header.Authorization = `Bearer ${token}`
     }

+ 5 - 1
src/router/interceptor.ts

@@ -13,7 +13,7 @@ import { EXCLUDE_PAGE_LIST, isNeedLogin, LOGIN_PAGE, LOGIN_PAGE_LIST } from '../
 export const navigateToInterceptor = {
   // 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
   // 增加对相对路径的处理,BY 网友 @ideal
-  invoke({ url }: { url: string }) {
+  invoke({ url, query }: { url: string, query?: Record<string, string> }) {
     console.log(url) // /pages/route-interceptor/index?name=feige&age=30
     if (url === undefined) {
       return
@@ -41,6 +41,10 @@ export const navigateToInterceptor = {
     }
 
     console.log('拦截器中得到的 path:', path)
+    console.log('拦截器中得到的 query:', query)
+    if (query) {
+      path += `?${Object.keys(query).map(key => `${key}=${query[key]}`).join('&')}`
+    }
     const redirectUrl = `${LOGIN_PAGE}?redirect=${encodeURIComponent(path)}`
 
     const userStore = useUserStore()

+ 37 - 3
src/store/user.ts

@@ -1,10 +1,11 @@
-import type { IUserInfoVo } from '@/api/types/login'
+import type { IUserInfoVo, IUserLogin, IUserTokenVo } from '@/api/types/login'
 import { defineStore } from 'pinia'
 import { ref } from 'vue'
 import {
   getUserInfo as _getUserInfo,
   login as _login,
   logout as _logout,
+  refreshToken as _refreshToken,
   wxLogin as _wxLogin,
   getWxCode,
 } from '@/api/login'
@@ -15,7 +16,12 @@ const userInfoState: IUserInfoVo = {
   id: 0,
   username: '',
   avatar: '/static/images/default-avatar.png',
+}
+
+const userTokenState: IUserTokenVo = {
   token: '',
+  refreshToken: '',
+  refreshExpire: 0,
 }
 
 export const useUserStore = defineStore(
@@ -23,6 +29,7 @@ export const useUserStore = defineStore(
   () => {
     // 定义用户信息
     const userInfo = ref<IUserInfoVo>({ ...userInfoState })
+    const userToken = ref<IUserTokenVo>({ ...userTokenState })
     // 设置用户信息
     const setUserInfo = (val: IUserInfoVo) => {
       console.log('设置用户信息', val)
@@ -43,9 +50,23 @@ export const useUserStore = defineStore(
     // 删除用户信息
     const removeUserInfo = () => {
       userInfo.value = { ...userInfoState }
+      userToken.value = { ...userTokenState }
       uni.removeStorageSync('userInfo')
       uni.removeStorageSync('token')
+      uni.removeStorageSync('refreshToken')
     }
+
+    /**
+     * 存储token,非导出
+     */
+    const setToken = (tokenBody: IUserLogin) => {
+      userToken.value.token = tokenBody.token
+      userToken.value.refreshToken = tokenBody.refreshToken
+      userToken.value.refreshExpire = tokenBody.refreshExpire
+      uni.setStorageSync('token', tokenBody.token)
+      uni.setStorageSync('refreshToken', tokenBody.refreshToken)
+    }
+
     /**
      * 获取用户信息
      */
@@ -54,7 +75,6 @@ export const useUserStore = defineStore(
       const userInfo = res.data
       setUserInfo(userInfo)
       uni.setStorageSync('userInfo', userInfo)
-      uni.setStorageSync('token', userInfo.token)
       // TODO 这里可以增加获取用户路由的方法 根据用户的角色动态生成路由
       return res
     }
@@ -72,6 +92,18 @@ export const useUserStore = defineStore(
       const res = await _login(credentials)
       console.log('登录信息', res)
       toast.success('登录成功')
+      // 这里设置token 和 refreshToken
+      setToken(res.data)
+      await getUserInfo()
+      return res
+    }
+
+    /**
+     * 刷新token
+     */
+    const refreshToken = async () => {
+      const res = await _refreshToken(userToken.value.refreshToken)
+      setToken(res.data)
       await getUserInfo()
       return res
     }
@@ -98,13 +130,15 @@ export const useUserStore = defineStore(
 
     return {
       userInfo,
+      userToken,
       login,
       wxLogin,
       setUserInfo,
       getUserInfo,
       setUserAvatar,
       logout,
-      hasLogin: computed(() => !!userInfo.value.token),
+      hasLogin: computed(() => !!userToken.value.token),
+      refreshToken,
     }
   },
   {

+ 6 - 1
src/typings.d.ts

@@ -21,7 +21,12 @@ declare global {
     avatar?: string
     /** 微信的 openid,非微信没有这个字段 */
     openid?: string
-    token?: string
+  }
+
+  interface IUserToken {
+    token: string
+    refreshToken?: string
+    refreshExpire?: number
   }
 }