func optimization
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import SubscriptionsPage from './pages/SubscriptionsPage';
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { createContext, useState, useMemo, useContext, useEffect } from '
|
||||
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
// Импортируем нашу настройку
|
||||
import { getDesignTokens } from './theme';
|
||||
|
||||
type ColorMode = 'light' | 'dark' | 'system';
|
||||
@@ -17,7 +16,6 @@ const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
|
||||
export const useThemeContext = () => useContext(ThemeContext);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// Читаем из localStorage или ставим 'system'
|
||||
const [mode, setMode] = useState<ColorMode>(() => {
|
||||
return (localStorage.getItem('themeMode') as ColorMode) || 'system';
|
||||
});
|
||||
@@ -36,7 +34,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
});
|
||||
};
|
||||
|
||||
// Вычисляем реальную тему (light/dark) на основе настроек и системы
|
||||
const theme = useMemo(() => {
|
||||
let activeMode: ColorMode;
|
||||
|
||||
@@ -46,7 +43,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
activeMode = mode;
|
||||
}
|
||||
|
||||
// ВАЖНО: Используем нашу функцию getDesignTokens
|
||||
const themeOptions = getDesignTokens(activeMode);
|
||||
|
||||
return createTheme(themeOptions);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:3000/api',
|
||||
baseURL: `${location.protocol}//${location.hostname}:3000/api`,
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -3,34 +3,54 @@ import api from '../api';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>(null!);
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
|
||||
if (savedToken) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`;
|
||||
}
|
||||
return savedToken;
|
||||
});
|
||||
|
||||
const login = (newToken: string) => {
|
||||
localStorage.setItem('token', newToken);
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
|
||||
setToken(newToken);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
delete api.defaults.headers.common['Authorization'];
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const login = (newToken: string) => setToken(newToken);
|
||||
const logout = () => setToken(null);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AppBar, Toolbar, Typography, IconButton, Tooltip, Box,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, List, ListItem, ListItemText
|
||||
@@ -16,10 +16,8 @@ export default function Header() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Состояние для модального окна справки
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
// Логика выхода
|
||||
const handleLogout = () => {
|
||||
if (confirm('Вы действительно хотите выйти?')) {
|
||||
logout();
|
||||
@@ -57,21 +55,18 @@ export default function Header() {
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
|
||||
{/* Кнопка Справки */}
|
||||
<Tooltip title="Справка о программе">
|
||||
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Кнопка Темы */}
|
||||
<Tooltip title={`Режим: ${getThemeLabel()}`}>
|
||||
<IconButton color="inherit" onClick={toggleColorMode}>
|
||||
{getThemeIcon()}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Кнопка Выхода */}
|
||||
<Tooltip title="Выйти из системы">
|
||||
<IconButton color="inherit" onClick={handleLogout}>
|
||||
<Logout />
|
||||
@@ -82,7 +77,6 @@ export default function Header() {
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Модальное окно справки */}
|
||||
<Dialog
|
||||
open={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Toolbar, Drawer, List, ListItem,
|
||||
ListItemButton, ListItemIcon, ListItemText, Box
|
||||
@@ -6,7 +5,7 @@ import {
|
||||
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
|
||||
import Header from './Header'; // <--- Новый компонент
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
const drawerWidth = 240;
|
||||
@@ -25,10 +24,8 @@ export default function Layout() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
|
||||
|
||||
{/* Шапка */}
|
||||
<Header />
|
||||
|
||||
{/* Боковое меню */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
@@ -55,7 +52,6 @@ export default function Layout() {
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
{/* Основной контейнер контента */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
@@ -68,12 +64,10 @@ export default function Layout() {
|
||||
>
|
||||
<Toolbar />
|
||||
|
||||
{/* Контент страницы */}
|
||||
<Box sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
{/* Футер */}
|
||||
<Footer />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
|
||||
import { Delete, Add, DeleteSweep, UploadFile, Remove } from '@mui/icons-material';
|
||||
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
|
||||
import api from '../api';
|
||||
|
||||
interface Domain { id: number; name: string; }
|
||||
@@ -9,22 +9,15 @@ export default function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [totalCount, setTotalCount] = useState(0); // Общее кол-во записей в БД
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Состояние пагинации
|
||||
const [page, setPage] = useState(0); // MUI использует индекс с 0
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10); // По умолчанию 10
|
||||
|
||||
useEffect(() => {
|
||||
loadDomains();
|
||||
}, [page, rowsPerPage]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
|
||||
const loadDomains = async () => {
|
||||
try {
|
||||
// Backend ждет page начиная с 1, а MUI дает с 0. Поэтому page + 1
|
||||
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
|
||||
|
||||
// Сервер теперь возвращает { data: [], total: 123 }
|
||||
setDomains(data.data);
|
||||
setTotalCount(data.total);
|
||||
} catch (e) {
|
||||
@@ -32,15 +25,17 @@ export default function DomainsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик смены страницы
|
||||
const handleChangePage = (event: unknown, newPage: number) => {
|
||||
useEffect(() => {
|
||||
loadDomains();
|
||||
}, [page, rowsPerPage]);
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// Обработчик смены кол-ва строк на странице
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0); // Сбрасываем на первую страницу
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
@@ -61,12 +56,11 @@ export default function DomainsPage() {
|
||||
try {
|
||||
await api.delete('/domains/all');
|
||||
loadDomains();
|
||||
} catch (e) { alert('Ошибка удаления'); }
|
||||
} catch (_e) { alert('Ошибка удаления'); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- ЛОГИКА ЗАГРУЗКИ ФАЙЛА ---
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -76,18 +70,15 @@ export default function DomainsPage() {
|
||||
const text = e.target?.result as string;
|
||||
if (!text) return;
|
||||
|
||||
// Разбиваем текст на строки по переносу
|
||||
const lines = text.split(/\r?\n/);
|
||||
|
||||
// Отправляем на сервер
|
||||
try {
|
||||
const { data } = await api.post('/domains/upload', { domains: lines });
|
||||
alert(`Успешно добавлено доменов: ${data.count}`);
|
||||
loadDomains();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
alert('Ошибка при загрузке списка');
|
||||
} finally {
|
||||
// Сбрасываем инпут, чтобы можно было загрузить тот же файл повторно
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
@@ -111,7 +102,6 @@ export default function DomainsPage() {
|
||||
>
|
||||
Из файла
|
||||
</Button>
|
||||
{/* Скрытый инпут */}
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button, Container } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
||||
@@ -1,38 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment } from '@mui/material';
|
||||
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip } from '@mui/material';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
const ROTATION_PRESETS = [
|
||||
{ label: 'Сутки', value: 1440 },
|
||||
{ label: '3 дня', value: 4320 },
|
||||
{ label: 'Неделя', value: 10080 },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
// Настройки 3x-ui и ротации
|
||||
const [settings, setSettings] = useState({
|
||||
xui_url: '',
|
||||
xui_login: '',
|
||||
xui_password: '',
|
||||
rotation_interval: '30', // Значение по умолчанию
|
||||
rotation_interval: '30',
|
||||
});
|
||||
|
||||
// Настройки админа (локальное состояние формы)
|
||||
const [adminProfile, setAdminProfile] = useState({
|
||||
login: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success'|'error', text: '' });
|
||||
const { logout } = useAuth(); // Чтобы разлогинить, если сменили свои данные
|
||||
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
|
||||
const [intervalError, setIntervalError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const val = parseInt(settings.rotation_interval, 10);
|
||||
if (isNaN(val) || val < 10) {
|
||||
setIntervalError('Минимальный интервал — 10 минут');
|
||||
} else {
|
||||
setIntervalError('');
|
||||
}
|
||||
}, [settings.rotation_interval]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/settings');
|
||||
// Заполняем основные настройки
|
||||
setSettings((prev) => ({ ...prev, ...data }));
|
||||
|
||||
// Логин админа тоже приходит в settings (если мы разрешили его чтение),
|
||||
// но пароль (хеш) показывать нельзя.
|
||||
|
||||
if (data.admin_login) {
|
||||
setAdminProfile((prev) => ({ ...prev, login: data.admin_login }));
|
||||
}
|
||||
@@ -41,14 +50,21 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handlers для настроек 3x-ui и ротации ---
|
||||
const handleSettingChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({ ...settings, [prop]: event.target.value });
|
||||
};
|
||||
|
||||
const handlePresetClick = (minutes: number) => {
|
||||
setSettings(prev => ({ ...prev, rotation_interval: minutes.toString() }));
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
if (intervalError) {
|
||||
setMsg({ open: true, text: 'Исправьте ошибки перед сохранением', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Отправляем всё, что в settings
|
||||
await api.post('/settings', settings);
|
||||
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
|
||||
} catch (e) {
|
||||
@@ -56,7 +72,6 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handlers для профиля админа ---
|
||||
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
|
||||
};
|
||||
@@ -65,27 +80,34 @@ export default function SettingsPage() {
|
||||
try {
|
||||
await api.post('/auth/update-profile', adminProfile);
|
||||
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
|
||||
setAdminProfile(prev => ({ ...prev, password: '' })); // Очищаем поле пароля
|
||||
|
||||
// Опционально: можно сделать логаут, чтобы заставить войти с новыми данными
|
||||
// logout();
|
||||
setAdminProfile(prev => ({ ...prev, password: '' }));
|
||||
} catch (e) {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceRotate = async () => {
|
||||
if (confirm('ВНИМАНИЕ: Это немедленно обновит конфиги в подписках.\n\nИнтервал автоматической ротации НЕ будет сброшен.\n\nПродолжить?')) {
|
||||
try {
|
||||
await api.post('/rotation/rotate-all');
|
||||
setMsg({ open: true, type: 'success', text: 'Ротация успешно выполнена!' });
|
||||
} catch (e) {
|
||||
setMsg({ open: true, type: 'error', text: 'Ошибка при запуске ротации' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
|
||||
{/* БЛОК 1: Подключение к 3x-ui */}
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Paper sx={{ p: 3, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
|
||||
<TextField
|
||||
fullWidth margin="normal" label="URL панели"
|
||||
value={settings.xui_url} onChange={handleSettingChange('xui_url')}
|
||||
@@ -99,22 +121,20 @@ export default function SettingsPage() {
|
||||
fullWidth margin="normal" label="Пароль 3x-ui" type="password"
|
||||
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
|
||||
/>
|
||||
|
||||
|
||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
|
||||
Сохранить подключение
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* БЛОК 2: Ротация и Админка */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
|
||||
{/* Настройки Ротации */}
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Интервал генерации"
|
||||
type="number"
|
||||
@@ -125,24 +145,43 @@ export default function SettingsPage() {
|
||||
}}
|
||||
helperText="Как часто менять инбаунды (минимум 10 мин)"
|
||||
/>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, mb: 2 }}>
|
||||
{ROTATION_PRESETS.map((preset) => (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
label={preset.label}
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
color={settings.rotation_interval === preset.value.toString() ? "primary" : "default"}
|
||||
variant={settings.rotation_interval === preset.value.toString() ? "filled" : "outlined"}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
|
||||
Применить интервал
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={handleForceRotate}
|
||||
sx={{ mt: 2, ml: 2 }}
|
||||
>
|
||||
Сгенерировать сейчас
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Настройки Администратора */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Логин администратора"
|
||||
value={adminProfile.login}
|
||||
value={adminProfile.login}
|
||||
onChange={handleAdminChange('login')}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Новый пароль" type="password"
|
||||
value={adminProfile.password}
|
||||
value={adminProfile.password}
|
||||
onChange={handleAdminChange('password')}
|
||||
helperText="Оставьте пустым, если не хотите менять"
|
||||
/>
|
||||
@@ -155,7 +194,7 @@ export default function SettingsPage() {
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({...msg, open: false})}>
|
||||
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({ ...msg, open: false })}>
|
||||
<Alert severity={msg.type}>{msg.text}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
|
||||
@@ -23,6 +23,35 @@ interface Tunnel {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
domain: string;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
const patchLink = function (link: string, newHost: string): string {
|
||||
if (link.startsWith('vmess://')) {
|
||||
try {
|
||||
const base64Part = link.substring(8);
|
||||
const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8');
|
||||
const config = JSON.parse(jsonStr);
|
||||
|
||||
config.add = newHost;
|
||||
|
||||
const newJsonStr = JSON.stringify(config);
|
||||
const newBase64 = Buffer.from(newJsonStr).toString('base64');
|
||||
return `vmess://${newBase64}`;
|
||||
} catch (e) {
|
||||
return link;
|
||||
}
|
||||
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
||||
return link.replace(/@.*?:/, `@${newHost}:`);
|
||||
} else if (link.startsWith('ss://')) {
|
||||
if (link.includes('@')) {
|
||||
return link.replace(/@.*?:/, `@${newHost}:`);
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
@@ -30,8 +59,8 @@ export default function SubscriptionsPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<string>('main');
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<string | number>('main');
|
||||
|
||||
const [linksOpen, setLinksOpen] = useState(false);
|
||||
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
|
||||
@@ -42,7 +71,7 @@ export default function SubscriptionsPage() {
|
||||
const { data } = await api.get('/subscriptions');
|
||||
setSubs(data);
|
||||
const tunnelsRes = await api.get('/tunnels');
|
||||
setTunnels(tunnelsRes.data);
|
||||
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -60,7 +89,13 @@ export default function SubscriptionsPage() {
|
||||
};
|
||||
|
||||
const showLinks = (sub: Subscription) => {
|
||||
const links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
|
||||
let links = [];
|
||||
if (selectedServer === 'main') {
|
||||
links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
|
||||
} else {
|
||||
const host = tunnels[+selectedServer - 1].domain.length > 0 ? tunnels[+selectedServer - 1].domain : tunnels[+selectedServer - 1].ip;
|
||||
links = sub.inbounds?.map(i => patchLink(i.link, host)).filter(Boolean) || [];
|
||||
}
|
||||
if (links.length === 0) {
|
||||
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
|
||||
} else {
|
||||
@@ -69,7 +104,7 @@ export default function SubscriptionsPage() {
|
||||
setLinksOpen(true);
|
||||
};
|
||||
|
||||
const handleServerChange = (event: SelectChangeEvent) => {
|
||||
const handleServerChange = (event: SelectChangeEvent<any>) => {
|
||||
setSelectedServer(event.target.value as string);
|
||||
};
|
||||
|
||||
@@ -77,34 +112,34 @@ export default function SubscriptionsPage() {
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4">Подписки</Typography>
|
||||
{tunnels.length > 0 && (
|
||||
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
|
||||
<Select
|
||||
labelId="server-select-label"
|
||||
value={selectedServer}
|
||||
onChange={handleServerChange}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
{selectedServer === 'main' ? <Dns fontSize="small"/> : <Router fontSize="small"/>}
|
||||
</InputAdornment>
|
||||
}
|
||||
>
|
||||
<MenuItem value="main">
|
||||
{tunnels.length > 0 && (
|
||||
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
|
||||
<Select
|
||||
labelId="server-select-label"
|
||||
value={selectedServer}
|
||||
onChange={handleServerChange}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
|
||||
</InputAdornment>
|
||||
}
|
||||
>
|
||||
<MenuItem value="main">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
{tunnels.map((t) => (
|
||||
<MenuItem key={t.id} value={t.id.toString()}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
{tunnels.map((t) => (
|
||||
<MenuItem key={t.id} value={t.id.toString()}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Box>
|
||||
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
|
||||
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
|
||||
@@ -125,20 +160,20 @@ export default function SubscriptionsPage() {
|
||||
<TableBody>
|
||||
{subs.map((sub) => (
|
||||
<TableRow key={sub.id}>
|
||||
<TableCell>{sub.name}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>{sub.name}</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
|
||||
<TableCell>{sub.inbounds?.length || 0}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `http://localhost:3000/bus/${sub.uuid}` : `http://localhost:3000/bus/${sub.uuid}/${selectedServer}`)}
|
||||
onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
|
||||
title="Копировать ссылку"
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => window.open(selectedServer === 'main' ? `http://localhost:3000/bus/${sub.uuid}` : `http://localhost:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
|
||||
onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
|
||||
title="Открыть подписку"
|
||||
>
|
||||
<OpenInNew />
|
||||
|
||||
Reference in New Issue
Block a user