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