feat: Update Easy Switch functionality to improve server and gateway management, including enhanced grouping by servers, refined UI components, and updated data structure for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m59s
This commit is contained in:
+187
-185
@@ -3,7 +3,6 @@ import api from './lib/api.js';
|
||||
import {
|
||||
IconSearch,
|
||||
IconRefresh,
|
||||
IconChevronDown,
|
||||
IconCheck,
|
||||
IconWorld,
|
||||
IconServer,
|
||||
@@ -15,8 +14,6 @@ import {
|
||||
IconBolt
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
// Функция для получения флага по коду страны
|
||||
const countryToFlag = (code) => {
|
||||
if (!code || typeof code !== 'string') return '';
|
||||
@@ -26,20 +23,6 @@ const countryToFlag = (code) => {
|
||||
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('');
|
||||
@@ -47,8 +30,6 @@ function EasySwitchManager() {
|
||||
// Серверы и фильтры
|
||||
const [servers, setServers] = useState([]);
|
||||
const [inventoryServers, setInventoryServers] = useState([]);
|
||||
const [allFilters, setAllFilters] = useState([]); // Все фильтры со всех серверов
|
||||
const [communities, setCommunities] = useState([]); // Список communities для отображения
|
||||
|
||||
// Поиск и фильтры
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -81,61 +62,63 @@ function EasySwitchManager() {
|
||||
// Загружаем список серверов из 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 serversWithFilters = await Promise.all(
|
||||
serversList.map(async (server) => {
|
||||
try {
|
||||
const response = await api.get(`/server-filters/${server.id}`);
|
||||
const filters = Array.isArray(response.data) ? response.data : [];
|
||||
|
||||
// Группируем фильтры по community внутри сервера
|
||||
const communitiesMap = new Map();
|
||||
filters.forEach(filter => {
|
||||
const comm = filter.community;
|
||||
if (!communitiesMap.has(comm)) {
|
||||
communitiesMap.set(comm, {
|
||||
community: comm,
|
||||
description: filter.description || '',
|
||||
gateways: []
|
||||
});
|
||||
}
|
||||
|
||||
const communityData = communitiesMap.get(comm);
|
||||
// Добавляем gateway, если его еще нет
|
||||
if (!communityData.gateways.find(g => g.name === filter.gateway)) {
|
||||
communityData.gateways.push({
|
||||
name: filter.gateway,
|
||||
description: filter.description
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...server,
|
||||
communities: Array.from(communitiesMap.values())
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Error loading filters for ${server.id}:`, e);
|
||||
return {
|
||||
...server,
|
||||
communities: []
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const filtersArrays = await Promise.all(filtersPromises);
|
||||
const combinedFilters = filtersArrays.flat();
|
||||
setAllFilters(combinedFilters);
|
||||
// Фильтруем серверы, у которых есть communities
|
||||
const serversWithCommunities = serversWithFilters.filter(s => s.communities.length > 0);
|
||||
setServers(serversWithCommunities);
|
||||
|
||||
// Группируем по 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)
|
||||
// Устанавливаем активные шлюзы (первый gateway для каждого community на каждом сервере)
|
||||
const initialActive = {};
|
||||
communitiesArray.forEach(comm => {
|
||||
if (comm.gateways.length > 0) {
|
||||
initialActive[comm.community] = comm.gateways[0].gateway;
|
||||
}
|
||||
serversWithCommunities.forEach(server => {
|
||||
server.communities.forEach(comm => {
|
||||
if (comm.gateways.length > 0) {
|
||||
const key = `${server.id}:${comm.community}`;
|
||||
initialActive[key] = comm.gateways[0].name;
|
||||
}
|
||||
});
|
||||
});
|
||||
setActiveGateways(initialActive);
|
||||
|
||||
@@ -147,10 +130,11 @@ function EasySwitchManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGatewaySelect = (community, gateway) => {
|
||||
const handleGatewaySelect = (serverId, community, gateway) => {
|
||||
const key = `${serverId}:${community}`;
|
||||
setActiveGateways(prev => ({
|
||||
...prev,
|
||||
[community]: gateway
|
||||
[key]: gateway
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
@@ -176,23 +160,29 @@ function EasySwitchManager() {
|
||||
);
|
||||
};
|
||||
|
||||
// Фильтрация communities по поиску
|
||||
const filteredCommunities = communities.filter(comm => {
|
||||
// Фильтрация серверов по поиску
|
||||
const filteredServers = servers.filter(server => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
|
||||
const search = searchQuery.toLowerCase();
|
||||
const commValue = (comm.community || '').toLowerCase();
|
||||
const desc = (comm.description || '').toLowerCase();
|
||||
const serverName = (server.name || '').toLowerCase();
|
||||
const serverDesc = (server.description || '').toLowerCase();
|
||||
|
||||
return commValue.includes(search) ||
|
||||
desc.includes(search) ||
|
||||
comm.gateways.some(g =>
|
||||
(g.gateway || '').toLowerCase().includes(search) ||
|
||||
(g.serverName || '').toLowerCase().includes(search)
|
||||
);
|
||||
// Поиск по имени/описанию сервера
|
||||
if (serverName.includes(search) || serverDesc.includes(search)) return true;
|
||||
|
||||
// Поиск по communities и gateways
|
||||
return server.communities.some(comm => {
|
||||
const commValue = (comm.community || '').toLowerCase();
|
||||
const commDesc = (comm.description || '').toLowerCase();
|
||||
|
||||
return commValue.includes(search) ||
|
||||
commDesc.includes(search) ||
|
||||
comm.gateways.some(g => (g.name || '').toLowerCase().includes(search));
|
||||
});
|
||||
});
|
||||
|
||||
if (loading && communities.length === 0) {
|
||||
if (loading && servers.length === 0) {
|
||||
return (
|
||||
<div className="container-xl">
|
||||
<div className="page-header">
|
||||
@@ -230,7 +220,7 @@ function EasySwitchManager() {
|
||||
Easy Switch
|
||||
</h2>
|
||||
<div className="text-muted mt-1">
|
||||
Быстрое переключение шлюзов
|
||||
Быстрое переключение шлюзов для {servers.length} {servers.length === 1 ? 'сервера' : 'серверов'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
@@ -266,7 +256,7 @@ function EasySwitchManager() {
|
||||
onClick={() => setActiveTab('proxies')}
|
||||
type="button"
|
||||
>
|
||||
Прокси ({communities.length})
|
||||
Прокси ({servers.reduce((sum, s) => sum + s.communities.length, 0)})
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
@@ -343,123 +333,135 @@ function EasySwitchManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список communities с карточками шлюзов */}
|
||||
{filteredCommunities.length === 0 ? (
|
||||
{/* Список серверов с communities и шлюзами */}
|
||||
{filteredServers.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'}
|
||||
{searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных серверов'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredCommunities.map((comm) => {
|
||||
const parsed = parseCommunity(comm.community);
|
||||
const activeGw = activeGateways[comm.community];
|
||||
{filteredServers.map((server) => {
|
||||
const serverMeta = getServerMetadata(server.name);
|
||||
const country = serverMeta?.country || '';
|
||||
const flag = countryToFlag(country);
|
||||
|
||||
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 key={server.id} className="card mb-3">
|
||||
{/* Заголовок сервера */}
|
||||
<div className="card-header py-2" style={{ backgroundColor: 'rgba(32, 107, 196, 0.05)' }}>
|
||||
<div className="d-flex align-items-center">
|
||||
{flag && <span className="me-2" style={{ fontSize: '1.5rem' }}>{flag}</span>}
|
||||
<IconServer size={20} className="me-2 text-primary" />
|
||||
<h3 className="card-title mb-0 me-2">{server.name}</h3>
|
||||
{server.description && (
|
||||
<span className="text-muted small">— {server.description}</span>
|
||||
)}
|
||||
<span className="badge bg-blue-lt text-blue ms-auto">
|
||||
{server.communities.length} {server.communities.length === 1 ? 'community' : 'communities'}
|
||||
</span>
|
||||
</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 className="card-body p-3">
|
||||
{/* Communities для этого сервера */}
|
||||
{server.communities.map((comm) => {
|
||||
const activeKey = `${server.id}:${comm.community}`;
|
||||
const activeGw = activeGateways[activeKey];
|
||||
|
||||
return (
|
||||
<div key={comm.community} className="mb-3">
|
||||
{/* Заголовок community */}
|
||||
<div className="mb-2">
|
||||
<span className="badge bg-blue-lt text-blue me-2" style={{ fontSize: '0.8rem' }}>
|
||||
{comm.community}
|
||||
</span>
|
||||
<span className="fw-medium">{comm.description}</span>
|
||||
<span className="text-muted small ms-2">
|
||||
({comm.gateways.length})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Сетка карточек шлюзов */}
|
||||
<div className="row g-2">
|
||||
{comm.gateways.map((gw, idx) => {
|
||||
const isActive = activeGw === gw.name;
|
||||
const isFastest = idx === 0;
|
||||
const ping = 20 + idx * 10 + Math.floor(Math.random() * 15);
|
||||
|
||||
return (
|
||||
<div key={gw.name} className="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<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: '6px',
|
||||
boxShadow: isActive ? '0 3px 8px rgba(32, 107, 196, 0.3)' : 'none'
|
||||
}}
|
||||
onClick={() => handleGatewaySelect(server.id, comm.community, gw.name)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card-body p-2">
|
||||
{/* Галочка и метка Fastest */}
|
||||
<div className="d-flex align-items-start justify-content-between mb-1">
|
||||
{isFastest && !isActive && (
|
||||
<span className="badge bg-yellow-lt text-yellow d-inline-flex align-items-center" style={{ fontSize: '0.65rem', padding: '2px 4px' }}>
|
||||
<IconBolt size={10} className="me-1" />
|
||||
Fast
|
||||
</span>
|
||||
)}
|
||||
{!isFastest && <span></span>}
|
||||
{isActive && (
|
||||
<div
|
||||
className="bg-white text-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||
style={{ width: '18px', height: '18px' }}
|
||||
>
|
||||
<IconCheck size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Название шлюза */}
|
||||
<div className={`fw-bold mb-1 ${isActive ? 'text-white' : ''}`} style={{ fontSize: '0.8rem' }}>
|
||||
{gw.name}
|
||||
</div>
|
||||
|
||||
{/* Протокол */}
|
||||
<div className={`small ${isActive ? 'text-white' : 'text-muted'}`} style={{ fontSize: '0.7rem', opacity: isActive ? 0.85 : 1 }}>
|
||||
{serverMeta?.provider || 'vless'}
|
||||
</div>
|
||||
|
||||
{/* Метрика (пинг) */}
|
||||
<div className={`fw-bold mt-1 ${isActive ? 'text-white' : 'text-success'}`} style={{ fontSize: '1rem' }}>
|
||||
{ping}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user