2025-12-04 10:04:21 +08:00

275 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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