275 lines
7.3 KiB
JavaScript
275 lines
7.3 KiB
JavaScript
|
|
// app/composables/useApi.js
|
|||
|
|
import { useToast } from './useToast'
|
|||
|
|
|
|||
|
|
// ---- 公共错误文案提取 ----
|
|||
|
|
function normalizeErrorMessage(payload) {
|
|||
|
|
if (!payload) return 'Request failed'
|
|||
|
|
if (typeof payload === 'string') return payload
|
|||
|
|
|
|||
|
|
if (typeof payload.detail === 'string') return payload.detail
|
|||
|
|
|
|||
|
|
if (Array.isArray(payload.detail) && payload.detail.length) {
|
|||
|
|
const first = payload.detail[0]
|
|||
|
|
if (typeof first === 'string') return first
|
|||
|
|
if (first && first.msg) return first.msg
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Array.isArray(payload.errors) && payload.errors.length) {
|
|||
|
|
const first = payload.errors[0]
|
|||
|
|
if (typeof first === 'string') return first
|
|||
|
|
if (first && (first.message || first.msg)) return first.message || first.msg
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof payload.message === 'string') return payload.message
|
|||
|
|
|
|||
|
|
return 'Request failed'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 判断是不是“凭证/登录态问题”的错误
|
|||
|
|
function isAuthInvalidMessage(msg, raw) {
|
|||
|
|
const txt = `${msg || ''} ${raw ? JSON.stringify(raw) : ''}`.toLowerCase()
|
|||
|
|
if (!txt) return false
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
txt.includes('could not validate credentials') ||
|
|||
|
|
txt.includes('not authenticated') ||
|
|||
|
|
txt.includes('invalid token') ||
|
|||
|
|
txt.includes('token is invalid') ||
|
|||
|
|
txt.includes('token has expired') ||
|
|||
|
|
// 项目里常见 errors: ["credentials"] 也算
|
|||
|
|
(txt.includes('credentials') && txt.includes('error'))
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- 全局 token(useState 共享) ----
|
|||
|
|
export function useAuthToken() {
|
|||
|
|
const token = useState('auth:token', () => null)
|
|||
|
|
|
|||
|
|
const setToken = (t) => {
|
|||
|
|
console.log('[useApi] setToken ->', t)
|
|||
|
|
token.value = t || null
|
|||
|
|
|
|||
|
|
if (process.client) {
|
|||
|
|
try {
|
|||
|
|
if (t) {
|
|||
|
|
localStorage.setItem('token', t)
|
|||
|
|
} else {
|
|||
|
|
localStorage.removeItem('token')
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('[useApi] localStorage error:', e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 初始化:从 localStorage 恢复
|
|||
|
|
if (process.client && token.value == null) {
|
|||
|
|
try {
|
|||
|
|
const t = localStorage.getItem('token')
|
|||
|
|
if (t) {
|
|||
|
|
console.log('[useApi] init token from localStorage ->', t)
|
|||
|
|
token.value = t
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('[useApi] localStorage read failed:', e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 调试辅助(仅浏览器)
|
|||
|
|
if (process.client) {
|
|||
|
|
// eslint-disable-next-line no-undef
|
|||
|
|
window.__authToken = token
|
|||
|
|
// eslint-disable-next-line no-undef
|
|||
|
|
window.__setAuthToken = setToken
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { token, setToken }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- 统一请求封装 ----
|
|||
|
|
export function useApi() {
|
|||
|
|
const {
|
|||
|
|
public: { apiBase },
|
|||
|
|
} = useRuntimeConfig()
|
|||
|
|
const { token, setToken } = useAuthToken()
|
|||
|
|
|
|||
|
|
// 从响应中“顺手”捞 token
|
|||
|
|
const maybePickAndSaveToken = (res) => {
|
|||
|
|
const picked = res?.user?.token || res?.token || null
|
|||
|
|
if (picked) setToken(picked)
|
|||
|
|
return res
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 401 时只触发一次的刷新逻辑
|
|||
|
|
let refreshingPromise = null
|
|||
|
|
async function refreshAccessTokenOnce() {
|
|||
|
|
if (!refreshingPromise) {
|
|||
|
|
console.log('[useApi] start /auth/refresh')
|
|||
|
|
refreshingPromise = $fetch(`${apiBase}/auth/refresh`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
credentials: 'include',
|
|||
|
|
})
|
|||
|
|
.then((data) => {
|
|||
|
|
console.log('[useApi] refresh response:', data)
|
|||
|
|
const newToken = data?.token || data?.user?.token
|
|||
|
|
if (!newToken) throw new Error('No token in refresh response')
|
|||
|
|
setToken(newToken)
|
|||
|
|
return newToken
|
|||
|
|
})
|
|||
|
|
.finally(() => {
|
|||
|
|
// 稍后允许下一次刷新
|
|||
|
|
setTimeout(() => {
|
|||
|
|
refreshingPromise = null
|
|||
|
|
}, 50)
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
console.log('[useApi] reuse inflight refresh promise')
|
|||
|
|
}
|
|||
|
|
return refreshingPromise
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 统一处理"登录失效":清空本地 & 提示 & 跳登录
|
|||
|
|
function handleAuthExpired(status, msg, raw, isAuthPath) {
|
|||
|
|
if (isAuthPath) return false
|
|||
|
|
|
|||
|
|
if (
|
|||
|
|
(status === 401 || status === 403) &&
|
|||
|
|
isAuthInvalidMessage(msg, raw)
|
|||
|
|
) {
|
|||
|
|
console.warn('[useApi] auth expired, clear token & redirect login')
|
|||
|
|
|
|||
|
|
// 清 token
|
|||
|
|
setToken(null)
|
|||
|
|
|
|||
|
|
if (process.client) {
|
|||
|
|
try {
|
|||
|
|
localStorage.removeItem('token')
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('[useApi] remove token failed:', e)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 使用 Toast 提示
|
|||
|
|
const toast = useToast()
|
|||
|
|
toast.error('登录已失效', '请重新登录')
|
|||
|
|
|
|||
|
|
navigateTo('/login')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 抛一个规范的错误给上层(一般不会再用到)
|
|||
|
|
throw createError({
|
|||
|
|
statusCode: 401,
|
|||
|
|
statusMessage: '登录已失效,请重新登录',
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function request(path, opts = {}) {
|
|||
|
|
const method = opts.method || 'GET'
|
|||
|
|
const headers = { ...(opts.headers || {}) }
|
|||
|
|
|
|||
|
|
if (token.value) {
|
|||
|
|
headers.Authorization = `Token ${token.value}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const url = `${apiBase}${path}`
|
|||
|
|
|
|||
|
|
console.log(`[useApi] ${method} ${url}`, {
|
|||
|
|
query: opts.query,
|
|||
|
|
body: opts.body,
|
|||
|
|
headers,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const isAuthPath = String(path).includes('/auth/')
|
|||
|
|
|
|||
|
|
const doFetch = async (retryFlag = false) => {
|
|||
|
|
try {
|
|||
|
|
const res = await $fetch(url, {
|
|||
|
|
method,
|
|||
|
|
headers,
|
|||
|
|
query: opts.query,
|
|||
|
|
body: opts.body,
|
|||
|
|
})
|
|||
|
|
console.log(`[useApi] OK ${method} ${path}`, res)
|
|||
|
|
|
|||
|
|
maybePickAndSaveToken(res)
|
|||
|
|
return res
|
|||
|
|
} catch (e) {
|
|||
|
|
const status =
|
|||
|
|
e?.statusCode ||
|
|||
|
|
e?.response?.status ||
|
|||
|
|
e?.status ||
|
|||
|
|
400
|
|||
|
|
|
|||
|
|
const rawPayload =
|
|||
|
|
e?.data || e?.response?._data || e?.response?.data || null
|
|||
|
|
|
|||
|
|
console.warn(
|
|||
|
|
`[useApi] ERROR ${method} ${path}`,
|
|||
|
|
status,
|
|||
|
|
rawPayload || e,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 401 且非 /auth/*:先尝试刷新一次
|
|||
|
|
if (
|
|||
|
|
status === 401 &&
|
|||
|
|
!isAuthPath &&
|
|||
|
|
!retryFlag
|
|||
|
|
) {
|
|||
|
|
try {
|
|||
|
|
const newToken = await refreshAccessTokenOnce()
|
|||
|
|
const retryHeaders = {
|
|||
|
|
...headers,
|
|||
|
|
Authorization: `Token ${newToken}`,
|
|||
|
|
}
|
|||
|
|
console.log('[useApi] retry with new token for', path)
|
|||
|
|
|
|||
|
|
const res = await $fetch(url, {
|
|||
|
|
method,
|
|||
|
|
headers: retryHeaders,
|
|||
|
|
query: opts.query,
|
|||
|
|
body: opts.body,
|
|||
|
|
})
|
|||
|
|
console.log(`[useApi] RETRY OK ${method} ${path}`, res)
|
|||
|
|
maybePickAndSaveToken(res)
|
|||
|
|
return res
|
|||
|
|
} catch (refreshErr) {
|
|||
|
|
console.warn(
|
|||
|
|
'[useApi] refresh failed, treat as auth expired',
|
|||
|
|
refreshErr,
|
|||
|
|
)
|
|||
|
|
// 刷新失败,当成登录失效处理
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const msg = normalizeErrorMessage(rawPayload || e?.message)
|
|||
|
|
|
|||
|
|
// 如果是登录态问题(401/403 + 文案匹配),统一清 token & 跳登录
|
|||
|
|
handleAuthExpired(status, msg, rawPayload, isAuthPath)
|
|||
|
|
|
|||
|
|
// 其他错误:抛给页面
|
|||
|
|
throw createError({
|
|||
|
|
statusCode: status,
|
|||
|
|
statusMessage: msg,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return doFetch(false)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
get: (path, query) =>
|
|||
|
|
request(path, { method: 'GET', query }),
|
|||
|
|
post: (path, body) =>
|
|||
|
|
request(path, { method: 'POST', body }),
|
|||
|
|
put: (path, body) =>
|
|||
|
|
request(path, { method: 'PUT', body }),
|
|||
|
|
patch: (path, body) =>
|
|||
|
|
request(path, { method: 'PATCH', body }),
|
|||
|
|
del: (path, body) =>
|
|||
|
|
request(path, { method: 'DELETE', body }),
|
|||
|
|
}
|
|||
|
|
}
|