fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios";
|
||||
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:
|
||||
@@ -10,6 +16,19 @@ const dynamicBase = (window as any).__dynamic_base__ || "";
|
||||
// ADMIN_API_BASE в оркестраторе.
|
||||
const API_BASE = "/api";
|
||||
const t = i18n.global.t;
|
||||
|
||||
/**
|
||||
* Запрос может отказаться от общего тоста, если показывает причину сам.
|
||||
*
|
||||
* Так делает форма пира: причины по полям она подставляет прямо под поля, и
|
||||
* второй сигнал тостом там только шумит.
|
||||
*/
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
skipErrorToast?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Создание axios instance
|
||||
const service = axios.create({
|
||||
baseURL: `${dynamicBase}${API_BASE}`,
|
||||
@@ -31,38 +50,98 @@ service.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Сессия кончилась под руками у оператора.
|
||||
*
|
||||
* Раньше эта ветка была недостижима, и не в одном месте, а в двух. Сервер
|
||||
* отвечал 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) => {
|
||||
const { code, message } = response.data;
|
||||
if (code === 20000) {
|
||||
return response.data;
|
||||
}
|
||||
// Обработка бинарного ответа при экспорте файлов
|
||||
if (response.data instanceof ArrayBuffer || response.data instanceof Blob) {
|
||||
// Бинарный ответ (выгрузка файла) не несёт конверта с кодом и обязан
|
||||
// проверяться ДО обращения к его полям: у Blob их нет.
|
||||
if (
|
||||
response.data instanceof ArrayBuffer ||
|
||||
response.data instanceof Blob
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
ElMessage.error(message || "Системная ошибка");
|
||||
return Promise.reject(new Error(message || "Error"));
|
||||
},
|
||||
(error: any) => {
|
||||
if (error.response.data) {
|
||||
const { code, msg } = error.response.data;
|
||||
// Token истёк, нужен повторный вход
|
||||
if (code === "A0230") {
|
||||
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), {
|
||||
confirmButtonText: t("common.confirm"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
localStorage.clear();
|
||||
window.location.href = "/";
|
||||
});
|
||||
} else {
|
||||
ElMessage.error(msg || "Системная ошибка");
|
||||
}
|
||||
const payload = response.data as ApiErrorPayload;
|
||||
if (payload?.code === API_CODE.success) {
|
||||
return response.data;
|
||||
}
|
||||
return Promise.reject(error.message);
|
||||
|
||||
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");
|
||||
|
||||
if (!error.config?.skipErrorToast) {
|
||||
ElMessage.error(message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user