Merge branch 'main' into dp-fix

This commit is contained in:
Den Piligrim
2026-03-22 14:04:16 +03:00
33 changed files with 972 additions and 267 deletions
+9 -5
View File
@@ -1,7 +1,11 @@
import { Box, Container, Grid, IconButton, Link, Stack } from '@mui/material';
import { GitHub, YouTube, Telegram } from '@mui/icons-material';
export default function Footer() {
interface FooterProps {
isMobile?: boolean;
}
export default function Footer({ isMobile }: FooterProps) {
return (
<Box
component="footer"
@@ -19,12 +23,12 @@ export default function Footer() {
<Grid container spacing={4} justifyContent="space-between" alignItems="center">
<Grid size={{ xs: 12, sm: 4 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
<Stack direction="row" alignItems="center" spacing={1} justifyContent={isMobile ? 'center' : 'start'}>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: isMobile ? 0 : 14 }} />
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'center' } }}>
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: 'center' }}>
<Link
href="https://3dp-manager.com/docs/intro"
target="_blank"
@@ -38,7 +42,7 @@ export default function Footer() {
</Grid>
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'right' } }}>
<Stack direction="row" spacing={1} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}>
<Stack direction="row" spacing={1} justifyContent={{ xs: 'center', sm: 'flex-end' }}>
<IconButton
component="a"
+15 -4
View File
@@ -10,8 +10,14 @@ import {
import { useNavigate } from 'react-router-dom';
import { useThemeContext } from '../ThemeContext';
import { useAuth } from '../auth/AuthContext';
import { Menu as MenuIcon } from '@mui/icons-material';
export default function Header() {
interface HeaderProps {
onMenuClick?: () => void;
isMobile?: boolean;
}
export default function Header({ onMenuClick, isMobile }: HeaderProps) {
const { mode, toggleColorMode } = useThemeContext();
const { logout } = useAuth();
const navigate = useNavigate();
@@ -48,12 +54,17 @@ export default function Header() {
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
{isMobile && (
<IconButton color="inherit" edge="start" onClick={onMenuClick} sx={{ mr: 1 }}>
<MenuIcon />
</IconButton>
)}
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
<Typography variant={isMobile ? 'body1' : 'h6'} noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
3DP-MANAGER
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ display: 'flex', gap: isMobile ? 0.25 : 1 }}>
<Tooltip title="Справка о программе">
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
@@ -121,7 +132,7 @@ export default function Header() {
</List>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Версия: 2.0.1<br />
Версия: 2.0.2<br />
Разработчик: DenPiligrim
</Typography>
</DialogContent>
+41 -25
View File
@@ -1,9 +1,10 @@
import {
Toolbar, Drawer, List, ListItem,
ListItemButton, ListItemIcon, ListItemText, Box
ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
} from '@mui/material';
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useState } from 'react';
import Header from './Header';
import Footer from './Footer';
@@ -13,6 +14,13 @@ const drawerWidth = 240;
export default function Layout() {
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const menuItems = [
{ text: 'Подписки', icon: <People />, path: '/' },
@@ -21,35 +29,44 @@ export default function Layout() {
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
];
const drawerContent = (
<Box sx={{ overflow: 'auto' }}>
<Toolbar />
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => {
navigate(item.path);
if (isMobile) setMobileOpen(false);
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
);
return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
<Header />
{/* Передаем функцию открытия в Header */}
<Header onMenuClick={handleDrawerToggle} isMobile={isMobile} />
<Drawer
variant="permanent"
variant={isMobile ? "temporary" : "permanent"}
open={isMobile ? mobileOpen : true}
onClose={handleDrawerToggle}
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
{drawerContent}
</Drawer>
<Box
@@ -59,16 +76,15 @@ export default function Layout() {
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100%'
width: '100%',
overflowX: 'hidden'
}}
>
<Toolbar />
<Box sx={{ flexGrow: 1, p: 3 }}>
<Box sx={{ flexGrow: 1, p: { xs: 2, md: 3 } }}>
<Outlet />
</Box>
<Footer />
<Footer isMobile={isMobile} />
</Box>
</Box>
);
+35
View File
@@ -0,0 +1,35 @@
.animated-container {
background: linear-gradient(-45deg, #111827, #15122c, #24243e, #0B0F19);
background-size: 400% 400%;
animation: gradientBG 15s ease infinite;
}
@keyframes gradientBG {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
input:autofill {
background: transparent;
}
+29 -21
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination, useTheme, useMediaQuery } from '@mui/material';
import { Delete, Add, UploadFile, Remove } from '@mui/icons-material';
import api from '../api';
@@ -10,6 +10,8 @@ export default function DomainsPage() {
const [newDomain, setNewDomain] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [totalCount, setTotalCount] = useState(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
@@ -52,12 +54,10 @@ export default function DomainsPage() {
const handleDeleteAll = async () => {
if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) {
if (confirm('Это действие необратимо. Точно удалить?')) {
try {
await api.delete('/domains/all');
loadDomains();
} catch (_e) { alert('Ошибка удаления'); }
}
try {
await api.delete('/domains/all');
loadDomains();
} catch (_e) { alert('Ошибка удаления'); }
}
};
@@ -87,21 +87,31 @@ export default function DomainsPage() {
return (
<Box>
<Typography variant="h4" gutterBottom>Белый список доменов (SNI)</Typography>
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Белый список доменов (SNI)</Typography>
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
<TextField
label="Добавить домен" size="small" fullWidth
label="Доменное имя" size="small" fullWidth
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
/>
<Button
variant="outlined"
startIcon={<UploadFile />}
sx={{ width: '170px' }}
onClick={() => fileInputRef.current?.click()}
>
Из файла
</Button>
{isMobile ? (
<>
<IconButton edge="end" onClick={() => fileInputRef.current?.click()}><UploadFile /></IconButton>
<IconButton edge="end" onClick={handleAdd}><Add /></IconButton>
</>
) : (
<>
<Button
variant="outlined"
startIcon={<UploadFile />}
sx={{ width: isMobile ? 'auto' : '170px' }}
onClick={() => fileInputRef.current?.click()}
>
{isMobile ? '' : 'Из файла'}
</Button>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</>
)}
<input
type="file"
accept=".txt"
@@ -109,7 +119,6 @@ export default function DomainsPage() {
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</Paper>
{domains.length > 0 && (
@@ -124,10 +133,9 @@ export default function DomainsPage() {
Удалить все
</Button>
</Box>
)}
<Paper>
<Paper sx={{ mt: domains.length > 0 ? 0 : 3 }}>
<List>
{domains.map((d) => (
<ListItem key={d.id} secondaryAction={
@@ -136,7 +144,7 @@ export default function DomainsPage() {
<ListItemText primary={d.name} />
</ListItem>
))}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Список пуст</Typography>}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет доменов</Typography>}
</List>
<TablePagination
component="div"
+14 -5
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Box, Paper, TextField, Button, Typography, Alert } from '@mui/material';
import { Box, Paper, TextField, Button, Typography, Alert, Chip } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../auth/AuthContext';
@@ -22,12 +22,21 @@ export default function LoginPage() {
};
return (
<Box sx={{
<Box className="animated-container" sx={{
height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'background.default'
}}>
<Paper sx={{ p: 4, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" gutterBottom align="center">Вход в 3DP-MANAGER</Typography>
<Paper sx={{
p: 4,
width: '100%',
maxWidth: 400,
background: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(10px)',
animation: 'fadeIn 1.5s ease-out',
boxShadow: '0 15px 25px rgba(0,0,0,0.5)'
}}>
<Typography variant="h5" gutterBottom align="center"><span style={{ verticalAlign: 'middle' }}>Вход в 3DP-MANAGER</span> <Chip label="v2.0.2" size="small" sx={{ verticalAlign: 'middle' }} /></Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
@@ -40,7 +49,7 @@ export default function LoginPage() {
fullWidth margin="normal" label="Пароль" type="password"
value={creds.password} onChange={(e) => setCreds({ ...creds, password: e.target.value })}
/>
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3 }}>
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3, transition: 'transform 0.3s ease', '&:hover': { transform: 'scale(1.05)' } }}>
Войти
</Button>
</form>
+6 -4
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton } from '@mui/material';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment, Stack, Chip, Tooltip, IconButton, useTheme, useMediaQuery } from '@mui/material';
import api from '../api';
import { CheckCircle, PauseCircleFilled, PlayCircleFilled, Schedule, Update } from '@mui/icons-material';
@@ -27,6 +27,8 @@ export default function SettingsPage() {
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success' | 'error', text: '' });
const [intervalError, setIntervalError] = useState<string>('');
const [loadingRotate, setLoadingRotate] = useState<boolean>(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
useEffect(() => {
loadSettings();
@@ -190,7 +192,7 @@ export default function SettingsPage() {
return (
<Box>
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom>Настройки утилиты</Typography>
<Grid container spacing={3}>
@@ -276,7 +278,7 @@ export default function SettingsPage() {
<Button
variant="outlined"
color="info"
sx={{ mt: 2, ml: 2 }}
sx={{ mt: 2, ml: isMobile ? 1 : 2 }}
onClick={handleCheckConnection}
>
Проверить
@@ -322,7 +324,7 @@ export default function SettingsPage() {
loading={loadingRotate}
color="warning"
onClick={handleForceRotate}
sx={{ mt: 2, ml: 2 }}
sx={{ mt: 2, ml: isMobile ? 0 : 2 }}
>
Сгенерировать сейчас
</Button>
+343 -66
View File
@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions,
FormControl,
Select,
InputAdornment,
MenuItem,
type SelectChangeEvent
DialogContent, TextField, DialogActions, FormControl, Select,
InputAdornment, InputLabel, MenuItem,
useTheme,
useMediaQuery,
Menu,
ListItemIcon,
ListItemText
} from '@mui/material';
import { Delete, Add, Link as LinkIcon, Refresh, OpenInNew, ContentCopy, Dns, Router } from '@mui/icons-material';
import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove } from '@mui/icons-material';
import api from '../api';
interface Subscription {
@@ -17,6 +18,7 @@ interface Subscription {
name: string;
uuid: string;
inbounds: any[];
inboundsConfig?: any[];
}
interface Tunnel {
@@ -27,15 +29,35 @@ interface Tunnel {
isInstalled: boolean;
}
interface InboundConfigUI {
id: string;
type: string;
port: string;
sni: string;
link?: string;
}
interface Domain { id: number; name: string; }
const CONNECTION_OPTIONS = [
'vless-tcp-reality',
'vless-xhttp-reality',
'vless-grpc-reality',
'vless-ws',
'hysteria2-udp',
'vmess-tcp',
'shadowsocks-tcp',
'trojan-tcp-reality',
'custom',
];
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}`;
@@ -50,35 +72,168 @@ const patchLink = function (link: string, newHost: string): string {
}
return link;
}
return link;
}
};
const generateId = () => Math.random().toString(36).substring(7);
export default function SubscriptionsPage() {
const [subs, setSubs] = useState<Subscription[]>([]);
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [selectedServer, setSelectedServer] = useState<string | number>('main');
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeSub, setActiveSub] = useState<Subscription | null>(null);
const [domains, setDomains] = useState<Domain[]>([]);
const openActionMenu = Boolean(menuAnchorEl);
// Состояния модального окна конструктора
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState('');
const [inbounds, setInbounds] = useState<InboundConfigUI[]>([]);
const [portErrors, setPortErrors] = useState<Record<string, string>>({});
// Состояния ссылок
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
useEffect(() => { loadSubs(); }, []);
const loadSubs = async () => {
const { data } = await api.get('/subscriptions');
setSubs(data);
const tunnelsRes = await api.get('/tunnels');
setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled));
const allDomains = await api.get('/domains/all');
setDomains(allDomains.data);
};
const handleCreate = async () => {
await api.post('/subscriptions', { name });
setOpen(false);
const handleActionMenuClick = (event: React.MouseEvent<HTMLButtonElement>, sub: Subscription) => {
setMenuAnchorEl(event.currentTarget);
setActiveSub(sub);
};
const handleActionMenuClose = () => {
setMenuAnchorEl(null);
setActiveSub(null);
};
const handleOpenCreate = () => {
setEditingId(null);
setName('');
loadSubs();
setInbounds([
{ id: generateId(), type: 'hysteria2-udp', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-xhttp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-grpc-reality', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vless-ws', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'vmess-tcp', port: 'random', sni: 'random', link: '' },
{ id: generateId(), type: 'shadowsocks-tcp', port: 'random', sni: 'random', link: '' },
]);
setPortErrors({});
setOpen(true);
};
const handleOpenEdit = (sub: Subscription) => {
setEditingId(sub.id);
setName(sub.name);
if (sub.inboundsConfig && sub.inboundsConfig.length > 0) {
setInbounds(sub.inboundsConfig.map(i => ({
id: generateId(),
type: i.type || 'vless-tcp-reality',
port: i.port ? i.port.toString() : 'random',
sni: i.sni || 'random',
link: i.link || ''
})));
} else {
setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
}
setPortErrors({});
setOpen(true);
};
const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
setInbounds(prev => prev.map(inb => inb.id === id ? { ...inb, [field]: value } : inb));
if (field === 'port' || (field === 'type' && value === 'custom')) {
setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
}
};
const addInbound = () => {
if (inbounds.length < 20) {
setInbounds([...inbounds, { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random', link: '' }]);
}
};
const removeInbound = (id?: string) => {
if (id) {
if (inbounds.length > 1) {
setInbounds(inbounds.filter(inb => inb.id !== id));
setPortErrors(prev => {
const n = { ...prev };
delete n[id];
return n;
});
}
} else {
setInbounds([
{
id: crypto.randomUUID(),
type: 'vless-tcp-reality',
port: 'random',
sni: 'random',
link: ''
}
]);
setPortErrors({});
}
};
const handleSave = async () => {
if (Object.keys(portErrors).length > 0) {
alert('Пожалуйста, исправьте ошибки с портами');
return;
}
if (!name.trim()) {
alert('Введите имя подписки');
return;
}
const payload = {
name,
inboundsConfig: inbounds.map(i => {
if (i.type === 'custom') {
return { type: i.type, link: i.link };
}
return {
type: i.type,
port: i.port === 'random' ? 'random' : parseInt(i.port),
sni: i.sni
};
})
};
try {
if (editingId) {
await api.put(`/subscriptions/${editingId}`, payload);
} else {
await api.post('/subscriptions', payload);
}
setOpen(false);
loadSubs();
} catch (error: any) {
alert(error.response?.data?.message || 'Произошла ошибка при сохранении');
}
};
const handleDelete = async (id: string) => {
@@ -104,20 +259,15 @@ export default function SubscriptionsPage() {
setLinksOpen(true);
};
const handleServerChange = (event: SelectChangeEvent<any>) => {
setSelectedServer(event.target.value as string);
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Подписки</Typography>
<Typography variant={isMobile ? 'h5' : 'h4'}>Подписки</Typography>
{tunnels.length > 0 && (
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
<Select
labelId="server-select-label"
value={selectedServer}
onChange={handleServerChange}
onChange={(e) => setSelectedServer(e.target.value)}
startAdornment={
<InputAdornment position="start">
{selectedServer === 'main' ? <Dns fontSize="small" /> : <Router fontSize="small" />}
@@ -125,29 +275,22 @@ export default function SubscriptionsPage() {
}
>
<MenuItem value="main">
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
</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>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
</MenuItem>
))}
</Select>
</FormControl>
)}
<Box>
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
<Button variant="contained" startIcon={<Add />} onClick={handleOpenCreate}>Создать</Button>
</Box>
</Box>
<Paper>
<Paper sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
<TableRow>
@@ -164,53 +307,187 @@ export default function SubscriptionsPage() {
<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' ? `${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' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
title="Открыть подписку"
>
<OpenInNew />
</IconButton>
<IconButton color="primary" onClick={() => showLinks(sub)} title="Показать конфиги">
<LinkIcon />
</IconButton>
<IconButton color="primary" onClick={() => handleDelete(sub.id)} title="Удалить">
<Delete />
{!isMobile && (
<>
<IconButton
color="primary"
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' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
title="Открыть подписку"
>
<OpenInNew />
</IconButton>
</>
)}
{/* Кнопка "Три точки" для вызова меню действий */}
<IconButton onClick={(e) => handleActionMenuClick(e, sub)}>
<MoreVert />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{subs.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Нет подписок</Typography>}
</Paper>
<Dialog open={open} onClose={() => setOpen(false)} disableRestoreFocus>
<DialogTitle>Новая подписка</DialogTitle>
<DialogContent>
<Menu
anchorEl={menuAnchorEl}
open={openActionMenu}
onClose={handleActionMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{isMobile && activeSub && (
<MenuItem onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`)}>
<ListItemIcon><ContentCopy fontSize="small" color="primary" /></ListItemIcon>
<ListItemText>Копировать ссылку</ListItemText>
</MenuItem>
)}
{isMobile && activeSub && (
<MenuItem onClick={() => window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${activeSub.uuid}/${selectedServer}`, '_blank')}>
<ListItemIcon><OpenInNew fontSize="small" color="primary" /></ListItemIcon>
<ListItemText>Открыть подписку</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => showLinks(activeSub)}>
<ListItemIcon><LinkIcon fontSize="small" /></ListItemIcon>
<ListItemText>Показать конфиги</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => handleOpenEdit(activeSub)}>
<ListItemIcon><Edit fontSize="small" /></ListItemIcon>
<ListItemText>Редактировать</ListItemText>
</MenuItem>
)}
{activeSub && (
<MenuItem onClick={() => handleDelete(activeSub.id)}>
<ListItemIcon><Delete fontSize="small" color="error" /></ListItemIcon>
<ListItemText sx={{ color: 'error.main' }}>Удалить</ListItemText>
</MenuItem>
)}
</Menu>
{/* Модальное окно создания / редактирования */}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth disableRestoreFocus>
<DialogTitle variant='h5'>{editingId ? 'Редактировать подписку' : 'Новая подписка'}</DialogTitle>
<DialogContent dividers>
<TextField
autoFocus margin="dense" label="Имя пользователя" fullWidth
autoFocus margin="dense" label="Имя подписки" fullWidth
value={name} onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreate();
}
}}
sx={{ mb: 4 }}
/>
<Typography variant="h6" sx={{ mb: 2 }}>
Инбаунды ({inbounds.length}/20)
</Typography>
{inbounds.map((inb, index) => (
<Box key={inb.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2, p: 2 }}>
<Typography sx={{ mt: 1, minWidth: 30, fontWeight: 'bold' }}>#{index + 1}</Typography>
<FormControl size="small" sx={{ minWidth: 185 }}>
<InputLabel>Тип</InputLabel>
<Select
value={inb.type}
label="Тип"
sx={{ minWidth: '185px' }}
onChange={(e) => handleInboundChange(inb.id, 'type', e.target.value)}
>
{CONNECTION_OPTIONS.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
</Select>
</FormControl>
{inb.type === 'custom' ? (
// Поле для кастомной ссылки
<FormControl size="small" sx={{ flexGrow: 1 }}>
<TextField
size="small"
label="Ссылка на подключение"
placeholder="vless://..."
value={inb.link || ''}
onChange={(e) => handleInboundChange(inb.id, 'link', e.target.value)}
fullWidth
/>
</FormControl>
) : (
<>
<FormControl size="small" sx={{ minWidth: 150 }}>
<TextField
size="small"
label="Порт"
placeholder="random или порт"
value={inb.port}
onChange={(e) => handleInboundChange(inb.id, 'port', e.target.value)}
error={!!portErrors[inb.id]}
helperText={portErrors[inb.id] || ""}
sx={{ width: 140 }}
/>
</FormControl>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>SNI</InputLabel>
<Select
value={inb.sni}
label="SNI"
onChange={(e) => handleInboundChange(inb.id, 'sni', e.target.value)}
>
<MenuItem value="random">random</MenuItem>
{domains.map(opt => <MenuItem key={opt.id} value={opt.name}>{opt.name}</MenuItem>)}
</Select>
</FormControl>
</>
)}
<IconButton
color="primary"
onClick={() => removeInbound(inb.id)}
disabled={inbounds.length <= 1}
sx={{ mt: 0.5 }}
>
<Delete />
</IconButton>
</Box>
))}
<Button
variant="outlined"
size='small'
startIcon={<Add />}
onClick={addInbound}
disabled={inbounds.length >= 20}
>
Добавить инбаунд
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<Remove />}
sx={{ ml: 0.5 }}
onClick={() => removeInbound()}
>
Удалить все
</Button>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button onClick={handleCreate}>Создать</Button>
<Button onClick={handleSave} variant="contained" color="primary">Сохранить</Button>
</DialogActions>
</Dialog>
{/* Модальное окно ссылок */}
<Dialog open={linksOpen} onClose={() => setLinksOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Активные ссылки</DialogTitle>
<DialogContent>
+71 -22
View File
@@ -2,7 +2,13 @@ import React, { useEffect, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions, Chip, CircularProgress
DialogContent, TextField, DialogActions, Chip, CircularProgress,
useTheme,
useMediaQuery,
FormControl,
RadioGroup,
FormControlLabel,
Radio
} from '@mui/material';
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
import api from '../api';
@@ -20,9 +26,12 @@ export default function TunnelsPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [open, setOpen] = useState(false);
const [loadingId, setLoadingId] = useState<number | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
const [form, setForm] = useState({
name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: ''
name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: ''
});
useEffect(() => { loadTunnels(); }, []);
@@ -35,9 +44,16 @@ export default function TunnelsPage() {
};
const handleCreate = async () => {
await api.post('/tunnels', form);
const payload = {
...form,
password: authMethod === 'password' ? form.password : null,
privateKey: authMethod === 'key' ? form.privateKey : null,
};
await api.post('/tunnels', payload);
setOpen(false);
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: '' });
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', privateKey: '', domain: '' });
setAuthMethod('password');
loadTunnels();
};
@@ -70,11 +86,11 @@ export default function TunnelsPage() {
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Relay серверы</Typography>
<Typography variant={isMobile ? 'h5' : 'h4'}>Relay серверы</Typography>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
</Box>
<Paper>
<Paper sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
<TableRow>
@@ -95,23 +111,35 @@ export default function TunnelsPage() {
</Box>
</TableCell>
<TableCell>
{t.isInstalled ?
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" /> :
<Chip icon={<Error />} label="Не настроен" color="warning" size="small" variant="outlined" />
}
{!isMobile && (t.isInstalled ?
<Chip icon={<CheckCircle />} label={"Активен"} color="success" size="small" variant="outlined" /> :
<Chip icon={<Error />} label={"Не настроен"} color="warning" size="small" variant="outlined" />
)}
{isMobile && (t.isInstalled ?
<CheckCircle color='success' /> :
<Error color='warning' />
)}
</TableCell>
<TableCell align="right">
{!t.isInstalled && (
<Button
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null}
onClick={() => handleInstall(t.id)}
sx={{ mr: 1 }}
variant="outlined"
size="small"
>
{loadingId === t.id ? 'Установка...' : 'Установить'}
</Button>
<>
{isMobile ? (
<IconButton disabled={loadingId !== null} color="primary" onClick={() => handleInstall(t.id)}>
{loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
</IconButton>
) : (
<Button
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null}
onClick={() => handleInstall(t.id)}
sx={{ mr: 1 }}
variant="outlined"
size="small"
>
{isMobile ? '' : (loadingId === t.id ? 'Установка...' : 'Установить')}
</Button>
)}
</>
)}
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
<Delete />
@@ -119,7 +147,7 @@ export default function TunnelsPage() {
</TableCell>
</TableRow>
))}
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center">Список пуст</TableCell></TableRow>}
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>Нет серверов</TableCell></TableRow>}
</TableBody>
</Table>
</Paper>
@@ -133,7 +161,28 @@ export default function TunnelsPage() {
<TextField margin="dense" label="SSH Порт" type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} />
<TextField margin="dense" label="SSH User" fullWidth value={form.username} onChange={handleChange('username')} />
</Box>
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
<FormControl component="fieldset" sx={{ mt: 2, mb: 1 }}>
<RadioGroup row value={authMethod} onChange={(e) => setAuthMethod(e.target.value as 'password' | 'key')}>
<FormControlLabel value="password" control={<Radio />} label="По паролю" />
<FormControlLabel value="key" control={<Radio />} label="По SSH ключу" />
</RadioGroup>
</FormControl>
{authMethod === 'password' ? (
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
) : (
<TextField
margin="dense"
label="SSH Private Key (RSA / Ed25519)"
multiline
rows={4}
fullWidth
value={form.privateKey}
onChange={handleChange('privateKey')}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
slotProps={{ input: { style: { fontFamily: 'monospace', fontSize: '0.875rem' } } }}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
+2 -2
View File
@@ -2,7 +2,7 @@ import type { PaletteMode } from '@mui/material';
const lightPalette = {
primary: {
main: '#2563eb',
main: '#1395de',
light: '#60a5fa',
dark: '#1e40af',
},
@@ -21,7 +21,7 @@ const lightPalette = {
const darkPalette = {
primary: {
main: '#3b82f6',
main: '#1395de',
light: '#60a5fa',
dark: '#1d4ed8',
},