diff --git a/README.md b/README.md
index 876927b..96b79ff 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@

- [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
+ [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
diff --git a/README_CN.md b/README_CN.md
index c30994e..8f708cb 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -2,7 +2,7 @@

- [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
+ [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
diff --git a/README_EN.md b/README_EN.md
index c51f854..7694c6a 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -2,7 +2,7 @@

- [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
+ [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
diff --git a/README_IR.md b/README_IR.md
index b11341b..0eb951a 100644
--- a/README_IR.md
+++ b/README_IR.md
@@ -2,7 +2,7 @@

- [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
+ [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
diff --git a/README_TK.md b/README_TK.md
index e4d2dce..0685712 100644
--- a/README_TK.md
+++ b/README_TK.md
@@ -2,7 +2,7 @@

- [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
+ [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER
diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx
index eb6e5d0..fe8ba97 100644
--- a/client/src/components/Header.tsx
+++ b/client/src/components/Header.tsx
@@ -132,7 +132,7 @@ export default function Header({ onMenuClick, isMobile }: HeaderProps) {
- Версия: 2.0.1
+ Версия: 2.0.2
Разработчик: DenPiligrim
diff --git a/client/src/pages/DomainsPage.tsx b/client/src/pages/DomainsPage.tsx
index 225ab40..30cd5bf 100644
--- a/client/src/pages/DomainsPage.tsx
+++ b/client/src/pages/DomainsPage.tsx
@@ -94,14 +94,24 @@ export default function DomainsPage() {
label="Доменное имя" size="small" fullWidth
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
/>
- }
- sx={{ width: '170px' }}
- onClick={() => fileInputRef.current?.click()}
- >
- Из файла
-
+ {isMobile ? (
+ <>
+ fileInputRef.current?.click()}>
+
+ >
+ ) : (
+ <>
+ }
+ sx={{ width: isMobile ? 'auto' : '170px' }}
+ onClick={() => fileInputRef.current?.click()}
+ >
+ {isMobile ? '' : 'Из файла'}
+
+ } onClick={handleAdd}>Добавить
+ >
+ )}
- } onClick={handleAdd}>Добавить
{domains.length > 0 && (
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
index 5e69424..a3109d0 100644
--- a/client/src/pages/LoginPage.tsx
+++ b/client/src/pages/LoginPage.tsx
@@ -35,7 +35,7 @@ export default function LoginPage() {
animation: 'fadeIn 1.5s ease-out',
boxShadow: '0 15px 25px rgba(0,0,0,0.5)'
}}>
- Вход в 3DP-MANAGER
+ Вход в 3DP-MANAGER
{error && {error}}
diff --git a/client/src/pages/SubscriptionsPage.tsx b/client/src/pages/SubscriptionsPage.tsx
index 8b34698..2235891 100644
--- a/client/src/pages/SubscriptionsPage.tsx
+++ b/client/src/pages/SubscriptionsPage.tsx
@@ -2,16 +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,
- useTheme
+ Menu,
+ ListItemIcon,
+ ListItemText
} from '@mui/material';
-import { Delete, Add, Link as LinkIcon, 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 {
@@ -29,15 +28,33 @@ interface Tunnel {
isInstalled: boolean;
}
+interface InboundConfigUI {
+ id: string;
+ type: string;
+ port: string;
+ sni: 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',
+];
+
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}`;
@@ -52,37 +69,184 @@ 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([]);
- const [open, setOpen] = useState(false);
- const [name, setName] = useState('');
const [tunnels, setTunnels] = useState([]);
- const theme = useTheme();
- const isMobile = useMediaQuery(theme.breakpoints.down('md'));
-
const [selectedServer, setSelectedServer] = useState('main');
+ const [menuAnchorEl, setMenuAnchorEl] = useState(null);
+ const [activeSub, setActiveSub] = useState(null);
+ const [domains, setDomains] = useState([]);
+ const openActionMenu = Boolean(menuAnchorEl);
+ // Состояния модального окна конструктора
+ const [open, setOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [name, setName] = useState('');
+ const [inbounds, setInbounds] = useState([]);
+ const [portErrors, setPortErrors] = useState>({});
+
+ // Состояния ссылок
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState([]);
+ 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, 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' },
+ { id: generateId(), type: 'vless-xhttp-reality', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'vless-grpc-reality', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'vless-ws', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'vmess-tcp', port: 'random', sni: 'random' },
+ { id: generateId(), type: 'shadowsocks-tcp', port: 'random', sni: 'random' },
+ ]);
+ setPortErrors({});
+ setOpen(true);
+ };
+
+ const handleOpenEdit = (sub: Subscription) => {
+ setEditingId(sub.id);
+ setName(sub.name);
+
+ // Пытаемся маппить существующие инбаунды, или даем дефолтные
+ if (sub.inbounds && sub.inbounds.length > 0) {
+ setInbounds(sub.inbounds.map(i => ({
+ id: generateId(),
+ type: i.type || 'vless-tcp-reality',
+ port: i.port ? i.port.toString() : 'random',
+ sni: i.sni || 'random'
+ })));
+ } else {
+ setInbounds([{ id: generateId(), type: 'vless-tcp-reality', port: 'random', sni: 'random' }]);
+ }
+ setPortErrors({});
+ setOpen(true);
+ };
+
+ // Проверка порта
+ const handlePortCheck = async (portValue: string, id: string) => {
+ if (portValue === 'random' || portValue.trim() === '') {
+ setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
+ return;
+ }
+ const portNum = parseInt(portValue);
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
+ setPortErrors(prev => ({ ...prev, [id]: 'Некорректный порт' }));
+ return;
+ }
+ try {
+ const { data } = await api.get(`/tunnels/check-port/${portNum}`);
+ if (!data.isFree) {
+ setPortErrors(prev => ({ ...prev, [id]: 'Порт занят' }));
+ } else {
+ setPortErrors(prev => { const n = { ...prev }; delete n[id]; return n; });
+ }
+ } catch (e) {
+ console.error('Ошибка проверки порта', e);
+ }
+ };
+
+ const handleInboundChange = (id: string, field: keyof InboundConfigUI, value: string) => {
+ setInbounds(prev => prev.map(inb => inb.id === id ? { ...inb, [field]: value } : inb));
+ if (field === 'port') {
+ 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' }]);
+ }
+ };
+
+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'
+ }
+ ]);
+ setPortErrors({});
+ }
+};
+
+ const handleSave = async () => {
+ if (Object.keys(portErrors).length > 0) {
+ alert('Пожалуйста, исправьте ошибки с портами');
+ return;
+ }
+ if (!name.trim()) {
+ alert('Введите имя подписки');
+ return;
+ }
+
+ const payload = {
+ name,
+ inboundsConfig: inbounds.map(i => ({
+ 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) => {
@@ -108,10 +272,6 @@ export default function SubscriptionsPage() {
setLinksOpen(true);
};
- const handleServerChange = (event: SelectChangeEvent) => {
- setSelectedServer(event.target.value as string);
- };
-
return (
@@ -119,9 +279,8 @@ export default function SubscriptionsPage() {
{tunnels.length > 0 && (
)}
- } onClick={() => setOpen(true)}>Создать
+ } onClick={handleOpenCreate}>Создать
-
-
+
@@ -167,25 +320,28 @@ export default function SubscriptionsPage() {
{sub.uuid}
{sub.inbounds?.length || 0}
- navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
- title="Копировать ссылку"
- >
-
-
- window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
- title="Открыть подписку"
- >
-
-
- showLinks(sub)} title="Показать конфиги">
-
-
- handleDelete(sub.id)} title="Удалить">
-
+ {!isMobile && (
+ <>
+ navigator.clipboard.writeText(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`)}
+ title="Копировать ссылку"
+ >
+
+
+ window.open(selectedServer === 'main' ? `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}` : `${location.protocol}//${location.hostname}:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')}
+ title="Открыть подписку"
+ >
+
+
+ >
+ )}
+
+ {/* Кнопка "Три точки" для вызова меню действий */}
+ handleActionMenuClick(e, sub)}>
+
@@ -195,26 +351,141 @@ export default function SubscriptionsPage() {
{subs.length === 0 && Нет подписок}
-