diff --git a/backend/src/PnvPanel.Api/Telegram/CountryFlag.cs b/backend/src/PnvPanel.Api/Telegram/CountryFlag.cs index 2c75cba..1cfe879 100644 --- a/backend/src/PnvPanel.Api/Telegram/CountryFlag.cs +++ b/backend/src/PnvPanel.Api/Telegram/CountryFlag.cs @@ -1,3 +1,5 @@ +using PnvPanel.Domain.Inbounds; + namespace PnvPanel.Api.Telegram; /// Emoji-флаг страны по ISO 3166-1 alpha-2 (пара regional indicator symbols). В Telegram @@ -5,13 +7,17 @@ namespace PnvPanel.Api.Telegram; /// не рисует). internal static class CountryFlag { - /// "DE" → "🇩🇪 " (с пробелом, для префикса к имени); пусто, если кода нет или он кривой. + /// "DE" → "🇩🇪 " (с пробелом, для префикса к имени); балансировщик + /// (Inbound.BalancerCountryCode) → "🔀 "; пусто, если кода нет или он кривой. public static string Prefix(string? countryCode) { if (countryCode is not { Length: 2 } || !countryCode.All(char.IsAsciiLetter)) return string.Empty; var upper = countryCode.ToUpperInvariant(); + if (upper == Inbound.BalancerCountryCode) + return "🔀 "; + return char.ConvertFromUtf32(0x1F1E6 + upper[0] - 'A') + char.ConvertFromUtf32(0x1F1E6 + upper[1] - 'A') + " "; diff --git a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs index bf5a8e2..f89a0c7 100644 --- a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs +++ b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs @@ -19,8 +19,13 @@ public sealed class Inbound : Entity public bool IsAvailable { get; private set; } = true; public string? DisplayName { get; private set; } + /// Зарезервированный CountryCode: инбаунд за балансировщиком, страна выхода заранее + /// неизвестна — вместо флага показывается значок балансира. "XX" — из пользовательского диапазона + /// ISO 3166-1 (реальной стране не присвоен), поэтому влезает в то же поле без миграции. + public const string BalancerCountryCode = "XX"; + /// ISO 3166-1 alpha-2 (верхний регистр) — страна, флаг которой пользователь видит перед - /// именем инбаунда. Только для отображения, на подключение не влияет. + /// именем инбаунда, либо BalancerCountryCode. Только для отображения, на подключение не влияет. public string? CountryCode { get; private set; } public IReadOnlyList AllowedRoleIds { get; private set; } = []; public DateTimeOffset? LastSyncAt { get; private set; } diff --git a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs index a55c42d..c35b262 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs @@ -51,6 +51,7 @@ public class InboundTests [Theory] [InlineData("de", "DE")] [InlineData(" NL ", "NL")] + [InlineData("xx", Inbound.BalancerCountryCode)] [InlineData("", null)] [InlineData(null, null)] public void Publish_NormalizesCountryCode(string? input, string? expected) diff --git a/docs/domain-model.md b/docs/domain-model.md index 96920a2..997973f 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -83,7 +83,7 @@ AppUser | `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) | | `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | -| `CountryCode` | `string?` | ISO 3166-1 alpha-2 (`DE`) — флаг перед именем у пользователя; задаётся при публикации, только отображение | +| `CountryCode` | `string?` | ISO 3166-1 alpha-2 (`DE`) — флаг перед именем у пользователя; задаётся при публикации, только отображение. Зарезервированный `XX` (`Inbound.BalancerCountryCode`) — инбаунд за балансировщиком, страна выхода неизвестна: вместо флага значок балансира (на сайте — иконка `Shuffle` с подсказкой, в боте — 🔀) | | `LastSyncAt` | `DateTimeOffset?` | | Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя diff --git a/docs/frontend.md b/docs/frontend.md index 22b3e47..1f5598a 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -91,7 +91,8 @@ frontend/ неактивированного — `ActivationGate` вместо дашборда. - **Создание конфига**: диалог — выбор инбаунда (по `displayName`, с флагом страны) + метка. Флаги — SVG из `country-flag-icons` (курируемый список в `shared/lib/countries.ts`; emoji-флаги Windows не рисует), - в Telegram-боте — emoji. У каждой локации — загрузка её ноды: «светофор» + % от `Node.Capacity` + в Telegram-боте — emoji. Код `XX` (`BALANCER_COUNTRY_CODE`) — инбаунд за балансировщиком: вместо флага + иконка `Shuffle` с подсказкой «страна выхода может меняться» (в боте — 🔀). У каждой локации — загрузка её ноды: «светофор» + % от `Node.Capacity` (<50 / <80 / ≥80), либо число конфигов на сервере, если ёмкость не задана; порядок — с сервера, от менее загруженных. Лимит одновременных IP (`limitIp` в 3x-ui) выставляется автоматически по квоте роли пользователя (`AppRole.MaxIpLimit`) — diff --git a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx index 0a3bcac..e36af32 100644 --- a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx +++ b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx @@ -8,7 +8,7 @@ import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { CountryFlag } from '@/shared/ui/country-flag' -import { COUNTRY_CODES, countryName } from '@/shared/lib/countries' +import { BALANCER_COUNTRY_CODE, COUNTRY_CODES, countryName } from '@/shared/lib/countries' import { HttpError } from '@/shared/api/client' import { listRoles } from '@/features/admin/roles/api' import type { InboundDto } from '@/shared/api/types' @@ -94,6 +94,12 @@ export function PublishInboundDialog({ {t('admin.nodes.noCountry')} + + + + {t('admin.nodes.balancerCountry')} + + {COUNTRY_CODES.map((code) => [code, countryName(code, i18n.language)] as const) .sort(([, a], [, b]) => a.localeCompare(b, i18n.language)) .map(([code, name]) => ( diff --git a/frontend/src/shared/lib/countries.ts b/frontend/src/shared/lib/countries.ts index ab57a0e..7ad4788 100644 --- a/frontend/src/shared/lib/countries.ts +++ b/frontend/src/shared/lib/countries.ts @@ -17,6 +17,10 @@ export const FLAGS: Record = { export const COUNTRY_CODES = Object.keys(FLAGS) +/** Зарезервированный код «инбаунд за балансировщиком, страна выхода неизвестна» (см. + * Inbound.BalancerCountryCode на бэкенде) — вместо флага рисуется значок балансира. */ +export const BALANCER_COUNTRY_CODE = 'XX' + /** Локализованное имя страны по ISO-коду (Intl), без собственных переводов. */ export function countryName(code: string, language: string): string { try { diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index f137ed3..aed2277 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -13,6 +13,9 @@ const resources = { system: 'Системная', unlimited: 'Без лимита', + countries: { + balancer: 'Через балансировщик — страна выхода может меняться', + }, auth: { loginTitle: 'Вход', registerTitle: 'Регистрация', @@ -487,6 +490,7 @@ const resources = { allowedRoles: 'Доступно ролям', country: 'Страна (флаг у пользователей)', noCountry: 'Без флага', + balancerCountry: 'Балансировщик (страна выхода не определена)', isPublishedLabel: 'Опубликовать инбаунд', optional: 'необязательно', passwordKeepUnchanged: 'оставьте пустым, чтобы не менять', @@ -645,6 +649,9 @@ const resources = { system: 'System', unlimited: 'Unlimited', + countries: { + balancer: 'Via load balancer — exit country may vary', + }, auth: { loginTitle: 'Log in', registerTitle: 'Register', @@ -1117,6 +1124,7 @@ const resources = { allowedRoles: 'Allowed for roles', country: 'Country (flag shown to users)', noCountry: 'No flag', + balancerCountry: 'Load balancer (exit country varies)', isPublishedLabel: 'Publish inbound', optional: 'optional', passwordKeepUnchanged: 'leave blank to keep unchanged', diff --git a/frontend/src/shared/ui/country-flag.tsx b/frontend/src/shared/ui/country-flag.tsx index f0e3357..0230c05 100644 --- a/frontend/src/shared/ui/country-flag.tsx +++ b/frontend/src/shared/ui/country-flag.tsx @@ -1,8 +1,19 @@ -import { FLAGS } from '@/shared/lib/countries' +import { Shuffle } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { BALANCER_COUNTRY_CODE, FLAGS } from '@/shared/lib/countries' import { cn } from '@/shared/lib/cn' export function CountryFlag({ code, className }: { code: string | null | undefined; className?: string }) { + const { t } = useTranslation() if (!code) return null + if (code.toUpperCase() === BALANCER_COUNTRY_CODE) { + const label = t('countries.balancer') + return ( + + + + ) + } const Flag = FLAGS[code.toUpperCase()] if (!Flag) { return {code.toUpperCase()}