import axios from 'axios' let lastToastKey = '' let lastToastAt = 0 const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]) const MAX_RETRY_COUNT = 1 const RETRY_BASE_DELAY_MS = 300 const TOAST_STYLE_ID = 'zsglpt-lite-toast-style' function ensureToastStyle() { if (typeof document === 'undefined') return if (document.getElementById(TOAST_STYLE_ID)) return const style = document.createElement('style') style.id = TOAST_STYLE_ID style.textContent = ` .zsglpt-lite-toast-wrap { position: fixed; right: 16px; top: 16px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .zsglpt-lite-toast { max-width: min(88vw, 420px); padding: 10px 12px; border-radius: 10px; color: #fff; font-size: 13px; font-weight: 600; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.24); opacity: 0; transform: translateY(-6px); transition: all .18s ease; } .zsglpt-lite-toast.is-visible { opacity: 1; transform: translateY(0); } .zsglpt-lite-toast.is-error { background: linear-gradient(135deg, #ef4444, #dc2626); } ` document.head.appendChild(style) } function ensureToastWrap() { if (typeof document === 'undefined') return null ensureToastStyle() let wrap = document.querySelector('.zsglpt-lite-toast-wrap') if (wrap) return wrap wrap = document.createElement('div') wrap.className = 'zsglpt-lite-toast-wrap' document.body.appendChild(wrap) return wrap } function showLiteToast(message) { const wrap = ensureToastWrap() if (!wrap) return const node = document.createElement('div') node.className = 'zsglpt-lite-toast is-error' node.textContent = String(message || '请求失败') wrap.appendChild(node) requestAnimationFrame(() => node.classList.add('is-visible')) window.setTimeout(() => node.classList.remove('is-visible'), 2300) window.setTimeout(() => node.remove(), 2600) } function toastErrorOnce(key, message, minIntervalMs = 1500) { const now = Date.now() if (key === lastToastKey && now - lastToastAt < minIntervalMs) return lastToastKey = key lastToastAt = now showLiteToast(message) } function getCookie(name) { const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1') const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`)) return match ? decodeURIComponent(match[1]) : '' } function isIdempotentMethod(method) { return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase()) } function shouldRetryRequest(error) { const config = error?.config if (!config || config.__no_retry) return false if (!isIdempotentMethod(config.method)) return false const retried = Number(config.__retry_count || 0) if (retried >= MAX_RETRY_COUNT) return false const code = String(error?.code || '') if (code === 'ECONNABORTED' || code === 'ERR_NETWORK') return true const status = Number(error?.response?.status || 0) return RETRYABLE_STATUS.has(status) } function delay(ms) { return new Promise((resolve) => { window.setTimeout(resolve, Math.max(0, Number(ms || 0))) }) } async function retryRequestOnce(error, client) { const config = error?.config || {} const retried = Number(config.__retry_count || 0) config.__retry_count = retried + 1 const backoffMs = RETRY_BASE_DELAY_MS * (retried + 1) await delay(backoffMs) return client.request(config) } export const publicApi = axios.create({ baseURL: '/api', timeout: 30_000, withCredentials: true, }) publicApi.interceptors.request.use((config) => { const method = String(config?.method || 'GET').toUpperCase() if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { const token = getCookie('csrf_token') if (token) { config.headers = config.headers || {} config.headers['X-CSRF-Token'] = token } } return config }) publicApi.interceptors.response.use( (response) => response, (error) => { if (shouldRetryRequest(error)) { return retryRequestOnce(error, publicApi) } const status = error?.response?.status const payload = error?.response?.data const message = payload?.error || payload?.message || error?.message || '请求失败' if (status === 401) { const pathname = window.location?.pathname || '' // 登录页面不弹通知,让 LoginPage.vue 自己处理错误显示 if (!pathname.startsWith('/login')) { toastErrorOnce('401', message || '登录已过期,请重新登录', 3000) window.location.href = '/login' } } else if (status === 403) { toastErrorOnce('403', message || '无权限', 5000) } else if (error?.code === 'ECONNABORTED') { toastErrorOnce('timeout', '请求超时', 3000) } else if (!status) { toastErrorOnce(`net:${message}`, message, 3000) } return Promise.reject(error) }, )