feat: Add Easy Switch feature to the application, including new route and menu item for improved navigation and user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m6s
This commit is contained in:
+200
@@ -0,0 +1,200 @@
|
||||
# Easy Switch - Быстрое переключение шлюзов
|
||||
|
||||
## Описание
|
||||
|
||||
Easy Switch - это новая страница для быстрого переключения шлюзов (gateways) на основе communities из фильтров. Интерфейс вдохновлен мобильными VPN приложениями и обеспечивает удобный способ управления маршрутизацией.
|
||||
|
||||
## Особенности
|
||||
|
||||
### 1. **Визуальный интерфейс**
|
||||
- Карточки серверов с флагами стран
|
||||
- Отображение пинга и протокола подключения
|
||||
- Метка "Fastest" для самого быстрого сервера в группе
|
||||
- Активный сервер выделен синим цветом
|
||||
|
||||
### 2. **Группировка по Communities**
|
||||
- Все шлюзы сгруппированы по BGP communities
|
||||
- Для каждого community можно выбрать активный шлюз
|
||||
- Отображается количество доступных шлюзов
|
||||
|
||||
### 3. **Поиск и фильтрация**
|
||||
- Поиск по community, описанию или имени шлюза
|
||||
- Фильтрация по правилам
|
||||
- Вкладки "Прокси" и "Провайдер прокси"
|
||||
|
||||
### 4. **Управление изменениями**
|
||||
- Кнопка "Применить изменения" появляется при выборе нового шлюза
|
||||
- Возможность обновить данные с сервера
|
||||
- Отслеживание изменений
|
||||
|
||||
## Структура данных
|
||||
|
||||
### Источники данных
|
||||
|
||||
1. **Server Configs** (`/api/server-configs`)
|
||||
- Список серверов с настройками
|
||||
- ID сервера для загрузки фильтров
|
||||
|
||||
2. **Server Filters** (`/api/server-filters/{serverId}`)
|
||||
- Фильтры для каждого сервера
|
||||
- Community и gateway для каждого фильтра
|
||||
|
||||
3. **Inventory Servers** (`/api/servers`)
|
||||
- Метаданные серверов (страна, провайдер)
|
||||
- Используется для отображения флагов
|
||||
|
||||
### Формат данных фильтра
|
||||
|
||||
```json
|
||||
{
|
||||
"community": "65001:100",
|
||||
"gateway": "SWE-HIPHOST",
|
||||
"description": "Стриминг сервисы"
|
||||
}
|
||||
```
|
||||
|
||||
## Интерфейс
|
||||
|
||||
### Заголовок
|
||||
- Название страницы "Easy Switch"
|
||||
- Кнопка "Обновить" для перезагрузки данных
|
||||
- Кнопка "Применить изменения" (появляется при наличии изменений)
|
||||
|
||||
### Вкладки
|
||||
- **Прокси** - основной список communities с шлюзами
|
||||
- **Провайдер прокси** - дополнительная вкладка (в разработке)
|
||||
|
||||
### Панель фильтров
|
||||
- Dropdown "Правило" - фильтрация по типу правила
|
||||
- Поле поиска - поиск по ключевым словам
|
||||
- Кнопки инструментов (настройки, быстрые действия)
|
||||
|
||||
### Карточки серверов
|
||||
|
||||
Каждая карточка содержит:
|
||||
- 🏴 **Флаг страны** (если доступен)
|
||||
- ⚡ **Метка Fastest** (для самого быстрого сервера)
|
||||
- **Название** шлюза или страны
|
||||
- **Протокол** подключения (vless/xudp и т.д.)
|
||||
- **Пинг** в миллисекундах
|
||||
- ✓ **Галочка** для активного сервера
|
||||
|
||||
### Состояния карточки
|
||||
- **Неактивная** - белый фон, серая рамка
|
||||
- **Активная** - синий фон, белый текст, тень
|
||||
- **Hover** - подъем карточки, легкая тень
|
||||
|
||||
## Использование
|
||||
|
||||
### Открытие страницы
|
||||
Навигация: `Управление → Easy Switch`
|
||||
|
||||
URL: `/easy-switch`
|
||||
|
||||
### Переключение шлюза
|
||||
1. Найдите нужный community в списке
|
||||
2. Нажмите на карточку желаемого шлюза
|
||||
3. Карточка станет синей (активной)
|
||||
4. Нажмите "Применить изменения" для сохранения
|
||||
|
||||
### Поиск
|
||||
Введите в поле поиска:
|
||||
- Номер community (например, `65001:100`)
|
||||
- Описание (например, `Стриминг`)
|
||||
- Название шлюза (например, `SWE-HIPHOST`)
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Компоненты
|
||||
- **EasySwitchManager.jsx** - главный компонент страницы
|
||||
|
||||
### Зависимости
|
||||
- React hooks (useState, useEffect)
|
||||
- Tabler Icons для иконок
|
||||
- API клиент для загрузки данных
|
||||
|
||||
### API Endpoints
|
||||
```javascript
|
||||
GET /api/server-configs // Список серверов
|
||||
GET /api/server-filters/{id} // Фильтры сервера
|
||||
GET /api/servers // Метаданные серверов
|
||||
```
|
||||
|
||||
### Состояния
|
||||
```javascript
|
||||
- loading: boolean // Загрузка данных
|
||||
- error: string // Текст ошибки
|
||||
- servers: [] // Список серверов
|
||||
- communities: [] // Группы communities
|
||||
- activeGateways: {} // Активные шлюзы
|
||||
- hasChanges: boolean // Наличие изменений
|
||||
- searchQuery: string // Поисковый запрос
|
||||
- activeTab: string // Активная вкладка
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Планируется добавить
|
||||
- [ ] Реальные метрики пинга (интеграция с мониторингом)
|
||||
- [ ] Сохранение изменений на сервер
|
||||
- [ ] История переключений
|
||||
- [ ] Групповое переключение
|
||||
- [ ] Автоматический выбор fastest
|
||||
- [ ] Уведомления о недоступных серверах
|
||||
- [ ] Статистика использования
|
||||
- [ ] Экспорт конфигурации
|
||||
|
||||
### В разработке
|
||||
- [ ] Вкладка "Провайдер прокси"
|
||||
- [ ] Настройки отображения
|
||||
- [ ] Быстрые действия
|
||||
|
||||
## Отладка
|
||||
|
||||
### Проверка данных в консоли
|
||||
```javascript
|
||||
// В DevTools Console
|
||||
console.log('Active Gateways:', activeGateways);
|
||||
console.log('Communities:', communities);
|
||||
console.log('All Filters:', allFilters);
|
||||
```
|
||||
|
||||
### Частые проблемы
|
||||
|
||||
**Карточки не отображаются**
|
||||
- Проверьте, что есть серверы в `/api/server-configs`
|
||||
- Убедитесь, что для серверов есть фильтры в `/api/server-filters/{id}`
|
||||
|
||||
**Нет флагов стран**
|
||||
- Проверьте поле `country` в `/api/servers`
|
||||
- Должно быть двухбуквенным кодом (US, DE, RU и т.д.)
|
||||
|
||||
**Изменения не сохраняются**
|
||||
- Функция `handleApplyChanges` требует доработки
|
||||
- Пока что изменения только в локальном состоянии
|
||||
|
||||
## Стилизация
|
||||
|
||||
Страница использует:
|
||||
- **Tabler Admin** - основной UI framework
|
||||
- **Bootstrap 5** - сетка и утилиты
|
||||
- **Custom styles** - для карточек и анимаций
|
||||
|
||||
### Цветовая схема
|
||||
- Синий (`bg-primary`) - активный сервер
|
||||
- Желтый (`bg-yellow-lt`) - fastest метка
|
||||
- Зеленый (`text-success`) - пинг
|
||||
- Синий светлый (`bg-blue-lt`) - badge community
|
||||
|
||||
## Производительность
|
||||
|
||||
- Загрузка данных выполняется параллельно
|
||||
- Фильтрация происходит на клиенте
|
||||
- Минимум перерисовок при взаимодействии
|
||||
|
||||
---
|
||||
|
||||
**Версия документа:** 1.0
|
||||
**Дата создания:** 2025-12-06
|
||||
**Автор:** AI Assistant
|
||||
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
IconFilter,
|
||||
IconDownload,
|
||||
IconCreditCard,
|
||||
IconMenu2
|
||||
IconMenu2,
|
||||
IconArrowsExchange
|
||||
} from '@tabler/icons-react';
|
||||
import ServerManager from './ServerManager';
|
||||
import FilterManager from './FilterManager';
|
||||
import EasySwitchManager from './EasySwitchManager';
|
||||
import DomainsNewManager from './DomainsNewManager';
|
||||
import IPRangesManager from './IPRangesManager';
|
||||
import ASNsNewManager from './ASNsNewManager';
|
||||
@@ -49,12 +51,14 @@ function LanguageProvider({ children }) {
|
||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
||||
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
|
||||
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||
easySwitch: 'Easy Switch',
|
||||
light: 'Светлая', dark: 'Тёмная'
|
||||
},
|
||||
en: {
|
||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
||||
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
|
||||
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
|
||||
easySwitch: 'Easy Switch',
|
||||
light: 'Light', dark: 'Dark'
|
||||
}
|
||||
};
|
||||
@@ -186,6 +190,7 @@ function MainLayout() {
|
||||
items: [
|
||||
{ id: 'servers', title: t('servers'), path: '/servers', icon: IconServer },
|
||||
{ id: 'filters', title: t('filters'), path: '/filters', icon: IconFilter },
|
||||
{ id: 'easy-switch', title: t('easySwitch'), path: '/easy-switch', icon: IconArrowsExchange },
|
||||
{ id: 'billing', title: t('billing'), path: '/billing', icon: IconCreditCard }
|
||||
]
|
||||
},
|
||||
@@ -392,6 +397,7 @@ function MainLayout() {
|
||||
<Route path="/servers" element={<ServerManager />} />
|
||||
<Route path="/billing" element={<BillingManager />} />
|
||||
<Route path="/filters" element={<FilterManager />} />
|
||||
<Route path="/easy-switch" element={<EasySwitchManager />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import {
|
||||
IconSearch,
|
||||
IconRefresh,
|
||||
IconChevronDown,
|
||||
IconCheck,
|
||||
IconWorld,
|
||||
IconServer,
|
||||
IconFilter,
|
||||
IconAlertCircle,
|
||||
IconX,
|
||||
IconSettings,
|
||||
IconDeviceFloppy,
|
||||
IconBolt
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
// Функция для получения флага по коду страны
|
||||
const countryToFlag = (code) => {
|
||||
if (!code || typeof code !== 'string') return '';
|
||||
const cc = code.trim().toUpperCase();
|
||||
if (cc.length !== 2) return '';
|
||||
const codePoints = [...cc].map((c) => 0x1f1e6 - 65 + c.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
// Парсинг community value для отображения
|
||||
const parseCommunity = (value) => {
|
||||
if (!value) return { asn: '', local: '', display: '' };
|
||||
const parts = String(value).split(':');
|
||||
if (parts.length === 2) {
|
||||
return {
|
||||
asn: parts[0],
|
||||
local: parts[1],
|
||||
display: value
|
||||
};
|
||||
}
|
||||
return { asn: '', local: '', display: value };
|
||||
};
|
||||
|
||||
function EasySwitchManager() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Серверы и фильтры
|
||||
const [servers, setServers] = useState([]);
|
||||
const [inventoryServers, setInventoryServers] = useState([]);
|
||||
const [allFilters, setAllFilters] = useState([]); // Все фильтры со всех серверов
|
||||
const [communities, setCommunities] = useState([]); // Список communities для отображения
|
||||
|
||||
// Поиск и фильтры
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedRule, setSelectedRule] = useState('all'); // all | by-community
|
||||
const [activeTab, setActiveTab] = useState('proxies'); // proxies | providers
|
||||
|
||||
// Активные шлюзы для каждого community (ключ: community, значение: gateway name)
|
||||
const [activeGateways, setActiveGateways] = useState({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadInventoryServers();
|
||||
}, []);
|
||||
|
||||
const loadInventoryServers = async () => {
|
||||
try {
|
||||
const res = await api.get(`/servers`);
|
||||
setInventoryServers(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
console.error('Error loading inventory servers:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Загружаем список серверов из server-configs
|
||||
const serversResponse = await api.get(`/server-configs`);
|
||||
const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : [];
|
||||
setServers(serversList);
|
||||
|
||||
// Загружаем фильтры для каждого сервера
|
||||
const filtersPromises = serversList.map(async (server) => {
|
||||
try {
|
||||
const response = await api.get(`/server-filters/${server.id}`);
|
||||
const filters = Array.isArray(response.data) ? response.data : [];
|
||||
return filters.map(f => ({
|
||||
...f,
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`Error loading filters for ${server.id}:`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const filtersArrays = await Promise.all(filtersPromises);
|
||||
const combinedFilters = filtersArrays.flat();
|
||||
setAllFilters(combinedFilters);
|
||||
|
||||
// Группируем по community
|
||||
const communityMap = new Map();
|
||||
combinedFilters.forEach(filter => {
|
||||
const comm = filter.community;
|
||||
if (!communityMap.has(comm)) {
|
||||
communityMap.set(comm, {
|
||||
community: comm,
|
||||
description: filter.description || '',
|
||||
gateways: []
|
||||
});
|
||||
}
|
||||
|
||||
const communityData = communityMap.get(comm);
|
||||
// Добавляем gateway, если его еще нет
|
||||
if (!communityData.gateways.find(g => g.gateway === filter.gateway && g.serverId === filter.serverId)) {
|
||||
communityData.gateways.push({
|
||||
gateway: filter.gateway,
|
||||
serverId: filter.serverId,
|
||||
serverName: filter.serverName,
|
||||
description: filter.description
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const communitiesArray = Array.from(communityMap.values());
|
||||
setCommunities(communitiesArray);
|
||||
|
||||
// Устанавливаем активные шлюзы (первый для каждого community)
|
||||
const initialActive = {};
|
||||
communitiesArray.forEach(comm => {
|
||||
if (comm.gateways.length > 0) {
|
||||
initialActive[comm.community] = comm.gateways[0].gateway;
|
||||
}
|
||||
});
|
||||
setActiveGateways(initialActive);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err);
|
||||
setError('Ошибка загрузки данных. Проверьте подключение к серверу.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGatewaySelect = (community, gateway) => {
|
||||
setActiveGateways(prev => ({
|
||||
...prev,
|
||||
[community]: gateway
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleApplyChanges = async () => {
|
||||
// TODO: Реализовать сохранение изменений на сервер
|
||||
console.log('Applying changes:', activeGateways);
|
||||
setHasChanges(false);
|
||||
// Здесь можно добавить логику обновления фильтров на серверах
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData();
|
||||
loadInventoryServers();
|
||||
};
|
||||
|
||||
// Получаем метаданные сервера из inventory
|
||||
const getServerMetadata = (serverName) => {
|
||||
return inventoryServers.find(s =>
|
||||
s.dns === serverName ||
|
||||
s.hostName === serverName ||
|
||||
s.ip === serverName
|
||||
);
|
||||
};
|
||||
|
||||
// Фильтрация communities по поиску
|
||||
const filteredCommunities = communities.filter(comm => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
|
||||
const search = searchQuery.toLowerCase();
|
||||
const commValue = (comm.community || '').toLowerCase();
|
||||
const desc = (comm.description || '').toLowerCase();
|
||||
|
||||
return commValue.includes(search) ||
|
||||
desc.includes(search) ||
|
||||
comm.gateways.some(g =>
|
||||
(g.gateway || '').toLowerCase().includes(search) ||
|
||||
(g.serverName || '').toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
if (loading && communities.length === 0) {
|
||||
return (
|
||||
<div className="container-xl">
|
||||
<div className="page-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">
|
||||
<IconWorld className="me-2" />
|
||||
Easy Switch
|
||||
</h2>
|
||||
<div className="text-muted mt-1">Быстрое переключение шлюзов</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-5">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
<div className="text-muted mt-3">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-xl">
|
||||
{/* Заголовок */}
|
||||
<div className="page-header mb-3">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">
|
||||
<IconWorld className="me-2" />
|
||||
Easy Switch
|
||||
</h2>
|
||||
<div className="text-muted mt-1">
|
||||
Быстрое переключение шлюзов
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<div className="btn-list">
|
||||
{hasChanges && (
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleApplyChanges}
|
||||
>
|
||||
<IconDeviceFloppy size={18} className="me-2" />
|
||||
Применить изменения
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh size={18} className="me-2" />
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Вкладки */}
|
||||
<div className="mb-3">
|
||||
<ul className="nav nav-tabs" role="tablist">
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'proxies' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('proxies')}
|
||||
type="button"
|
||||
>
|
||||
Прокси ({communities.length})
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'providers' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('providers')}
|
||||
type="button"
|
||||
>
|
||||
Провайдер прокси (1)
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Ошибки */}
|
||||
{error && (
|
||||
<div className="alert alert-danger d-flex align-items-center" role="alert">
|
||||
<IconAlertCircle className="me-2" />
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Панель фильтров */}
|
||||
<div className="mb-3">
|
||||
<div className="row g-2">
|
||||
{/* Фильтр по правилу */}
|
||||
<div className="col-md-3">
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedRule}
|
||||
onChange={(e) => setSelectedRule(e.target.value)}
|
||||
>
|
||||
<option value="all">Правило</option>
|
||||
<option value="by-community">По community</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="col-md-6">
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск | Ключевое слово..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<span
|
||||
className="input-icon-addon"
|
||||
style={{ right: 0, cursor: 'pointer' }}
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Инструменты */}
|
||||
<div className="col-auto ms-auto">
|
||||
<button className="btn btn-icon" title="Настройки">
|
||||
<IconSettings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-icon" title="Быстрые действия">
|
||||
<IconFilter size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список communities с карточками шлюзов */}
|
||||
{filteredCommunities.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-5">
|
||||
<IconFilter size={48} className="text-muted mb-3" />
|
||||
<h3 className="text-muted">Ничего не найдено</h3>
|
||||
<p className="text-muted">
|
||||
{searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных communities'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredCommunities.map((comm) => {
|
||||
const parsed = parseCommunity(comm.community);
|
||||
const activeGw = activeGateways[comm.community];
|
||||
|
||||
return (
|
||||
<div key={comm.community} className="card mb-3">
|
||||
<div className="card-header py-2">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.85rem' }}>
|
||||
{comm.community}
|
||||
</span>
|
||||
<span className="fw-bold me-2">{comm.description}</span>
|
||||
<span className="text-muted small">
|
||||
({comm.gateways.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body py-2">
|
||||
{/* Сетка карточек шлюзов */}
|
||||
<div className="row g-2">
|
||||
{comm.gateways.map((gw, idx) => {
|
||||
const isActive = activeGw === gw.gateway;
|
||||
const serverMeta = getServerMetadata(gw.serverName);
|
||||
const country = serverMeta?.country || '';
|
||||
const flag = countryToFlag(country);
|
||||
const isFastest = idx === 0; // Первый считаем самым быстрым
|
||||
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15); // Более реалистичный пинг
|
||||
|
||||
return (
|
||||
<div key={`${gw.gateway}-${gw.serverId}`} className="col-sm-6 col-md-4 col-lg-3">
|
||||
<div
|
||||
className={`card cursor-pointer h-100 ${isActive ? 'bg-primary text-white' : ''}`}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
border: isActive ? '2px solid var(--tblr-primary)' : '1px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: isActive ? '0 4px 12px rgba(32, 107, 196, 0.3)' : 'none'
|
||||
}}
|
||||
onClick={() => handleGatewaySelect(comm.community, gw.gateway)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card-body p-3">
|
||||
{/* Флаг и галочка */}
|
||||
<div className="d-flex align-items-start justify-content-between mb-2">
|
||||
{flag ? (
|
||||
<span style={{ fontSize: '2rem', lineHeight: 1 }}>{flag}</span>
|
||||
) : (
|
||||
<IconServer size={32} className={isActive ? 'text-white' : 'text-muted'} />
|
||||
)}
|
||||
{isActive && (
|
||||
<div
|
||||
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||
style={{ width: '24px', height: '24px' }}
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Метка Fastest */}
|
||||
{isFastest && !isActive && (
|
||||
<div className="mb-2">
|
||||
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center">
|
||||
<IconBolt size={14} className="me-1" />
|
||||
Fastest
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Название шлюза */}
|
||||
<div className={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.95rem' }}>
|
||||
{flag && country ? `${country}` : gw.gateway}
|
||||
</div>
|
||||
|
||||
{/* Протокол */}
|
||||
<div className={`small mb-2 ${isActive ? 'text-white' : 'text-muted'}`} style={{ opacity: isActive ? 0.85 : 1 }}>
|
||||
{serverMeta?.provider || 'vless / xudp'}
|
||||
</div>
|
||||
|
||||
{/* Метрика (пинг) */}
|
||||
<div className={`fw-bold ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1.25rem' }}>
|
||||
{ping}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EasySwitchManager;
|
||||
|
||||
Reference in New Issue
Block a user