import { useEffect, useState, useCallback } from 'react'; import { Box, Button, Typography, Paper, Table, TableBody, TableCell, TableHead, TableRow, IconButton, Dialog, DialogTitle, DialogContent, TextField, DialogActions, FormControl, Select, InputAdornment, InputLabel, MenuItem, Snackbar, Alert, useTheme, useMediaQuery, Menu, ListItemIcon, ListItemText, Checkbox } from '@mui/material'; import { Delete, Add, Link as LinkIcon, OpenInNew, ContentCopy, Dns, Router, Edit, MoreVert, Remove, Refresh } from '@mui/icons-material'; import api from '../api'; import { copyToClipboard } from '../utils/copyToClipboard'; import { Logger } from '../utils/logger'; interface Subscription { id: string; name: string; uuid: string; inbounds: unknown[]; inboundsConfig?: unknown[]; isAutoRotationEnabled?: boolean; } interface Tunnel { id: number; name: string; ip: string; domain: string; 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}`; } catch { 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; }; const generateId = () => Math.random().toString(36).substring(7); const getSubscriptionUrl = (uuid: string, tunnelId: string | number) => { // Используем относительный путь - Nginx проксирует /bus/ на бэкенд const tunnelPart = tunnelId !== 'main' ? `/${tunnelId}` : ''; const path = `/bus/${uuid}${tunnelPart}`; // Для копирования нужен полный URL с origin if (typeof window !== 'undefined') { return `${window.location.origin}${path}`; } return path; }; export default function SubscriptionsPage() { const [subs, setSubs] = useState([]); const [tunnels, setTunnels] = useState([]); 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([]); // Snackbar state for notifications const [snackbar, setSnackbar] = useState({ open: false, type: 'success' as 'success' | 'error', message: '' }); // Confirmation dialog state const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', onConfirm: () => {}, confirmText: 'Удалить', confirmColor: 'error' as 'error' | 'primary' }); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const loadSubs = useCallback(async () => { try { Logger.debug('Loading subscriptions...', 'Subs'); const { data } = await api.get('/subscriptions'); setSubs(data); Logger.debug(`Loaded ${data.length} subscriptions`, 'Subs'); const tunnelsRes = await api.get('/tunnels'); setTunnels(tunnelsRes.data.filter((el: Tunnel) => el.isInstalled)); Logger.debug(`Loaded ${tunnelsRes.data.filter((el: Tunnel) => el.isInstalled).length} active tunnels`, 'Subs'); const allDomains = await api.get('/domains/all'); setDomains(allDomains.data); Logger.debug(`Loaded ${allDomains.data.length} domains`, 'Subs'); } catch (error) { Logger.error('Failed to load', 'Subs', error); throw error; } }, []); useEffect(() => { loadSubs(); }, [loadSubs]); const handleActionMenuClick = (event: React.MouseEvent, sub: Subscription) => { setMenuAnchorEl(event.currentTarget); setActiveSub(sub); }; const handleActionMenuClose = () => { setMenuAnchorEl(null); setActiveSub(null); }; const handleOpenCreate = () => { setEditingId(null); setName(''); 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) { setSnackbar({ open: true, type: 'error', message: 'Пожалуйста, исправьте ошибки с портами' }); return; } if (!name.trim()) { setSnackbar({ open: true, type: 'error', message: 'Введите имя подписки' }); 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 { Logger.debug(`${editingId ? 'Updating' : 'Creating'} subscription`, 'Subs', payload); if (editingId) { await api.put(`/subscriptions/${editingId}`, payload); Logger.debug(`Updated subscription ${editingId}`, 'Subs'); } else { await api.post('/subscriptions', payload); Logger.debug('Created subscription', 'Subs'); } setOpen(false); loadSubs(); setSnackbar({ open: true, type: 'success', message: editingId ? 'Подписка обновлена' : 'Подписка создана' }); } catch (error: unknown) { const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Произошла ошибка при сохранении'; Logger.error(`Save error: ${message}`, 'Subs'); setSnackbar({ open: true, type: 'error', message }); } }; const handleDelete = async (id: string) => { setConfirmDialog({ open: true, title: 'Удалить подписку и все соединения?', confirmText: 'Удалить', confirmColor: 'error', onConfirm: async () => { Logger.debug(`Deleting subscription: ${id}`, 'Subs'); await api.delete(`/subscriptions/${id}`); Logger.debug(`Deleted subscription ${id}`, 'Subs'); loadSubs(); setSnackbar({ open: true, type: 'success', message: 'Подписка удалена' }); } }); }; const handleToggleAutoRotation = async (subscriptionId: string, enabled: boolean) => { try { await api.put('/subscriptions/bulk-auto-rotation', { subscriptionIds: [subscriptionId], enabled }); setSubs(prev => prev.map(s => s.id === subscriptionId ? { ...s, isAutoRotationEnabled: enabled } : s )); setSnackbar({ open: true, type: 'success', message: enabled ? 'Авторотация включена' : 'Авторотация выключена' }); } catch (error: unknown) { const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка обновления'; Logger.error(`Toggle auto-rotation error: ${message}`, 'Subs'); setSnackbar({ open: true, type: 'error', message }); loadSubs(); } }; const handleManualRotate = async (sub: Subscription) => { setConfirmDialog({ open: true, title: `Обновить подписку "${sub.name}" сейчас?`, confirmText: 'Обновить', confirmColor: 'primary', onConfirm: async () => { try { Logger.debug(`Starting manual rotation for subscription: ${sub.id}`, 'Subs'); const res = await api.post(`/rotation/rotate-one/${sub.id}`); Logger.debug('Manual rotation completed', 'Subs'); setSnackbar({ open: true, type: 'success', message: res.data.message || 'Ротация выполнена' }); loadSubs(); } catch (error: unknown) { const message = (error as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Ошибка ротации'; Logger.error(`Manual rotation error: ${message}`, 'Subs'); setSnackbar({ open: true, type: 'error', message }); } } }); }; const showLinks = (sub: Subscription) => { let links: string[] = []; if (selectedServer === 'main') { links = sub.inbounds?.map(i => (i as { link?: string }).link).filter(Boolean) || []; } else { const tunnelIndex = +selectedServer - 1; const host = tunnels[tunnelIndex]?.domain?.length > 0 ? tunnels[tunnelIndex].domain : tunnels[tunnelIndex].ip; links = sub.inbounds?.map(i => patchLink((i as { link?: string }).link || '', host)).filter(Boolean) || []; } if (links.length === 0) { setCurrentLinks(['Нет активных ссылок (ждите ротации)']); } else { setCurrentLinks(links); } setLinksOpen(true); }; const handleCopyLink = async (uuid: string, tunnelId: string | number) => { await copyToClipboard(getSubscriptionUrl(uuid, tunnelId)); setSnackbar({ open: true, type: 'success', message: 'Ссылка на подписку скопирована' }); }; return ( Подписки {tunnels.length > 0 && ( )} Имя UUID Инбаунды Авторотация Действия {subs.map((sub) => ( {sub.name} {sub.uuid} {sub.inbounds?.length || 0} handleToggleAutoRotation(sub.id, e.target.checked)} color="primary" /> {!isMobile && ( <> handleCopyLink(sub.uuid, selectedServer)} title="Копировать ссылку" > window.open(getSubscriptionUrl(sub.uuid, selectedServer), '_blank')} title="Открыть подписку" > )} {/* Кнопка "Три точки" для вызова меню действий */} handleActionMenuClick(e, sub)}> ))}
{subs.length === 0 && Нет подписок}
{isMobile && activeSub && ( handleCopyLink(activeSub.uuid, selectedServer)}> Копировать ссылку )} {isMobile && activeSub && ( window.open(getSubscriptionUrl(activeSub.uuid, selectedServer), '_blank')}> Открыть подписку )} {activeSub && ( showLinks(activeSub)}> Показать конфиги )} {activeSub && ( handleManualRotate(activeSub)}> Обновить сейчас )} {activeSub && ( handleOpenEdit(activeSub)}> Редактировать )} {activeSub && ( handleDelete(activeSub.id)}> Удалить )} {/* Модальное окно создания / редактирования */} setOpen(false)} maxWidth="md" fullWidth disableRestoreFocus> {editingId ? 'Редактировать подписку' : 'Новая подписка'} setName(e.target.value)} sx={{ mb: 4 }} /> Инбаунды ({inbounds.length}/20) {inbounds.map((inb, index) => ( #{index + 1} Тип {inb.type === 'custom' ? ( // Поле для кастомной ссылки handleInboundChange(inb.id, 'link', e.target.value)} fullWidth /> ) : ( <> handleInboundChange(inb.id, 'port', e.target.value)} error={!!portErrors[inb.id]} helperText={portErrors[inb.id] || ""} sx={{ width: 140 }} /> SNI )} removeInbound(inb.id)} disabled={inbounds.length <= 1} sx={{ mt: 0.5 }} > ))} {/* Модальное окно ссылок */} setLinksOpen(false)} maxWidth="md" fullWidth> Активные ссылки {/* Confirmation Dialog */} setConfirmDialog({ ...confirmDialog, open: false })}> Подтверждение {confirmDialog.title} {/* Snackbar notifications */} setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackbar({ ...snackbar, open: false })} severity={snackbar.type} sx={{ width: '100%' }} > {snackbar.message}
); }