import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig, } from "axios"; import { useAdminStoreHook } from "@/store/modules/admin"; import i18n from "@/lang/index"; import { API_CODE, ApiError, ApiErrorPayload } from "@/utils/api-error"; import { describeApiError } from "@/utils/api-message"; const dynamicBase = (window as any).__dynamic_base__ || ""; // Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI: // под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый // админский API, хотя middleware у них разные. // Значение синхронизировано с constant.AdminAPIBase в админке и // ADMIN_API_BASE в оркестраторе. const API_BASE = "/api"; const t = i18n.global.t; /** * Запрос может отказаться от общего тоста, если показывает причину сам. * * Так делает форма пира: причины по полям она подставляет прямо под поля, и * второй сигнал тостом там только шумит. Так же поступают действия над строкой * пира: отзыв доступа умеет завершиться частично, и такой исход показывается * предупреждением, а не ошибкой. * * Флаг относится ТОЛЬКО к отказу API — тому, у которого есть конверт с `code` * и `errors`. Транспортный отказ показывается всегда: разбирать в нём нечего, * и молчание о нём означало бы операцию, не происходящую без объяснений. */ declare module "axios" { export interface AxiosRequestConfig { skipErrorToast?: boolean; } } // Создание axios instance const service = axios.create({ baseURL: `${dynamicBase}${API_BASE}`, timeout: 50000, headers: { "Content-Type": "application/json;charset=utf-8" }, }); // Request interceptor service.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const adminStore = useAdminStoreHook(); if (adminStore.token) { config.headers.Authorization = adminStore.token; } return config; }, (error: any) => { return Promise.reject(error); } ); /** * Сессия кончилась под руками у оператора. * * Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер * отвечал HTTP 200 на любой отказ, поэтому обработчик ошибок axios (второй * аргумент interceptors.response.use) для отказов API не вызывался вовсе — а * жила ветка сессии именно там. Условие в ней проверяло `code === "A0230"` и * поле `msg`, которых в этом API никогда не было: остатки чужого шаблона. * Ключ common.sessionExpired существовал и был мёртвым. * * Диалог показывается ОДИН раз: истёкший токен обычно роняет сразу несколько * параллельных запросов страницы, и без этого оператор получил бы стопку * одинаковых окон. */ let sessionPromptOpen = false; function promptSignIn(expired: boolean): void { if (sessionPromptOpen) { return; } sessionPromptOpen = true; const finish = () => { sessionPromptOpen = false; // Сбрасывается ТОЛЬКО сессия. Прежний код звал localStorage.clear(), то // есть заодно стирал выбранный оператором язык панели: при следующем входе // интерфейс возвращался к значению по умолчанию без всякой причины. useAdminStoreHook().resetToken(); const redirect = encodeURIComponent( window.location.pathname + window.location.search ); window.location.href = `/login?redirect=${redirect}`; }; ElMessageBox.confirm( expired ? t("common.sessionExpired") : t("common.signInRequired"), t("common.warning"), { confirmButtonText: t("common.signIn"), showCancelButton: false, closeOnClickModal: false, closeOnPressEscape: false, showClose: false, type: "warning", } ) .then(finish) .catch(finish); } // Response interceptor service.interceptors.response.use( (response: AxiosResponse) => { // Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан // проверяться ДО обращения к его полям: у Blob их нет. if ( response.data instanceof ArrayBuffer || response.data instanceof Blob ) { return response; } const payload = response.data as ApiErrorPayload; if (payload?.code === API_CODE.success) { return response.data; } const apiError = new ApiError(payload ?? { code: API_CODE.systemError }); if (apiError.requiresSignIn) { promptSignIn(apiError.sessionExpired); return Promise.reject(apiError); } if (!response.config?.skipErrorToast) { ElMessage.error(describeApiError(apiError)); } return Promise.reject(apiError); }, (error: AxiosError) => { // Сюда приходит транспорт: сеть недоступна, таймаут, отменённый запрос, // HTTP-статус вне 2xx. Прежний код читал error.response.data без проверки // самого error.response — то есть при обрыве соединения падал с // TypeError и подменял настоящую причину отказом внутри обработчика. const message = error.response ? t("common.systemError") : t("common.networkError"); // skipErrorToast здесь НЕ действует, и это существенно. // // Флаг существует ради одного случая: страница разбирает причину отказа // сама — раскладывает её по полям формы или отличает частичный результат // от полного отказа по коду. Всё это возможно только для отказа API, у // которого есть конверт с `code` и `errors`. // // У транспортного отказа конверта нет вовсе. Страница о нём не может // сказать ничего, кроме того же «сервер не отвечает», и все они пишут в // своих обработчиках `// транспортный отказ уже показан общим // перехватчиком» и молчат. Пока флаг гасил и эту ветку, утверждение было // ложным: обрыв соединения при сохранении пира, его удалении или отзыве // доступа не показывал оператору НИЧЕГО — операция просто не происходила // молча. ElMessage.error(message); return Promise.reject(error); } ); // Export axios instance export default service;