275 lines
7.3 KiB
JavaScript
Raw Normal View History

2025-12-04 10:04:21 +08:00
// 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'))
)
}
// ---- 全局 tokenuseState 共享) ----
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 }),
}
}