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 }),
|
||
}
|
||
}
|