Add centralized API client with axios interceptors, remove vue-axios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 12:44:33 +08:00
parent 6af7253d08
commit 147b16ca34
29 changed files with 301 additions and 270 deletions

33
src/api/auth.js Normal file
View File

@@ -0,0 +1,33 @@
import axios from 'axios';
import { getCookie, setCookie, setCookieWithoutExpiration } from '@/utils/cookieUtil.js';
/**
* Refresh the access token using the refresh token cookie.
* Uses plain axios (not apiClient) to avoid interceptor loops.
* @returns {Promise<string>} The new access token.
*/
export async function refreshTokenAndGetNew() {
const api = '/api/oauth/token';
const config = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
};
const data = {
grant_type: 'refresh_token',
refresh_token: getCookie('luciaRefreshToken'),
};
const response = await axios.post(api, data, config);
const newAccessToken = response.data.access_token;
const newRefreshToken = response.data.refresh_token;
setCookieWithoutExpiration('luciaToken', newAccessToken);
// Expire in ~6 months
const expiredMs = new Date();
expiredMs.setMonth(expiredMs.getMonth() + 6);
const days = Math.ceil((expiredMs.getTime() - Date.now()) / (24 * 60 * 60 * 1000));
setCookie('luciaRefreshToken', newRefreshToken, days);
return newAccessToken;
}

78
src/api/client.js Normal file
View File

@@ -0,0 +1,78 @@
import axios from 'axios';
import { getCookie, deleteCookie } from '@/utils/cookieUtil.js';
const apiClient = axios.create();
// Request interceptor: automatically attach Authorization header
apiClient.interceptors.request.use((config) => {
const token = getCookie('luciaToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor: handle 401 by attempting token refresh
let isRefreshing = false;
let pendingRequests = [];
function onRefreshSuccess(newToken) {
pendingRequests.forEach((cb) => cb(newToken));
pendingRequests = [];
}
function onRefreshFailure(error) {
pendingRequests.forEach((cb) => cb(null, error));
pendingRequests = [];
}
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Only attempt refresh on 401, and not for auth endpoints or already-retried requests
if (
error.response?.status !== 401 ||
originalRequest._retried ||
originalRequest.url === '/api/oauth/token'
) {
return Promise.reject(error);
}
if (isRefreshing) {
// Queue this request until the refresh completes
return new Promise((resolve, reject) => {
pendingRequests.push((newToken, err) => {
if (err) return reject(err);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
originalRequest._retried = true;
resolve(apiClient(originalRequest));
});
});
}
isRefreshing = true;
originalRequest._retried = true;
try {
// Dynamic import to avoid circular dependency with login store
const { refreshTokenAndGetNew } = await import('@/api/auth.js');
const newToken = await refreshTokenAndGetNew();
isRefreshing = false;
onRefreshSuccess(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
isRefreshing = false;
onRefreshFailure(refreshError);
// Refresh failed: clear auth and redirect to login
deleteCookie('luciaToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
);
export default apiClient;