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:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+105 -26
View File
@@ -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);
}
);