feat: 新增请求层拦截器、鉴权store与会话空闲检测

main
halo 2026-07-08 15:48:56 +08:00
parent 3ee4aa6ee7
commit fb22f8d99b
4 changed files with 163 additions and 0 deletions

7
src/api/auth.js Normal file
View File

@ -0,0 +1,7 @@
import request from './request'
export const sendSmsApi = (data) => request.post('/auth/sms/send', data)
export const loginApi = (data) => request.post('/auth/login', data)
export const logoutApi = () => request.post('/auth/logout')
export const fetchUserInfoApi = () => request.get('/auth/userinfo')
export const fetchMenuApi = () => request.get('/auth/menu')

83
src/api/request.js Normal file
View File

@ -0,0 +1,83 @@
import axios from 'axios'
import { message } from 'ant-design-vue'
import { useAuthStore } from '@/stores/auth'
export function getBaseUrl() {
return localStorage.getItem('base_url_override') || import.meta.env.VITE_API_BASE_URL
}
export function setBaseUrlOverride(url) {
if (url) localStorage.setItem('base_url_override', url)
else localStorage.removeItem('base_url_override')
}
const request = axios.create({ withCredentials: true, timeout: 15000 })
request.interceptors.request.use((config) => {
config.baseURL = getBaseUrl()
const authStore = useAuthStore()
if (authStore.accessToken) {
config.headers.Authorization = `Bearer ${authStore.accessToken}`
}
return config
})
let isRefreshing = false
let waitQueue = []
async function refreshAccessToken() {
const authStore = useAuthStore()
const res = await axios.post(`${getBaseUrl()}/auth/refresh`, {}, { withCredentials: true })
if (res.data.code !== 200) throw new Error('refresh failed')
authStore.setAccessToken(res.data.data.accessToken)
return res.data.data.accessToken
}
request.interceptors.response.use(
(response) => {
const { code, msg } = response.data || {}
if (code !== undefined && code !== 200) {
message.error(msg || '请求失败')
}
return response
},
async (error) => {
const authStore = useAuthStore()
const { response, config } = error
if (response?.status === 401 && config && !config._retried) {
config._retried = true
if (!isRefreshing) {
isRefreshing = true
try {
const token = await refreshAccessToken()
isRefreshing = false
waitQueue.forEach((cb) => cb(token))
waitQueue = []
config.headers.Authorization = `Bearer ${token}`
return request(config)
} catch (e) {
isRefreshing = false
waitQueue = []
authStore.clear()
window.location.href = '/login'
return Promise.reject(e)
}
}
return new Promise((resolve) => {
waitQueue.push((token) => {
config.headers.Authorization = `Bearer ${token}`
resolve(request(config))
})
})
}
if (response?.status === 403) {
message.error('无权限访问该资源')
}
if (!response) {
message.error('网络异常,请检查后端服务是否可用')
}
return Promise.reject(error)
}
)
export default request

55
src/stores/auth.js Normal file
View File

@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { loginApi, logoutApi, fetchMenuApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
accessToken: localStorage.getItem('access_token') || '',
userInfo: null,
menuTree: [],
buttonCodes: [],
routesReady: false
}),
actions: {
setAccessToken(token) {
this.accessToken = token
localStorage.setItem('access_token', token)
},
async login(payload) {
const res = await loginApi(payload)
if (res.data.code === 200) {
const { accessToken, userInfo, menuTree, buttonCodes } = res.data.data
this.setAccessToken(accessToken)
this.userInfo = userInfo
this.menuTree = menuTree
this.buttonCodes = buttonCodes
}
return res.data
},
async fetchMenu() {
const res = await fetchMenuApi()
if (res.data.code === 200) {
this.menuTree = res.data.data.menuTree
this.buttonCodes = res.data.data.buttonCodes
}
return res.data
},
async logout() {
await logoutApi()
this.clear()
},
clear() {
this.accessToken = ''
this.userInfo = null
this.menuTree = []
this.buttonCodes = []
this.routesReady = false
localStorage.removeItem('access_token')
},
markRoutesReady() {
this.routesReady = true
},
hasButton(code) {
return this.buttonCodes.includes(code)
}
}
})

18
src/utils/lastActive.js Normal file
View File

@ -0,0 +1,18 @@
const ACTIVE_EVENTS = ['mousemove', 'mousedown', 'keydown', 'scroll', 'click']
export function startIdleWatcher(onTimeout, timeoutMs = 5 * 60 * 1000) {
let timer = null
function reset() {
if (timer) clearTimeout(timer)
timer = setTimeout(onTimeout, timeoutMs)
}
ACTIVE_EVENTS.forEach((evt) => window.addEventListener(evt, reset))
reset()
return function stop() {
if (timer) clearTimeout(timer)
ACTIVE_EVENTS.forEach((evt) => window.removeEventListener(evt, reset))
}
}