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 после
разнесения по каталогам совпадал бы ровно с одним файлом.
@@ -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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 @@
|
||||
<?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 |
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,3 +32,10 @@ $menuActiveBorder: var(--menuActiveBorder);
|
||||
|
||||
$sideBarWidth: 210px;
|
||||
$sideBarCollapsedWidth: 54px;
|
||||
|
||||
// Высота подписи разработчика внизу бокового меню.
|
||||
//
|
||||
// Значение объявлено здесь, потому что его знают ДВОЕ: сам футер и высота
|
||||
// области прокрутки меню, из которой оно вычитается. Разойдясь, эти двое дают
|
||||
// либо наезд пунктов меню на подпись, либо полосу пустоты над ней.
|
||||
$sidebarFooterHeight: 34px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||