func optimization

This commit is contained in:
Den Piligrim
2026-01-31 18:44:09 +03:00
parent 6db895bcc7
commit 27310c0bc0
39 changed files with 1989 additions and 539 deletions
-1
View File
@@ -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';
-4
View File
@@ -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
View File
@@ -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;
+32 -12
View File
@@ -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 -7
View File
@@ -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 -7
View File
@@ -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>
+12 -22
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { Box, Typography, Button, Container } from '@mui/material';
import { useNavigate } from 'react-router-dom';
+70 -31
View File
@@ -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>
+68 -33
View File
@@ -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 />