feat: Добавлена веб-панель управления VPN-прокси и Docker-конфигурация.
This commit is contained in:
429
web/index.html
429
web/index.html
@@ -340,6 +340,163 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Server List */
|
||||
.server-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.server-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.server-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.server-item.selected {
|
||||
border-color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
|
||||
.server-radio {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.server-item.selected .server-radio {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.server-item.selected .server-radio::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.server-details {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.server-type {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
@@ -354,6 +511,15 @@
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -375,21 +541,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="proxyForm">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="subscription">📡 Подписка</button>
|
||||
<button class="tab" data-tab="vless">🔑 VLESS Ключ</button>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Tab Content -->
|
||||
<div id="subscription-tab" class="tab-content active">
|
||||
<div class="form-group">
|
||||
<label for="urlInput">VLESS Key</label>
|
||||
<label for="subUrlInput">URL подписки</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
|
||||
<input type="text" id="subUrlInput" placeholder="https://..." autocomplete="off"
|
||||
spellcheck="false">
|
||||
</div>
|
||||
<p class="hint">Вставьте VLESS ссылку</p>
|
||||
<p class="hint">Вставьте ссылку подписки (sing-box формат)</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<span class="btn-icon">⚡</span>
|
||||
<span id="btnText">Применить</span>
|
||||
<button type="button" class="btn btn-secondary" id="fetchServersBtn" style="margin-bottom: 1rem;">
|
||||
<span class="btn-icon">🔄</span>
|
||||
<span id="fetchBtnText">Загрузить серверы</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="serverListContainer" style="display: none;">
|
||||
<label style="margin-bottom: 0.75rem; display: block;">Выберите сервер</label>
|
||||
<div class="server-list" id="serverList"></div>
|
||||
|
||||
<button type="button" class="btn btn-primary" id="applySubBtn">
|
||||
<span class="btn-icon">⚡</span>
|
||||
<span id="applySubBtnText">Применить</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="emptyServers" class="empty-state" style="display: none;">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<p>Серверы не загружены</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VLESS Tab Content -->
|
||||
<div id="vless-tab" class="tab-content">
|
||||
<form id="proxyForm">
|
||||
<div class="form-group">
|
||||
<label for="urlInput">VLESS Key</label>
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
|
||||
spellcheck="false">
|
||||
</div>
|
||||
<p class="hint">Вставьте VLESS ссылку</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<span class="btn-icon">⚡</span>
|
||||
<span id="btnText">Применить</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="message" id="message">
|
||||
<span class="message-icon" id="messageIcon"></span>
|
||||
@@ -416,6 +623,7 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// DOM Elements
|
||||
const form = document.getElementById('proxyForm');
|
||||
const urlInput = document.getElementById('urlInput');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
@@ -426,6 +634,36 @@
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const statusValue = document.getElementById('statusValue');
|
||||
|
||||
// Subscription elements
|
||||
const subUrlInput = document.getElementById('subUrlInput');
|
||||
const fetchServersBtn = document.getElementById('fetchServersBtn');
|
||||
const fetchBtnText = document.getElementById('fetchBtnText');
|
||||
const serverListContainer = document.getElementById('serverListContainer');
|
||||
const serverList = document.getElementById('serverList');
|
||||
const emptyServers = document.getElementById('emptyServers');
|
||||
const applySubBtn = document.getElementById('applySubBtn');
|
||||
const applySubBtnText = document.getElementById('applySubBtnText');
|
||||
|
||||
// State
|
||||
let subscriptionConfig = null;
|
||||
let selectedServer = null;
|
||||
let subscriptionUrl = null;
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
tab.classList.add('active');
|
||||
const targetTab = tab.dataset.tab;
|
||||
document.getElementById(`${targetTab}-tab`).classList.add('active');
|
||||
|
||||
// Hide message when switching tabs
|
||||
message.className = 'message';
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch initial status
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
@@ -452,15 +690,16 @@
|
||||
messageText.textContent = text;
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
submitBtn.disabled = loading;
|
||||
function setLoading(btn, textEl, loading, defaultText = 'Применить') {
|
||||
btn.disabled = loading;
|
||||
if (loading) {
|
||||
btnText.innerHTML = '<div class="spinner"></div>';
|
||||
textEl.innerHTML = '<div class="spinner"></div>';
|
||||
} else {
|
||||
btnText.textContent = 'Применить';
|
||||
textEl.textContent = defaultText;
|
||||
}
|
||||
}
|
||||
|
||||
// VLESS form submit
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -471,7 +710,7 @@
|
||||
}
|
||||
|
||||
message.className = 'message';
|
||||
setLoading(true);
|
||||
setLoading(submitBtn, btnText, true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/apply', {
|
||||
@@ -492,12 +731,174 @@
|
||||
} catch (e) {
|
||||
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(submitBtn, btnText, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch servers from subscription
|
||||
fetchServersBtn.addEventListener('click', async () => {
|
||||
const url = subUrlInput.value.trim();
|
||||
if (!url) {
|
||||
showMessage('error', 'Введите URL подписки');
|
||||
return;
|
||||
}
|
||||
|
||||
message.className = 'message';
|
||||
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
|
||||
|
||||
try {
|
||||
const res = await fetch('/fetch-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.servers && data.servers.length > 0) {
|
||||
subscriptionConfig = data.config;
|
||||
subscriptionUrl = url; // Save URL for persistence
|
||||
renderServerList(data.servers);
|
||||
serverListContainer.style.display = 'block';
|
||||
emptyServers.style.display = 'none';
|
||||
showMessage('success', `Найдено ${data.servers.length} сервер(ов)`);
|
||||
} else {
|
||||
serverListContainer.style.display = 'none';
|
||||
emptyServers.style.display = 'block';
|
||||
showMessage('error', data.error || 'Серверы не найдены');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||
} finally {
|
||||
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
|
||||
}
|
||||
});
|
||||
|
||||
// Render server list
|
||||
function renderServerList(servers, savedServerTag = null) {
|
||||
serverList.innerHTML = '';
|
||||
selectedServer = null;
|
||||
|
||||
servers.forEach((server, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'server-item';
|
||||
item.dataset.tag = server.tag;
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="server-radio"></div>
|
||||
<div class="server-info">
|
||||
<div class="server-name">${escapeHtml(server.tag)}</div>
|
||||
<div class="server-details">${escapeHtml(server.server)}:${server.port}</div>
|
||||
</div>
|
||||
<span class="server-type">${server.type}</span>
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
document.querySelectorAll('.server-item').forEach(i => i.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
selectedServer = server.tag;
|
||||
});
|
||||
|
||||
// Select saved server or first by default
|
||||
const shouldSelect = savedServerTag
|
||||
? server.tag === savedServerTag
|
||||
: index === 0;
|
||||
|
||||
if (shouldSelect) {
|
||||
item.classList.add('selected');
|
||||
selectedServer = server.tag;
|
||||
}
|
||||
|
||||
serverList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Apply subscription with selected server
|
||||
applySubBtn.addEventListener('click', async () => {
|
||||
if (!subscriptionConfig) {
|
||||
showMessage('error', 'Сначала загрузите серверы');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
showMessage('error', 'Выберите сервер');
|
||||
return;
|
||||
}
|
||||
|
||||
message.className = 'message';
|
||||
setLoading(applySubBtn, applySubBtnText, true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/apply-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: subscriptionConfig,
|
||||
selectedServer: selectedServer,
|
||||
subUrl: subscriptionUrl
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage('success', data.message || 'Конфигурация применена!');
|
||||
fetchStatus();
|
||||
} else {
|
||||
showMessage('error', data.error || 'Произошла ошибка');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||
} finally {
|
||||
setLoading(applySubBtn, applySubBtnText, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved subscription
|
||||
async function loadSavedSubscription() {
|
||||
try {
|
||||
const res = await fetch('/subscription');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.saved && data.url) {
|
||||
subUrlInput.value = data.url;
|
||||
subscriptionUrl = data.url;
|
||||
|
||||
// Auto-load servers from saved subscription
|
||||
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
|
||||
try {
|
||||
const subRes = await fetch('/fetch-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: data.url })
|
||||
});
|
||||
const subData = await subRes.json();
|
||||
|
||||
if (subData.success && subData.servers && subData.servers.length > 0) {
|
||||
subscriptionConfig = subData.config;
|
||||
renderServerList(subData.servers, data.selectedServer);
|
||||
serverListContainer.style.display = 'block';
|
||||
emptyServers.style.display = 'none';
|
||||
}
|
||||
} finally {
|
||||
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('No saved subscription found');
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
fetchStatus();
|
||||
loadSavedSubscription();
|
||||
// Refresh status every 30 seconds
|
||||
setInterval(fetchStatus, 30000);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user