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:
@@ -0,0 +1,73 @@
|
||||
import i18n from "@/lang/index";
|
||||
import { ApiError, ApiFieldError } from "@/utils/api-error";
|
||||
|
||||
/**
|
||||
* Локализация причины отказа.
|
||||
*
|
||||
* Ключ строится ИЗ КОДА, а не из текста ответа. Сервер присылает и своё
|
||||
* человекочитаемое сообщение — оно остаётся ответом для клиента без панели и
|
||||
* запасным вариантом здесь: код, которого панель ещё не знает, обязан доехать
|
||||
* до оператора хоть в каком-то виде, а не превратиться в пустую строку.
|
||||
*
|
||||
* Числа правил (границы длины, допустимые значения) приходят в `params`.
|
||||
* Второй копии этих чисел в панели нет намеренно: копия неизбежно разошлась бы
|
||||
* с серверной, и оператор читал бы «от 6 до 128», получая отказ по другим
|
||||
* границам.
|
||||
*/
|
||||
|
||||
const t = i18n.global.t;
|
||||
const te = i18n.global.te;
|
||||
|
||||
/** Локализованное название поля формы; при отсутствии — имя из ответа. */
|
||||
function fieldLabel(field: string): string {
|
||||
const key = `error.field.${field}`;
|
||||
return te(key) ? t(key) : field;
|
||||
}
|
||||
|
||||
/** Сообщение по одной причине отказа. */
|
||||
export function describeFieldError(error: ApiFieldError): string {
|
||||
const key = `error.code.${error.code}`;
|
||||
if (te(key)) {
|
||||
return t(key, {
|
||||
field: error.field ? fieldLabel(error.field) : "",
|
||||
...(error.params ?? {}),
|
||||
});
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
/** Причины по именам полей формы — для подстановки в el-form. */
|
||||
export function fieldErrorMap(error: ApiError): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const item of error.fieldErrors()) {
|
||||
// Первая причина по полю выигрывает: показывать в одном поле две строки
|
||||
// некуда, а порядок ответа отражает порядок правил.
|
||||
if (item.field && !(item.field in result)) {
|
||||
result[item.field] = describeFieldError(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна строка, пригодная для тоста.
|
||||
*
|
||||
* Отказ уровня операции показывается как есть. Отказ по полям сворачивается в
|
||||
* перечисление «поле: причина» — тост при этом остаётся вторым сигналом, а
|
||||
* первым служит подсветка самих полей.
|
||||
*/
|
||||
export function describeApiError(error: ApiError): string {
|
||||
const operation = error.operationError();
|
||||
if (operation) {
|
||||
return describeFieldError(operation);
|
||||
}
|
||||
|
||||
const fields = error.fieldErrors();
|
||||
if (fields.length > 0) {
|
||||
return fields
|
||||
.map((item) => `${fieldLabel(item.field!)}: ${describeFieldError(item)}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
return error.message || t("common.systemError");
|
||||
}
|
||||
Reference in New Issue
Block a user