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
+4
View File
@@ -16,11 +16,14 @@ export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
});
}
// Форма пира показывает причины отказа под своими полями, поэтому общий тост
// ей не нужен: он повторял бы то же самое вторым сигналом.
export function savePeerApi(data: PeerSaveDto): AxiosPromise {
return request({
url: "/peers",
method: "post",
data,
skipErrorToast: true,
});
}
@@ -44,6 +47,7 @@ export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
url: `/peers/${data.id}`,
method: "patch",
data,
skipErrorToast: true,
});
}
+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="#000000"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="#000000"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="currentColor"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="currentColor"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 783 B

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 445 B

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="#000000"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="#000000"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="currentColor"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="currentColor"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="#000000"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="currentColor"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="#000000"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="currentColor"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="#000000"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="#000000"></path></svg>
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="currentColor"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+18 -9
View File
@@ -1,33 +1,42 @@
<template>
<svg
aria-hidden="true"
focusable="false"
class="svg-icon"
:style="'width:' + size + ';height:' + size"
>
<use :xlink:href="symbolId" :fill="color" />
<use :xlink:href="symbolId" />
</svg>
</template>
<script setup lang="ts">
import { SYMBOL_PREFIX } from "./symbol";
/**
* Цвет иконке не передаётся — и это контракт, а не упущение.
*
* Раньше здесь были проп `color` и `:fill="color"` на `<use>`. Ими никто не
* пользовался ни разу, а существование такого пропа приглашает чинить
* сломанный цвет точечно: «вот этой иконке передадим белый». Монохромная
* иконка обязана получать цвет ровно одним способом — наследованием
* `currentColor` от компонента и темы; ассет, который так не умеет, чинится в
* самом ассете и не доезжает до релиза (см. `symbol.ts`).
*
* Префикс id тоже больше не проп: он принадлежит спрайту, а не месту вызова, и
* объявлен рядом с кодом, который этот id создаёт.
*/
const props = defineProps({
prefix: {
type: String,
default: "icon",
},
iconClass: {
type: String,
required: false,
},
color: {
type: String,
},
size: {
type: String,
default: "1em",
},
});
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
const symbolId = computed(() => `#${SYMBOL_PREFIX}-${props.iconClass}`);
</script>
<style scoped>
+6 -63
View File
@@ -20,9 +20,14 @@
* Оптимизация через SVGO при этом потеряна. Для семнадцати вручную отобранных
* иконок это несколько килобайт, и они не стоят неисправимой зависимости в
* сборке.
*
* Преобразование файла в `<symbol>` и контракт ассета живут в `./symbol.ts`:
* там нет ни Vite, ни DOM, поэтому те же правила проверяются тестом и
* релизным гейтом, а не только глазами на живой странице.
*/
const SYMBOL_PREFIX = "icon";
import { iconName, toSymbol } from "./symbol";
const SPRITE_ELEMENT_ID = "__hy2xs_svg_sprite__";
// eager: файлы читаются на этапе сборки и попадают в бандл строками, сетевых
@@ -33,68 +38,6 @@ const sources = import.meta.glob<string>("@/assets/icons/*.svg", {
eager: true,
});
function iconName(filePath: string): string {
return filePath.replace(/^.*\//, "").replace(/\.svg$/, "");
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* Отбрасываются XML-пролог и DOCTYPE: внутри уже существующего документа они
* не только бесполезны, но и делают разметку невалидной. `width` и `height`
* тоже отбрасываются — размер задаёт компонент.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый плагин.
*/
function toSymbol(raw: string, name: string): string {
const withoutProlog = raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* Вставляет спрайт в документ. Идемпотентна: повторный вызов заменяет
* содержимое, а не добавляет второй элемент с теми же id.
@@ -0,0 +1,206 @@
/**
* Превращение исходного SVG-файла в `<symbol>` и контракт, которому исходник
* обязан соответствовать.
*
* Модуль намеренно ЧИСТЫЙ: ни `import.meta.glob`, ни `document`, ни любого
* другого Vite/DOM API здесь нет. Сборка спрайта из файлов живёт в `sprite.ts`,
* а сюда вынесено ровно то, что можно выполнить вне браузера и вне Vite —
* то есть проверить тестом (`tools/test/frontend-sprite.test.ts`) и релизным
* гейтом.
*
* Разделение появилось не ради красоты. Цвет иконок был сломан молча: контракт
* `fill: currentcolor` существовал в двух местах (`SvgIcon/index.vue` и
* `styles/sidebar.scss`), но восемь из семнадцати ассетов несли литеральный
* атрибут `fill="#000000"` прямо на `<path>`, а атрибут представления
* перебивает унаследованное CSS-свойство. Все семь иконок бокового меню
* рисовались чёрным по `--menuBg: #181818`. Ни одна существующая проверка
* этого не видела, потому что проверять было нечего: сам файл иконки под
* гейтом не был.
*/
/** Префикс id у `<symbol>`; `SvgIcon` строит по нему `<use href="#icon-…">`. */
export const SYMBOL_PREFIX = "icon";
/**
* Иконки, которые многоцветны НАМЕРЕННО.
*
* Для них собственная палитра — часть ассета, а не дефект, поэтому проверка
* цвета к ним не применяется. Список закрытый и явный: «многоцветность»
* обязана быть решением, а не следствием того, что иконку скачали с готовыми
* значениями fill.
*
* Всё остальное — монохромный UI: цвет наследуется от компонента и темы через
* `currentColor`, и это единственный способ, которым иконка может получить
* цвет. Ни CSS-фильтров, ни правил на конкретное имя иконки.
*/
export const MULTICOLOR_ICONS: ReadonlySet<string> = new Set([
"download",
"upload",
]);
/**
* Значения `fill`/`stroke`, которые цветом не являются и потому разрешены
* монохромной иконке.
*
* `none` — это «не закрашивать», а не цвет: у `refresh` контур рисуется
* штрихом, и `fill="none"` там обязателен.
*/
const NON_COLOR_PAINT = new Set(["currentcolor", "none", "inherit", "transparent"]);
/** Атрибуты, любое литеральное значение которых задаёт цвет. */
const PAINT_ATTRIBUTES = [
"fill",
"stroke",
"stop-color",
"flood-color",
"lighting-color",
];
/** `icons/log-system.svg` → `log-system`. */
export function iconName(filePath: string): string {
return filePath.replace(/^.*[\\/]/, "").replace(/\.svg$/i, "");
}
/**
* Убирает то, что внутри уже существующего документа не только бесполезно, но
* и делает разметку невалидной: XML-пролог, DOCTYPE и комментарии.
*/
function stripProlog(raw: string): string {
return raw
.replace(/<\?xml[\s\S]*?\?>/gi, "")
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
.trim();
}
/**
* Превращает содержимое файла в `<symbol>`.
*
* `width` и `height` отбрасываются вместе с корневым тегом — размер задаёт
* компонент. Цвета НЕ переписываются: источник истины — сам файл, а
* молчаливая нормализация в рантайме скрывала бы ровно тот дефект, который
* этот модуль обязан делать видимым. За соответствие отвечает
* `findIconContractViolations`, вызываемая тестом и релизным гейтом.
*
* `viewBox` обязателен: без него `<use>` не знает систему координат иконки и
* рисует её в натуральную величину, обрезая по размеру родительского `<svg>`.
* Три иконки из семнадцати (eye, fullscreen, exit-fullscreen) его не имеют и
* задают только width/height, поэтому viewBox для них синтезируется — ровно
* так же, как это делал заменённый `vite-plugin-svg-icons`.
*/
export function toSymbol(raw: string, name: string): string {
const withoutProlog = stripProlog(raw);
const openTag = withoutProlog.match(/<svg\b[^>]*>/i);
if (!openTag) {
return "";
}
const body = withoutProlog
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "");
const viewBoxAttr = resolveViewBox(openTag[0]);
return `<symbol id="${SYMBOL_PREFIX}-${name}"${viewBoxAttr}>${body}</symbol>`;
}
export function resolveViewBox(openTag: string): string {
const declared = openTag.match(/viewBox="([^"]+)"/i);
if (declared) {
return ` viewBox="${declared[1]}"`;
}
const width = numericAttribute(openTag, "width");
const height = numericAttribute(openTag, "height");
if (width !== null && height !== null) {
return ` viewBox="0 0 ${width} ${height}"`;
}
return "";
}
/** Читает размер, игнорируя единицы измерения: `128`, `128px`, `128pt`. */
function numericAttribute(openTag: string, name: string): number | null {
const match = openTag.match(new RegExp(`${name}="([\\d.]+)[a-z%]*"`, "i"));
if (!match) {
return null;
}
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* Проверяет ассет на соответствие контракту спрайта.
*
* Возвращает список нарушений; пустой список означает, что иконка пригодна.
* Проверка одна на всех потребителей — тест и релизный гейт зовут её, а не
* повторяют правила у себя. Второй экземпляр этих правил неизбежно разошёлся
* бы с первым, и разошёлся бы молча.
*/
export function findIconContractViolations(raw: string, name: string): string[] {
const violations: string[] = [];
const source = stripProlog(raw);
const openTag = source.match(/<svg\b[^>]*>/i);
if (!openTag) {
return [`${name}: нет корневого <svg>`];
}
// Система координат: либо объявленный viewBox, либо пара width/height, из
// которой он синтезируется. Иконка без обоих способов сломала бы отрисовку
// молча.
if (!resolveViewBox(openTag[0])) {
violations.push(`${name}: нет ни viewBox, ни пары width/height`);
}
if (MULTICOLOR_ICONS.has(name)) {
return violations;
}
for (const attribute of PAINT_ATTRIBUTES) {
const pattern = new RegExp(`\\b${attribute}\\s*=\\s*"([^"]*)"`, "gi");
for (const match of source.matchAll(pattern)) {
const value = match[1].trim();
if (value === "") {
continue;
}
if (!NON_COLOR_PAINT.has(value.toLowerCase())) {
violations.push(
`${name}: атрибут ${attribute}="${value}" задаёт цвет мимо currentColor`
);
}
}
}
// Инлайновый style бьёт и атрибут, и наследование, поэтому цвет в нём —
// такое же нарушение контракта, как литеральный атрибут.
for (const match of source.matchAll(/\bstyle\s*=\s*"([^"]*)"/gi)) {
const declarations = match[1].toLowerCase();
for (const attribute of PAINT_ATTRIBUTES) {
const property = declarations.match(
new RegExp(`(?:^|;)\\s*${attribute}\\s*:\\s*([^;]+)`)
);
if (property && !NON_COLOR_PAINT.has(property[1].trim())) {
violations.push(
`${name}: инлайновый style задаёт ${attribute}: ${property[1].trim()}`
);
}
}
}
// Непустой <style> внутри ассета уезжает в документ вместе со спрайтом и
// способен покрасить что угодно, включая чужие иконки: селекторы там
// глобальные. Пустой блок остаётся от редакторов и безвреден.
for (const match of source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
if (match[1].trim() !== "") {
violations.push(`${name}: непустой <style> внутри ассета`);
}
}
// Растр внутри иконки не наследует цвет ничем и никогда.
if (/<image\b/i.test(source)) {
violations.push(`${name}: растровое <image> не подчиняется currentColor`);
}
return violations;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Внутренние константы бренда.
*
* Единственное место, где живёт адрес атрибуции. Это не настройка: оператор
* HY2XS не должен иметь возможности переназначить, куда ведёт подпись
* разработчика, — ни через панель, ни через hy2xs.env, ни через таблицу
* `config`. Поэтому значение принадлежит приложению и попадает в бандл при
* сборке.
*
* По той же причине оно объявлено один раз, а не написано в шаблоне
* компонента: литерал, размазанный по нескольким Vue-файлам, невозможно ни
* проверить одним гейтом, ни изменить одной правкой.
*
* Отсутствие адреса в операторской конфигурации проверяется приёмкой сборки.
*/
export const FLAMY_NAME = "Flamy" as const;
export const FLAMY_URL = "https://flamy.studio" as const;
+61 -5
View File
@@ -112,7 +112,63 @@ export default {
invalid: "Invalid value",
switchLanguageSuccess: "Language switched successfully",
logoutConfirm: "Are you sure you want to log out?",
sessionExpired: "Current session has expired, please log in again",
sessionExpired: "Your session has expired. Sign in again to continue.",
signInRequired: "Signing in is required.",
signIn: "Sign in",
systemError: "System error",
networkError: "The server is not responding. Check the connection.",
},
error: {
field: {
name: "Peer name",
secret: "Secret",
remark: "Remark",
quotaBytes: "Quota",
expiresAt: "Expiry",
maxDevices: "Max devices",
disabled: "State",
bannedUntil: "Banned until",
file: "File",
id: "Identifier",
username: "Username",
pass: "Password",
oldPassword: "Old password",
newPassword: "New password",
key: "Setting key",
value: "Setting value",
numLine: "Line count",
pageNum: "Page number",
pageSize: "Page size",
},
code: {
required: "“{field}”: required",
min: "“{field}”: must not be less than {min}",
max: "“{field}”: must not be greater than {max}",
min_length: "“{field}”: at least {min} characters",
max_length: "“{field}”: at most {max} characters",
len: "“{field}”: length must be exactly {len}",
oneof: "“{field}”: allowed values are {values}",
gt: "“{field}”: must be greater than {gt}",
peer_name:
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: contains characters that are not allowed",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid: "Request could not be parsed: check field formats and types",
peer_name_taken: "A peer with this name already exists",
peer_name_reserved: "This name is reserved for the installer peer",
peer_bootstrap_identity_locked:
"The installer peer's name and secret are mirrored in a file on the server and cannot be changed from the panel. Delete the bootstrap peer entirely if it is no longer needed.",
invalid_credentials: "Wrong username or password",
import_file_extension: "Import accepts .json files only",
unauthorized: "Signing in is required",
session_expired: "Session expired",
token_invalid: "Session is not valid",
account_disabled: "Account is disabled",
},
},
sidebar: {
developedBy: "Made at {brand}",
},
info: {
expireTime: "y-M-d H:m:s",
@@ -128,12 +184,12 @@ export default {
remark: "Remark",
secret: "Secret",
form: {
namePlaceholder: "e.g. ivan-laptop",
namePlaceholder: "client-01",
nameHint:
"Short peer identifier. Use latin letters, digits and hyphens — the name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "e.g. Ivan's laptop, sales team",
"Peer identifier: 6 to 32 characters, latin letters, digits and hyphens. The name becomes part of the auto-generated secret and is shown to the client as the profile name.",
remarkPlaceholder: "laptop",
remarkHint: "Optional operator note. It is never shown to the client.",
secretPlaceholder: "leave empty to generate automatically",
secretPlaceholder: "leave empty to generate one",
secretHint:
"Client connection password. Leave empty to generate one automatically. If set manually: 6 to 128 characters.",
quotaHint: "Traffic limit in bytes. Use -1 for unlimited.",
+69 -5
View File
@@ -109,7 +109,71 @@ export default {
invalid: "Некорректное значение",
switchLanguageSuccess: "Язык переключён",
logoutConfirm: "Выйти из системы?",
sessionExpired: "Текущая сессия истекла, войдите снова",
sessionExpired: "Сессия истекла. Войдите снова, чтобы продолжить.",
signInRequired: "Требуется вход в панель.",
signIn: "Войти",
systemError: "Системная ошибка",
networkError: "Сервер не отвечает. Проверьте соединение с панелью.",
},
// Причины отказа API.
//
// Ключи строятся из КОДА ответа, а не из его текста: панель не разбирает
// человеческие сообщения сервера. Числа правил приходят в параметрах, поэтому
// второй копии границ длины здесь нет — она неизбежно разошлась бы с
// серверной.
error: {
field: {
name: "Имя пира",
secret: "Секрет",
remark: "Комментарий",
quotaBytes: "Квота",
expiresAt: "Срок действия",
maxDevices: "Лимит устройств",
disabled: "Состояние",
bannedUntil: "Блокировка до",
file: "Файл",
id: "Идентификатор",
username: "Логин",
pass: "Пароль",
oldPassword: "Старый пароль",
newPassword: "Новый пароль",
key: "Ключ настройки",
value: "Значение настройки",
numLine: "Число строк",
pageNum: "Номер страницы",
pageSize: "Размер страницы",
},
code: {
required: "«{field}»: поле обязательно",
min: "«{field}»: значение не может быть меньше {min}",
max: "«{field}»: значение не может быть больше {max}",
min_length: "«{field}»: не короче {min} символов",
max_length: "«{field}»: не длиннее {max} символов",
len: "«{field}»: длина должна быть ровно {len}",
oneof: "«{field}»: допустимые значения — {values}",
gt: "«{field}»: значение должно быть больше {gt}",
peer_name:
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
credential_format: "«{field}»: недопустимые символы",
rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
peer_name_taken: "Пир с таким именем уже существует",
peer_name_reserved: "Это имя зарезервировано за пиром установщика",
peer_bootstrap_identity_locked:
"Имя и секрет пира установщика продублированы в файле на сервере и не меняются через панель. Ненужный bootstrap-пир следует удалить целиком.",
invalid_credentials: "Неверный логин или пароль",
import_file_extension: "Импорт принимает только файлы .json",
unauthorized: "Требуется вход в панель",
session_expired: "Сессия истекла",
token_invalid: "Сессия недействительна",
account_disabled: "Учётная запись отключена",
},
},
sidebar: {
// {brand} подставляется ссылкой, поэтому фраза обязана остаться одной
// строкой с одним подстановочным местом.
developedBy: "Разработано во {brand}",
},
info: {
expireTime: "г-М-д Ч:м:с",
@@ -124,12 +188,12 @@ export default {
remark: "Комментарий",
secret: "Секрет",
form: {
namePlaceholder: "например, ivan-laptop",
namePlaceholder: "client-01",
nameHint:
"Короткий идентификатор пира. Используйте латиницу, цифры и дефис — имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: апример, Ноутбук Ивана, отдел продаж",
"Идентификатор пира: от 6 до 32 символов, латиница, цифры и дефис. Имя попадает в автогенерируемый секрет и показывается клиенту как название профиля.",
remarkPlaceholder: оутбук",
remarkHint: "Необязательная пометка для оператора. Клиент её не видит.",
secretPlaceholder: "оставьте пустым — сгенерируем автоматически",
secretPlaceholder: "оставьте пустым — сгенерируем",
secretHint:
"Пароль подключения клиента. Если оставить поле пустым, секрет будет сгенерирован автоматически. При ручном вводе: от 6 до 128 символов.",
quotaHint: "Лимит трафика в байтах. Укажите -1 для безлимита.",
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { FLAMY_NAME, FLAMY_URL } from "@/constants/branding";
defineProps({
collapse: {
type: Boolean,
required: true,
},
});
</script>
<template>
<div class="sidebar-footer" :class="{ 'is-collapsed': collapse }">
<!--
Свёрнутое меню шириной 54px не вмещает фразу целиком, поэтому в нём
остаётся только имя-ссылка. Прятать подпись совсем нельзя: атрибуция
обязана быть видна в обоих состояниях.
-->
<a
v-if="collapse"
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
<i18n-t v-else keypath="sidebar.developedBy" tag="span" scope="global">
<template #brand>
<a
class="sidebar-footer-brand"
:href="FLAMY_URL"
target="_blank"
rel="noopener noreferrer"
>{{ FLAMY_NAME }}</a
>
</template>
</i18n-t>
</div>
</template>
<style lang="scss" scoped>
.sidebar-footer {
display: flex;
align-items: center;
justify-content: center;
height: $sidebarFooterHeight;
padding: 0 12px;
overflow: hidden;
font-size: 12px;
line-height: 1.2;
color: rgb(255 255 255 / 45%);
text-align: center;
white-space: nowrap;
background-color: var(--menuBg);
border-top: 1px solid rgb(255 255 255 / 6%);
}
.sidebar-footer.is-collapsed {
padding: 0 4px;
}
.sidebar-footer-brand {
color: var(--el-color-primary);
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
</style>
@@ -3,6 +3,7 @@ import { useRoute } from "vue-router";
import SidebarItem from "./SidebarItem.vue";
import Logo from "./Logo.vue";
import Footer from "./Footer.vue";
import { usePermissionStore } from "@/store/modules/permission";
import { useAppStore } from "@/store/modules/app";
@@ -36,5 +37,6 @@ const route = useRoute();
/>
</el-menu>
</el-scrollbar>
<Footer :collapse="!appStore.sidebar.opened" />
</div>
</template>
+11 -1
View File
@@ -38,12 +38,22 @@
height: 100%;
}
// Область прокрутки меню ограничена сверху логотипом, снизу — подписью
// разработчика. Пункты меню поэтому не могут наехать на подпись даже при
// длинном списке: им физически некуда.
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
height: calc(100% - 50px - #{$sidebarFooterHeight});
}
}
.sidebar-footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
.is-horizontal {
display: none;
}
+7
View File
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
$sideBarWidth: 210px;
$sideBarCollapsedWidth: 54px;
// Высота подписи разработчика внизу бокового меню.
//
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
$sidebarFooterHeight: 34px;
+111
View File
@@ -0,0 +1,111 @@
/**
* Разбор структурированного отказа API.
*
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
* панель могла сделать, — показать это слово тостом. Оператор, оставивший поле
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
* не имел ни одного способа узнать причину.
*
* Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
*/
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
export const API_CODE = {
success: 20000,
systemError: 50000,
validationFailed: 50001,
unauthorized: 50401,
forbidden: 50403,
} as const;
/**
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
*
* Перечислены только те, на которые панель реагирует по-разному. Остальные
* доезжают до оператора сообщением сервера.
*/
export const ERR_CODE = {
bodyInvalid: "body_invalid",
validationFailed: "validation_failed",
required: "required",
min: "min",
max: "max",
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
// разные фразы для оператора.
minLength: "min_length",
maxLength: "max_length",
len: "len",
oneOf: "oneof",
greaterThan: "gt",
ruleViolated: "rule_violated",
peerName: "peer_name",
credentialFormat: "credential_format",
peerNameTaken: "peer_name_taken",
peerNameReserved: "peer_name_reserved",
peerBootstrapLocked: "peer_bootstrap_identity_locked",
invalidCredentials: "invalid_credentials",
importFileExtension: "import_file_extension",
unauthorized: "unauthorized",
sessionExpired: "session_expired",
tokenInvalid: "token_invalid",
accountDisabled: "account_disabled",
} as const;
export interface ApiFieldError {
code: string;
field?: string;
message: string;
params?: Record<string, string>;
}
export interface ApiErrorPayload {
code: number;
message?: string;
errors?: ApiFieldError[];
}
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
export class ApiError extends Error {
readonly code: number;
readonly errors: ApiFieldError[];
constructor(payload: ApiErrorPayload) {
super(payload.message || "Error");
this.name = "ApiError";
this.code = payload.code;
this.errors = payload.errors ?? [];
}
/** Причины, привязанные к полям формы. */
fieldErrors(): ApiFieldError[] {
return this.errors.filter((item) => !!item.field);
}
/** Первая причина без привязки к полю — отказ уровня операции. */
operationError(): ApiFieldError | undefined {
return this.errors.find((item) => !item.field);
}
hasCode(code: string): boolean {
return this.errors.some((item) => item.code === code);
}
get requiresSignIn(): boolean {
return this.code === API_CODE.unauthorized;
}
get sessionExpired(): boolean {
return (
this.hasCode(ERR_CODE.sessionExpired) ||
this.hasCode(ERR_CODE.accountDisabled)
);
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
+73
View File
@@ -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");
}
+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);
}
);
+175 -23
View File
@@ -184,21 +184,29 @@
:rules="rules"
label-width="140px"
>
<el-form-item :label="$t('peer.name')" prop="name">
<el-form-item
:label="$t('peer.name')"
prop="name"
:error="serverErrors.name"
>
<el-input
v-model="dataForm.name"
:placeholder="$t('peer.form.namePlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.nameHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.remark')">
<el-form-item :label="$t('peer.remark')" :error="serverErrors.remark">
<el-input
v-model="dataForm.remark"
:placeholder="$t('peer.form.remarkPlaceholder')"
/>
<div class="form-hint">{{ $t("peer.form.remarkHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.secret')" prop="secret">
<el-form-item
:label="$t('peer.secret')"
prop="secret"
:error="serverErrors.secret"
>
<el-input
v-model="dataForm.secret"
show-password
@@ -206,20 +214,24 @@
/>
<div class="form-hint">{{ $t("peer.form.secretHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.quota')">
<el-form-item :label="$t('peer.quota')" :error="serverErrors.quotaBytes">
<el-input-number v-model="dataForm.quotaBytes" :min="-1" />
<div class="form-hint">{{ $t("peer.form.quotaHint") }}</div>
</el-form-item>
<el-form-item :label="$t('peer.expireTime')"
<el-form-item
:label="$t('peer.expireTime')"
:error="serverErrors.expiresAt"
><el-date-picker
v-model="dataForm.expiresAt"
type="datetime"
value-format="x"
/></el-form-item>
<el-form-item :label="$t('peer.maxDevices')"
<el-form-item
:label="$t('peer.maxDevices')"
:error="serverErrors.maxDevices"
><el-input-number v-model="dataForm.maxDevices" :min="1"
/></el-form-item>
<el-form-item :label="$t('peer.disabled')"
<el-form-item :label="$t('peer.disabled')" :error="serverErrors.disabled"
><el-switch v-model="disabledBool"
/></el-form-item>
</el-form>
@@ -342,7 +354,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import QrcodeVue from "qrcode.vue";
import { useI18n } from "vue-i18n";
import copy from "copy-to-clipboard";
@@ -369,6 +381,8 @@ import {
PeerVo,
} from "@/api/peer/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
import { isApiError } from "@/utils/api-error";
import { describeApiError, fieldErrorMap } from "@/utils/api-message";
/**
* Единственный переход от строки слота таблицы к модели пира.
@@ -430,6 +444,53 @@ const disabledBool = computed({
set: (v: boolean) => (dataForm.disabled = v ? 1 : 0),
});
/**
* Причины отказа, присланные сервером, — по именам полей формы.
*
* Сервер остаётся ЕДИНСТВЕННЫМ авторитетом: правила ниже лишь избавляют
* оператора от лишнего похода на сервер за очевидной ошибкой, а окончательный
* ответ всегда даёт он. Поэтому его причины подставляются прямо под поля, а не
* показываются тостом «Invalid», как было раньше.
*/
const serverErrors = reactive<Record<string, string>>({});
function clearServerErrors() {
for (const key of Object.keys(serverErrors)) {
delete serverErrors[key];
}
}
/**
* Правка поля снимает серверную причину с НЕГО.
*
* Проп `error` у `el-form-item` перекрывает внутреннее состояние проверки:
* оставленная под полем серверная причина висела бы там, пока оператор
* исправляет значение, и не исчезала бы даже когда локальные правила уже
* довольны. Снимается причина только с изменённого поля — остальные отказы
* той же отправки всё ещё в силе, и убирать их означало бы скрыть работу,
* которую оператору ещё предстоит сделать.
*/
watch(
() => ({ ...dataForm }),
(next, previous) => {
if (!previous) {
return;
}
for (const key of Object.keys(serverErrors)) {
if (next[key as keyof typeof next] !== previous[key as keyof typeof previous]) {
delete serverErrors[key];
}
}
}
);
// Зеркало серверного контракта, а не второй его экземпляр: границы и набор
// символов заданы в service.IsValidPeerName и dto.PeerSaveDto, и расхождение
// здесь приводит лишь к лишнему запросу, а не к принятому некорректному пиру.
const PEER_NAME_PATTERN = /^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$/;
const SECRET_MIN_LENGTH = 6;
const SECRET_MAX_LENGTH = 128;
const rules = {
name: [
{
@@ -437,6 +498,57 @@ const rules = {
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: PEER_NAME_PATTERN,
message: t("error.code.peer_name", {
field: t("error.field.name"),
min: 6,
max: 32,
charset: "a-z A-Z 0-9 !@#$%^&*()_+-=",
}),
trigger: ["change", "blur"],
},
],
secret: [
{
// Пустое поле — законный ввод: секрет сгенерирует сервер. Проверяется
// только НЕПУСТОЕ значение.
validator: (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
const manual = (value ?? "").trim();
if (manual === "") {
callback();
return;
}
if (manual.length < SECRET_MIN_LENGTH) {
callback(
new Error(
t("error.code.min_length", {
field: t("error.field.secret"),
min: SECRET_MIN_LENGTH,
})
)
);
return;
}
if (manual.length > SECRET_MAX_LENGTH) {
callback(
new Error(
t("error.code.max_length", {
field: t("error.field.secret"),
max: SECRET_MAX_LENGTH,
})
)
);
return;
}
callback();
},
trigger: ["change", "blur"],
},
],
};
@@ -481,6 +593,7 @@ async function handleQuery() {
}
function handleAdd() {
clearServerErrors();
Object.assign(dataForm, {
id: undefined,
name: "",
@@ -497,6 +610,7 @@ function handleAdd() {
}
async function handleUpdate(row: PeerVo) {
clearServerErrors();
const { data } = await getPeerApi({ id: row.id });
Object.assign(dataForm, data, { secret: "" });
dialog.title = t("common.update");
@@ -505,6 +619,8 @@ async function handleUpdate(row: PeerVo) {
}
async function submitForm() {
clearServerErrors();
if (formRef.value) {
const ok = await formRef.value.validate().catch(() => false);
if (!ok) return;
@@ -516,25 +632,59 @@ async function submitForm() {
{ type: "warning" }
);
}
if (dialog.editId > 0) {
const payload: PeerUpdateDto = {
id: dialog.editId,
name: dataForm.name,
secret: dataForm.secret || undefined,
quotaBytes: dataForm.quotaBytes,
expiresAt: dataForm.expiresAt,
maxDevices: dataForm.maxDevices,
disabled: dataForm.disabled,
remark: dataForm.remark,
};
await updatePeerApi(payload);
} else {
await savePeerApi(dataForm);
try {
if (dialog.editId > 0) {
const payload: PeerUpdateDto = {
id: dialog.editId,
name: dataForm.name,
// Пустой секрет при изменении означает «не менять», и сервер читает
// его именно так. Отправлять undefined больше не требуется, но и вреда
// в этом нет: оба состояния для него теперь одинаковы.
secret: dataForm.secret || undefined,
quotaBytes: dataForm.quotaBytes,
expiresAt: dataForm.expiresAt,
maxDevices: dataForm.maxDevices,
disabled: dataForm.disabled,
remark: dataForm.remark,
};
await updatePeerApi(payload);
} else {
// Секрет отправляется как есть, включая пустую строку: автогенерация —
// обязанность сервера, а не подстановка значения здесь.
await savePeerApi(dataForm);
}
} catch (error) {
applyServerErrors(error);
return;
}
dialog.visible = false;
await handleQuery();
}
/**
* Раскладывает отказ сервера по полям формы.
*
* Если причина не относится ни к одному полю — это отказ уровня операции
* (например, имя уже занято другим пиром при переименовании), и он
* показывается тостом. Диалог при этом остаётся открытым: закрывать форму,
* потерявшую введённое, из-за исправимой ошибки нельзя.
*/
function applyServerErrors(error: unknown) {
if (!isApiError(error)) {
// Транспортный отказ уже показан общим перехватчиком.
return;
}
const byField = fieldErrorMap(error);
Object.assign(serverErrors, byField);
if (Object.keys(byField).length === 0) {
ElMessage.error(describeApiError(error));
}
}
async function handleDelete(row: PeerVo) {
await ElMessageBox.confirm(
t("common.deleteConfirm", { username: row.name }),
@@ -615,7 +765,9 @@ async function downloadExport(includeSecrets: boolean) {
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.downloadSuccess"));
} catch {
ElMessage.error(t("common.invalid"));
// Выгрузка приходит бинарным потоком, поэтому её отказ не проходит через
// общий разбор конверта: у Blob нет полей code и errors.
ElMessage.error(t("common.systemError"));
}
}