request.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import axios, { type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios';
  2. import { ElMessage } from 'element-plus';
  3. import { useAuthStore } from '@/stores/auth';
  4. import { useServerStore } from '@/stores/server';
  5. import router from '@/router';
  6. import type { ApiResponse } from '@media-manager/shared';
  7. // 创建 axios 实例
  8. const request: AxiosInstance = axios.create({
  9. timeout: 30000,
  10. headers: {
  11. 'Content-Type': 'application/json',
  12. },
  13. });
  14. // 将 localhost 规范为 127.0.0.1,避免 Windows 上 IPv6(::1) 导致 ECONNREFUSED
  15. function normalizeBaseUrlForRequest(url: string): string {
  16. try {
  17. const u = new URL(url);
  18. if (u.hostname === 'localhost' || u.hostname === '::1') {
  19. u.hostname = '127.0.0.1';
  20. }
  21. return u.origin.replace(/\/$/, '');
  22. } catch {
  23. return url;
  24. }
  25. }
  26. function isLocalBackend(url: string): boolean {
  27. try {
  28. const u = new URL(url);
  29. const port = u.port || (u.protocol === 'https:' ? '443' : '80');
  30. return (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1') && port === '3000';
  31. } catch {
  32. return false;
  33. }
  34. }
  35. // 请求拦截器
  36. request.interceptors.request.use(
  37. (config: InternalAxiosRequestConfig) => {
  38. const serverStore = useServerStore();
  39. const authStore = useAuthStore();
  40. // 设置基础 URL:显式传入的 baseURL 优先,否则用 store
  41. const rawBaseUrl = config.baseURL ?? serverStore.currentServer?.url;
  42. if (rawBaseUrl) {
  43. // 开发环境下本地 3000 端口走 Vite 代理,避免 CORS 和重复 Access-Control-Allow-Origin 头
  44. const isDevLocal = import.meta.env.DEV && isLocalBackend(rawBaseUrl);
  45. config.baseURL = isDevLocal ? '' : normalizeBaseUrlForRequest(rawBaseUrl);
  46. }
  47. // 添加认证 token
  48. if (authStore.accessToken) {
  49. config.headers.Authorization = `Bearer ${authStore.accessToken}`;
  50. }
  51. return config;
  52. },
  53. (error) => {
  54. return Promise.reject(error);
  55. }
  56. );
  57. // 是否正在刷新 token
  58. let isRefreshing = false;
  59. // 等待刷新的请求队列
  60. let refreshSubscribers: ((token: string) => void)[] = [];
  61. // 通知所有等待的请求
  62. function onRefreshed(token: string) {
  63. refreshSubscribers.forEach(callback => callback(token));
  64. refreshSubscribers = [];
  65. }
  66. // 添加到等待队列
  67. function addRefreshSubscriber(callback: (token: string) => void) {
  68. refreshSubscribers.push(callback);
  69. }
  70. // 响应拦截器
  71. request.interceptors.response.use(
  72. (response: AxiosResponse<ApiResponse>) => {
  73. const data = response.data;
  74. if (data.success) {
  75. return data.data as any;
  76. }
  77. // 业务错误
  78. ElMessage.error(data.message || '请求失败');
  79. return Promise.reject(new Error(data.message));
  80. },
  81. async (error) => {
  82. const originalRequest = error.config;
  83. // 排除不需要刷新 token 的请求
  84. const isAuthRequest = originalRequest.url?.includes('/api/auth/refresh')
  85. || originalRequest.url?.includes('/api/auth/login')
  86. || originalRequest.url?.includes('/api/auth/register');
  87. // Token 过期,尝试刷新(排除认证相关请求)
  88. if (error.response?.status === 401 && !originalRequest._retry && !isAuthRequest) {
  89. originalRequest._retry = true;
  90. // 如果已经在刷新中,将请求加入队列等待
  91. if (isRefreshing) {
  92. return new Promise((resolve) => {
  93. addRefreshSubscriber((token: string) => {
  94. originalRequest.headers.Authorization = `Bearer ${token}`;
  95. resolve(request(originalRequest));
  96. });
  97. });
  98. }
  99. isRefreshing = true;
  100. const authStore = useAuthStore();
  101. try {
  102. const refreshed = await authStore.refreshAccessToken();
  103. if (refreshed) {
  104. const newToken = authStore.accessToken!;
  105. onRefreshed(newToken);
  106. originalRequest.headers.Authorization = `Bearer ${newToken}`;
  107. return request(originalRequest);
  108. }
  109. } catch {
  110. // 刷新失败
  111. } finally {
  112. isRefreshing = false;
  113. }
  114. // 刷新失败,清除等待队列并跳转登录
  115. refreshSubscribers = [];
  116. authStore.clearTokens();
  117. ElMessage.error('登录已过期,请重新登录');
  118. router.push('/login');
  119. return Promise.reject(error);
  120. }
  121. // 认证请求失败(login/register 等)
  122. if (isAuthRequest) {
  123. // 获取错误消息
  124. const message = error.response?.data?.error?.message
  125. || error.response?.data?.message
  126. || error.message
  127. || '认证失败';
  128. // 显示错误消息
  129. ElMessage.error(message);
  130. // refresh 请求失败才清除 token 并跳转
  131. if (originalRequest.url?.includes('/api/auth/refresh')) {
  132. const authStore = useAuthStore();
  133. authStore.clearTokens();
  134. router.push('/login');
  135. }
  136. return Promise.reject(error);
  137. }
  138. // 其他错误
  139. const message = error.response?.data?.error?.message
  140. || error.response?.data?.message
  141. || error.message
  142. || '网络错误';
  143. ElMessage.error(message);
  144. return Promise.reject(error);
  145. }
  146. );
  147. export default request;