first commit
This commit is contained in:
957
public/proxy.js
Normal file
957
public/proxy.js
Normal file
@@ -0,0 +1,957 @@
|
||||
'use strict';
|
||||
|
||||
const PS = {
|
||||
status: null,
|
||||
config: null,
|
||||
};
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: body ? (body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) : {},
|
||||
};
|
||||
if (body && !(body instanceof FormData)) {
|
||||
opts.body = JSON.stringify(body);
|
||||
} else if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
throw new Error(json.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
const get = (path) => api('GET', path);
|
||||
const post = (path, body) => api('POST', path, body);
|
||||
const put = (path, body) => api('PUT', path, body);
|
||||
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast ${type}`;
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
function typeLabel(t) {
|
||||
const m = { ss: 'Shadowsocks', vmess: 'VMess', vless: 'VLESS', trojan: 'Trojan', hysteria2: 'Hysteria2', http: 'HTTP', socks5: 'SOCKS5', direct: 'DIRECT' };
|
||||
return m[t] || t;
|
||||
}
|
||||
|
||||
function groupTypeLabel(t) {
|
||||
const m = { select: 'Выбор', 'url-test': 'Автотест', fallback: 'Резерв', 'load-balance': 'Балансировка' };
|
||||
return m[t] || t;
|
||||
}
|
||||
|
||||
function getProxies() { return PS.config && Array.isArray(PS.config.proxies) ? PS.config.proxies : []; }
|
||||
function getGroups() { return PS.config && Array.isArray(PS.config['proxy-groups']) ? PS.config['proxy-groups'] : []; }
|
||||
function getRules() { return PS.config && Array.isArray(PS.config.rules) ? PS.config.rules : []; }
|
||||
function getGeneral() { return PS.config && PS.config.general ? PS.config.general : {}; }
|
||||
function getTProxy() { return PS.config && PS.config.tproxy ? PS.config.tproxy : {}; }
|
||||
function getDNS() { return PS.config && PS.config.dns ? PS.config.dns : {}; }
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
PS.status = await get('/api/mihomo/status');
|
||||
renderStatus();
|
||||
} catch (e) {
|
||||
console.error('load status', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
PS.config = await get('/api/mihomo/config');
|
||||
renderAll();
|
||||
} catch (e) {
|
||||
console.error('load config', e);
|
||||
PS.config = {};
|
||||
renderAll();
|
||||
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
|
||||
}
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function saveFullConfig(restart) {
|
||||
try {
|
||||
await put('/api/mihomo/config', PS.config);
|
||||
showToast('Конфиг сохранён', 'success');
|
||||
refreshYAMLPreview();
|
||||
if (restart) {
|
||||
try {
|
||||
await post('/api/mihomo/restart', null);
|
||||
showToast('Mihomo перезапущен', 'success');
|
||||
} catch (e) {
|
||||
showToast('Перезапуск не удался: ' + e.message, 'error');
|
||||
}
|
||||
await loadStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка сохранения: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshYAMLPreview() {
|
||||
try {
|
||||
const res = await fetch('/api/mihomo/config.yaml');
|
||||
if (res.ok) {
|
||||
document.getElementById('yamlPreview').value = await res.text();
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const s = PS.status;
|
||||
if (!s) return;
|
||||
const text = document.getElementById('statusText');
|
||||
const headerBadge = document.getElementById('statusBadge');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
const coreInfo = document.getElementById('coreInfo');
|
||||
|
||||
if (s.running) {
|
||||
text.className = 'svc-badge running';
|
||||
text.textContent = 'Запущен (PID ' + (s.pid || '?') + ')';
|
||||
headerBadge.className = 'svc-badge running';
|
||||
headerBadge.textContent = 'Запущен';
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
restartBtn.disabled = false;
|
||||
} else {
|
||||
text.className = 'svc-badge stopped';
|
||||
text.textContent = 'Остановлен';
|
||||
headerBadge.className = 'svc-badge stopped';
|
||||
headerBadge.textContent = 'Остановлен';
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
}
|
||||
|
||||
document.getElementById('corePath').textContent = s.core_path || '—';
|
||||
document.getElementById('coreExists').textContent = s.core_exists ? 'Да' : 'Нет';
|
||||
document.getElementById('corePid').textContent = s.running && s.pid ? s.pid : '—';
|
||||
|
||||
if (!s.core_exists) {
|
||||
coreInfo.classList.remove('hidden');
|
||||
document.getElementById('coreInfoMsg').textContent = 'Ядро Mihomo не найдено. Загрузите бинарный файл в раздел «Ядро».';
|
||||
} else {
|
||||
coreInfo.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('statusBar').classList.remove('hidden');
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderProxies();
|
||||
renderGroups();
|
||||
renderRules();
|
||||
fillSettings();
|
||||
refreshYAMLPreview();
|
||||
}
|
||||
|
||||
function renderProxies() {
|
||||
const list = document.getElementById('proxyList');
|
||||
const empty = document.getElementById('proxyEmpty');
|
||||
const proxies = getProxies();
|
||||
list.innerHTML = '';
|
||||
if (proxies.length === 0) {
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
proxies.forEach(p => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'proxy-card';
|
||||
card.innerHTML = `
|
||||
<div class="proxy-card-header">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span class="proxy-name">${esc(p.name)}</span>
|
||||
<span class="tag-active">${esc(typeLabel(p.type))}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-ghost btn-sm" data-edit-proxy="${esc(p.name)}">Изменить</button>
|
||||
<button class="btn btn-danger btn-sm" data-delete-proxy="${esc(p.name)}">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-card-info">
|
||||
${p.type !== 'direct' ? `<div class="info-row"><span class="info-label">Сервер</span><span class="info-val mono">${esc(p.server || '—')}</span></div>
|
||||
<div class="info-row"><span class="info-label">Порт</span><span class="info-val mono">${p.port || '—'}</span></div>` : ''}
|
||||
${p.udp ? '<span class="tag-active" style="margin-left:0">UDP</span>' : ''}
|
||||
${p.tls ? '<span class="tag-active" style="margin-left:4px">TLS</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const list = document.getElementById('groupList');
|
||||
const empty = document.getElementById('groupEmpty');
|
||||
const groups = getGroups();
|
||||
list.innerHTML = '';
|
||||
if (groups.length === 0) {
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
groups.forEach(g => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'proxy-card';
|
||||
const proxyList = (g.proxies || []).slice(0, 5).map(p => esc(p)).join(', ');
|
||||
const more = (g.proxies || []).length > 5 ? ` +${(g.proxies || []).length - 5}` : '';
|
||||
card.innerHTML = `
|
||||
<div class="proxy-card-header">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span class="proxy-name">${esc(g.name)}</span>
|
||||
<span class="tag-gw">${esc(groupTypeLabel(g.type))}</span>
|
||||
${g['include-all'] ? '<span class="tag-active">Все прокси</span>' : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-ghost btn-sm" data-edit-group="${esc(g.name)}">Изменить</button>
|
||||
<button class="btn btn-danger btn-sm" data-delete-group="${esc(g.name)}">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-card-info">
|
||||
<div class="info-row"><span class="info-label">Узлы</span><span class="info-val">${proxyList}${more || '—'}</span></div>
|
||||
${g.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(g.url)}</span></div>` : ''}
|
||||
${g.interval ? `<div class="info-row"><span class="info-label">Интервал</span><span class="info-val">${g.interval}с</span></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRules() {
|
||||
const list = document.getElementById('rulesList');
|
||||
const rules = getRules();
|
||||
list.innerHTML = '';
|
||||
if (rules.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">Нет правил маршрутизации</div>';
|
||||
return;
|
||||
}
|
||||
rules.forEach((rule, i) => {
|
||||
const parts = rule.split(',');
|
||||
const type = parts[0] || '';
|
||||
const value = parts[1] || '';
|
||||
const target = parts[2] || '';
|
||||
const flags = parts.slice(3).join(',');
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'rule-item';
|
||||
el.innerHTML = `
|
||||
<div class="rule-info">
|
||||
<span class="rule-type">${esc(type)}</span>
|
||||
<span class="rule-value">${esc(value || (type === 'MATCH' ? '*' : ''))}</span>
|
||||
<span class="rule-target">${esc(target)}</span>
|
||||
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-danger btn-sm" data-delete-rule="${i}">✕</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function fillSettings() {
|
||||
if (!PS.config) {
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const g = getGeneral();
|
||||
const tp = getTProxy();
|
||||
const dns = getDNS();
|
||||
|
||||
document.getElementById('mixedPort').value = g['mixed-port'] || 7890;
|
||||
document.getElementById('allowLan').checked = g['allow-lan'] !== false;
|
||||
document.getElementById('ipv6').checked = g.ipv6 !== false;
|
||||
document.getElementById('tcpConcurrent').checked = g['tcp-concurrent'] !== false;
|
||||
document.getElementById('externalController').value = g['external-controller'] || '0.0.0.0:9090';
|
||||
document.getElementById('secret').value = g.secret || '';
|
||||
|
||||
setSegBtn('modeSwitch', g.mode || 'rule');
|
||||
|
||||
document.getElementById('tproxyEnabled').checked = tp.enabled || false;
|
||||
document.getElementById('tproxyPort').value = tp.port || 7894;
|
||||
|
||||
document.getElementById('dnsEnabled').checked = dns.enable !== false;
|
||||
document.getElementById('dnsListen').value = dns.listen || '0.0.0.0:1053';
|
||||
setSegBtn('dnsModeSwitch', dns['enhanced-mode'] || 'redir-host');
|
||||
document.getElementById('dnsNameserver').value = (dns.nameserver || []).join('\n');
|
||||
document.getElementById('dnsFallback').value = (dns.fallback || []).join('\n');
|
||||
}
|
||||
|
||||
function setSegBtn(id, mode) {
|
||||
document.querySelectorAll(`#${id} .seg-btn`).forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
}
|
||||
|
||||
function getSegBtn(id) {
|
||||
const active = document.querySelector(`#${id} .seg-btn.active`);
|
||||
return active ? active.dataset.mode : '';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ─── Tab switching ───
|
||||
document.querySelectorAll('.ptab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.ptab').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.ptab-content').forEach(c => c.classList.add('hidden'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Core control ───
|
||||
document.getElementById('startBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await post('/api/mihomo/start', null);
|
||||
showToast('Mihomo запущен', 'success');
|
||||
await loadStatus();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('stopBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await post('/api/mihomo/stop', null);
|
||||
showToast('Mihomo остановлен', 'success');
|
||||
await loadStatus();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('restartBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await post('/api/mihomo/restart', null);
|
||||
showToast('Mihomo перезапущен', 'success');
|
||||
await loadStatus();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Config mode switches ───
|
||||
document.getElementById('modeSwitch').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (btn) setSegBtn('modeSwitch', btn.dataset.mode);
|
||||
});
|
||||
document.getElementById('dnsModeSwitch').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (btn) setSegBtn('dnsModeSwitch', btn.dataset.mode);
|
||||
});
|
||||
|
||||
// ─── Settings save ───
|
||||
function applySettingsToConfig() {
|
||||
if (!PS.config) PS.config = {};
|
||||
PS.config['mixed-port'] = parseInt(document.getElementById('mixedPort').value) || 7890;
|
||||
PS.config['allow-lan'] = document.getElementById('allowLan').checked;
|
||||
PS.config['bind-address'] = '*';
|
||||
PS.config.mode = getSegBtn('modeSwitch');
|
||||
PS.config['log-level'] = 'info';
|
||||
PS.config.ipv6 = document.getElementById('ipv6').checked;
|
||||
PS.config['external-controller'] = document.getElementById('externalController').value;
|
||||
PS.config.secret = document.getElementById('secret').value || '';
|
||||
PS.config['tcp-concurrent'] = document.getElementById('tcpConcurrent').checked;
|
||||
PS.config['find-process-mode'] = 'off';
|
||||
|
||||
const tpEnabled = document.getElementById('tproxyEnabled').checked;
|
||||
if (tpEnabled) {
|
||||
PS.config['tproxy-port'] = parseInt(document.getElementById('tproxyPort').value) || 7894;
|
||||
} else {
|
||||
delete PS.config['tproxy-port'];
|
||||
}
|
||||
|
||||
PS.config.dns = {
|
||||
enable: document.getElementById('dnsEnabled').checked,
|
||||
ipv6: document.getElementById('ipv6').checked,
|
||||
listen: document.getElementById('dnsListen').value,
|
||||
'enhanced-mode': getSegBtn('dnsModeSwitch'),
|
||||
'fake-ip-range': '198.18.0.1/16',
|
||||
'fake-ip-filter': ['*.lan', '*.local', '+.market.xiaomi.com'],
|
||||
'default-nameserver': ['223.5.5.5', '119.29.29.29'],
|
||||
nameserver: document.getElementById('dnsNameserver').value.split('\n').map(s => s.trim()).filter(Boolean),
|
||||
fallback: document.getElementById('dnsFallback').value.split('\n').map(s => s.trim()).filter(Boolean),
|
||||
};
|
||||
|
||||
PS.config.profile = { 'store-selected': true, 'store-fake-ip': true };
|
||||
}
|
||||
|
||||
document.getElementById('settingsForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
applySettingsToConfig();
|
||||
saveFullConfig(false);
|
||||
});
|
||||
document.getElementById('saveAndRestartBtn').addEventListener('click', () => {
|
||||
applySettingsToConfig();
|
||||
saveFullConfig(true);
|
||||
});
|
||||
|
||||
// ─── Proxy Modal ───
|
||||
function updateProxyFields() {
|
||||
const type = document.getElementById('proxyType').value;
|
||||
const serverFields = document.getElementById('proxyServerFields');
|
||||
const authFields = document.getElementById('proxyAuthFields');
|
||||
const cipherField = document.getElementById('proxyCipherField');
|
||||
const uuidField = document.getElementById('proxyUUIDField');
|
||||
const trojanPassField = document.getElementById('proxyTrojanPassField');
|
||||
const hysteria2Fields = document.getElementById('proxyHysteria2Fields');
|
||||
const tlsField = document.getElementById('proxyTLSField');
|
||||
const vlessFlowField = document.getElementById('proxyVlessFlowField');
|
||||
const networkField = document.getElementById('proxyNetworkField');
|
||||
|
||||
serverFields.classList.remove('hidden');
|
||||
authFields.classList.add('hidden');
|
||||
cipherField.classList.add('hidden');
|
||||
uuidField.classList.add('hidden');
|
||||
trojanPassField.classList.add('hidden');
|
||||
hysteria2Fields.classList.add('hidden');
|
||||
tlsField.classList.add('hidden');
|
||||
vlessFlowField.classList.add('hidden');
|
||||
networkField.classList.add('hidden');
|
||||
|
||||
switch (type) {
|
||||
case 'ss':
|
||||
cipherField.classList.remove('hidden');
|
||||
tlsField.classList.remove('hidden');
|
||||
document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden');
|
||||
break;
|
||||
case 'vmess':
|
||||
uuidField.classList.remove('hidden');
|
||||
cipherField.classList.remove('hidden');
|
||||
tlsField.classList.remove('hidden');
|
||||
networkField.classList.remove('hidden');
|
||||
document.getElementById('proxyCipher').innerHTML = '<option value="auto">auto</option><option value="none">none</option><option value="zero">zero</option><option value="aes-128-gcm">aes-128-gcm</option><option value="chacha20-poly1305">chacha20-poly1305</option>';
|
||||
break;
|
||||
case 'vless':
|
||||
uuidField.classList.remove('hidden');
|
||||
tlsField.classList.remove('hidden');
|
||||
vlessFlowField.classList.remove('hidden');
|
||||
networkField.classList.remove('hidden');
|
||||
break;
|
||||
case 'trojan':
|
||||
trojanPassField.classList.remove('hidden');
|
||||
tlsField.classList.remove('hidden');
|
||||
networkField.classList.remove('hidden');
|
||||
break;
|
||||
case 'hysteria2':
|
||||
hysteria2Fields.classList.remove('hidden');
|
||||
tlsField.classList.add('hidden');
|
||||
document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden');
|
||||
serverFields.classList.remove('hidden');
|
||||
break;
|
||||
case 'http':
|
||||
authFields.classList.remove('hidden');
|
||||
tlsField.classList.remove('hidden');
|
||||
break;
|
||||
case 'socks5':
|
||||
authFields.classList.remove('hidden');
|
||||
tlsField.classList.remove('hidden');
|
||||
break;
|
||||
case 'direct':
|
||||
serverFields.classList.add('hidden');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('proxyType').addEventListener('change', updateProxyFields);
|
||||
|
||||
let editProxyName = null;
|
||||
|
||||
function openProxyModal(proxy) {
|
||||
editProxyName = proxy ? proxy.name : null;
|
||||
document.getElementById('proxyModalTitle').textContent = proxy ? 'Редактировать прокси' : 'Добавить прокси';
|
||||
document.getElementById('proxyEditName').value = proxy ? proxy.name : '';
|
||||
document.getElementById('proxyName').value = proxy ? proxy.name : '';
|
||||
document.getElementById('proxyType').value = proxy ? proxy.type : 'ss';
|
||||
document.getElementById('proxyServer').value = proxy ? (proxy.server || '') : '';
|
||||
document.getElementById('proxyPort').value = proxy ? (proxy.port || '') : '';
|
||||
document.getElementById('proxyUDP').checked = proxy ? (proxy.udp !== false) : true;
|
||||
document.getElementById('proxyUsername').value = proxy ? (proxy.username || '') : '';
|
||||
document.getElementById('proxyPassword').value = proxy ? (proxy.password || '') : '';
|
||||
document.getElementById('proxyTLS').checked = proxy ? (proxy.tls || false) : false;
|
||||
document.getElementById('proxySNI').value = proxy ? (proxy.servername || '') : '';
|
||||
document.getElementById('proxySkipCertVerify').checked = proxy ? (proxy['skip-cert-verify'] || false) : false;
|
||||
document.getElementById('proxyUUID').value = proxy ? (proxy.uuid || '') : '';
|
||||
document.getElementById('proxyCipher').value = proxy ? (proxy.cipher || 'auto') : 'auto';
|
||||
document.getElementById('proxyFlow').value = proxy ? (proxy.flow || '') : '';
|
||||
document.getElementById('proxyNetwork').value = proxy ? (proxy.network || '') : '';
|
||||
document.getElementById('proxyTrojanPass').value = proxy ? (proxy.password || '') : '';
|
||||
document.getElementById('proxyObfs').value = proxy ? (proxy.obfs || '') : '';
|
||||
document.getElementById('proxyObfsPass').value = proxy ? (proxy['obfs-password'] || '') : '';
|
||||
|
||||
updateProxyFields();
|
||||
document.getElementById('proxyModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeProxyModal() {
|
||||
document.getElementById('proxyModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('addProxyBtn').addEventListener('click', () => openProxyModal(null));
|
||||
document.getElementById('closeProxyModal').addEventListener('click', closeProxyModal);
|
||||
document.getElementById('cancelProxyBtn').addEventListener('click', closeProxyModal);
|
||||
document.getElementById('proxyModalBackdrop').addEventListener('click', closeProxyModal);
|
||||
|
||||
document.getElementById('saveProxyBtn').addEventListener('click', () => {
|
||||
const type = document.getElementById('proxyType').value;
|
||||
const proxy = {
|
||||
name: document.getElementById('proxyName').value.trim(),
|
||||
type: type,
|
||||
server: document.getElementById('proxyServer').value.trim(),
|
||||
port: parseInt(document.getElementById('proxyPort').value) || 443,
|
||||
udp: document.getElementById('proxyUDP').checked,
|
||||
tls: document.getElementById('proxyTLS').checked,
|
||||
servername: document.getElementById('proxySNI').value.trim(),
|
||||
'skip-cert-verify': document.getElementById('proxySkipCertVerify').checked,
|
||||
network: document.getElementById('proxyNetwork').value || '',
|
||||
};
|
||||
|
||||
if (type === 'ss') {
|
||||
proxy.cipher = document.getElementById('proxyCipher').value;
|
||||
proxy.password = document.getElementById('proxyPassword').value;
|
||||
} else if (type === 'vmess' || type === 'vless') {
|
||||
proxy.uuid = document.getElementById('proxyUUID').value.trim();
|
||||
if (type === 'vless') proxy.flow = document.getElementById('proxyFlow').value.trim();
|
||||
proxy.cipher = document.getElementById('proxyCipher').value;
|
||||
} else if (type === 'trojan') {
|
||||
proxy.password = document.getElementById('proxyTrojanPass').value;
|
||||
} else if (type === 'hysteria2') {
|
||||
proxy.password = document.getElementById('proxyPassword').value;
|
||||
proxy.obfs = document.getElementById('proxyObfs').value.trim();
|
||||
proxy['obfs-password'] = document.getElementById('proxyObfsPass').value.trim();
|
||||
} else if (type === 'http' || type === 'socks5') {
|
||||
proxy.username = document.getElementById('proxyUsername').value.trim();
|
||||
proxy.password = document.getElementById('proxyPassword').value;
|
||||
} else if (type === 'direct') {
|
||||
delete proxy.server;
|
||||
delete proxy.port;
|
||||
}
|
||||
|
||||
if (!proxy.name) {
|
||||
showToast('Имя прокси обязательно', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const proxies = getProxies();
|
||||
if (editProxyName) {
|
||||
const idx = proxies.findIndex(p => p.name === editProxyName);
|
||||
if (idx >= 0) {
|
||||
const oldName = proxies[idx].name;
|
||||
proxies[idx] = proxy;
|
||||
if (proxy.name !== oldName) {
|
||||
getGroups().forEach(g => {
|
||||
if (g.proxies) {
|
||||
g.proxies = g.proxies.map(pn => pn === oldName ? proxy.name : pn);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (proxies.some(p => p.name === proxy.name)) {
|
||||
showToast('Прокси с таким именем уже существует', 'error');
|
||||
return;
|
||||
}
|
||||
proxies.push(proxy);
|
||||
}
|
||||
PS.config.proxies = proxies;
|
||||
|
||||
closeProxyModal();
|
||||
renderAll();
|
||||
saveFullConfig(false);
|
||||
});
|
||||
|
||||
// Proxy list delegated events
|
||||
document.getElementById('proxyList').addEventListener('click', e => {
|
||||
const editBtn = e.target.closest('[data-edit-proxy]');
|
||||
const delBtn = e.target.closest('[data-delete-proxy]');
|
||||
if (editBtn) {
|
||||
const name = editBtn.dataset.editProxy;
|
||||
const proxy = getProxies().find(p => p.name === name);
|
||||
if (proxy) openProxyModal(proxy);
|
||||
} else if (delBtn) {
|
||||
const name = delBtn.dataset.deleteProxy;
|
||||
if (confirm(`Удалить прокси "${name}"?`)) {
|
||||
const proxies = getProxies();
|
||||
const idx = proxies.findIndex(p => p.name === name);
|
||||
if (idx >= 0) {
|
||||
proxies.splice(idx, 1);
|
||||
PS.config.proxies = proxies;
|
||||
getGroups().forEach(g => {
|
||||
if (g.proxies) {
|
||||
g.proxies = g.proxies.filter(pn => pn !== name);
|
||||
}
|
||||
});
|
||||
renderAll();
|
||||
saveFullConfig(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Group Modal ───
|
||||
function updateGroupFields() {
|
||||
const type = document.getElementById('groupType').value;
|
||||
const urlField = document.getElementById('groupURLField');
|
||||
const lbField = document.getElementById('groupLBStrategy');
|
||||
if (type === 'url-test' || type === 'fallback' || type === 'load-balance') {
|
||||
urlField.classList.remove('hidden');
|
||||
} else {
|
||||
urlField.classList.add('hidden');
|
||||
}
|
||||
if (type === 'load-balance') {
|
||||
lbField.classList.remove('hidden');
|
||||
} else {
|
||||
lbField.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('groupType').addEventListener('change', updateGroupFields);
|
||||
|
||||
let editGroupName = null;
|
||||
|
||||
function openGroupModal(group) {
|
||||
editGroupName = group ? group.name : null;
|
||||
document.getElementById('groupEditName').value = group ? group.name : '';
|
||||
document.getElementById('groupModalTitle').textContent = group ? 'Редактировать группу' : 'Добавить группу';
|
||||
document.getElementById('groupName').value = group ? group.name : '';
|
||||
document.getElementById('groupType').value = group ? group.type : 'select';
|
||||
document.getElementById('groupURL').value = group ? (group.url || 'https://www.gstatic.com/generate_204') : 'https://www.gstatic.com/generate_204';
|
||||
document.getElementById('groupInterval').value = group ? (group.interval || 300) : 300;
|
||||
document.getElementById('groupTolerance').value = group ? (group.tolerance || 50) : 50;
|
||||
document.getElementById('groupIncludeAll').checked = group ? (group['include-all'] || false) : false;
|
||||
document.getElementById('groupFilter').value = group ? (group.filter || '') : '';
|
||||
document.getElementById('groupLBStrategy').value = group ? (group.strategy || 'round-robin') : 'round-robin';
|
||||
|
||||
const checkboxes = document.getElementById('groupProxyCheckboxes');
|
||||
checkboxes.innerHTML = '';
|
||||
const builtin = ['DIRECT'];
|
||||
const allProxies = [...builtin, ...getProxies().map(p => p.name)];
|
||||
const selected = group ? (group.proxies || []) : [];
|
||||
allProxies.forEach(name => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'checkbox-label';
|
||||
label.style.cssText = 'font-size:.85rem;padding:4px 0;display:flex;align-items:center;gap:6px';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = name;
|
||||
if (selected.includes(name)) cb.checked = true;
|
||||
label.appendChild(cb);
|
||||
label.appendChild(document.createTextNode(name));
|
||||
checkboxes.appendChild(label);
|
||||
});
|
||||
|
||||
updateGroupFields();
|
||||
document.getElementById('groupModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeGroupModal() {
|
||||
document.getElementById('groupModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('addGroupBtn').addEventListener('click', () => openGroupModal(null));
|
||||
document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal);
|
||||
document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal);
|
||||
document.getElementById('groupModalBackdrop').addEventListener('click', closeGroupModal);
|
||||
|
||||
document.getElementById('saveGroupBtn').addEventListener('click', () => {
|
||||
const selectedProxies = [];
|
||||
document.querySelectorAll('#groupProxyCheckboxes input[type="checkbox"]:checked').forEach(cb => {
|
||||
selectedProxies.push(cb.value);
|
||||
});
|
||||
|
||||
const group = {
|
||||
name: document.getElementById('groupName').value.trim(),
|
||||
type: document.getElementById('groupType').value,
|
||||
proxies: selectedProxies,
|
||||
url: document.getElementById('groupURLField').classList.contains('hidden') ? '' : document.getElementById('groupURL').value.trim(),
|
||||
interval: parseInt(document.getElementById('groupInterval').value) || 300,
|
||||
tolerance: parseInt(document.getElementById('groupTolerance').value) || 0,
|
||||
'include-all': document.getElementById('groupIncludeAll').checked,
|
||||
filter: document.getElementById('groupFilter').value.trim(),
|
||||
};
|
||||
|
||||
if (group.type === 'load-balance') {
|
||||
group.strategy = document.getElementById('groupLBStrategy').value;
|
||||
}
|
||||
|
||||
if (!group.name) {
|
||||
showToast('Имя группы обязательно', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = getGroups();
|
||||
if (editGroupName) {
|
||||
const idx = groups.findIndex(g => g.name === editGroupName);
|
||||
if (idx >= 0) {
|
||||
groups[idx] = group;
|
||||
}
|
||||
} else {
|
||||
if (groups.some(g => g.name === group.name)) {
|
||||
showToast('Группа с таким именем уже существует', 'error');
|
||||
return;
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
PS.config['proxy-groups'] = groups;
|
||||
|
||||
closeGroupModal();
|
||||
renderAll();
|
||||
saveFullConfig(false);
|
||||
});
|
||||
|
||||
// Group list events
|
||||
document.getElementById('groupList').addEventListener('click', e => {
|
||||
const editBtn = e.target.closest('[data-edit-group]');
|
||||
const delBtn = e.target.closest('[data-delete-group]');
|
||||
if (editBtn) {
|
||||
const name = editBtn.dataset.editGroup;
|
||||
const group = getGroups().find(g => g.name === name);
|
||||
if (group) openGroupModal(group);
|
||||
} else if (delBtn) {
|
||||
const name = delBtn.dataset.deleteGroup;
|
||||
if (confirm(`Удалить группу "${name}"?`)) {
|
||||
const groups = getGroups();
|
||||
const idx = groups.findIndex(g => g.name === name);
|
||||
if (idx >= 0) {
|
||||
groups.splice(idx, 1);
|
||||
PS.config['proxy-groups'] = groups;
|
||||
const rules = getRules();
|
||||
PS.config.rules = rules.map(r => {
|
||||
if (r.endsWith(',' + name)) {
|
||||
return r.replace(',' + name, ',DIRECT');
|
||||
}
|
||||
return r;
|
||||
});
|
||||
renderAll();
|
||||
saveFullConfig(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Rule Modal ───
|
||||
function updateRuleFields() {
|
||||
const type = document.getElementById('ruleType').value;
|
||||
const valueDiv = document.getElementById('ruleValueField');
|
||||
const noResolveDiv = document.getElementById('ruleNoResolveDiv');
|
||||
valueDiv.classList.remove('hidden');
|
||||
noResolveDiv.classList.add('hidden');
|
||||
|
||||
if (type === 'MATCH') {
|
||||
valueDiv.classList.add('hidden');
|
||||
} else if (type === 'IP-CIDR' || type === 'IP-CIDR6' || type === 'SRC-IP-CIDR' || type === 'GEOIP') {
|
||||
noResolveDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const sel = document.getElementById('ruleProxy');
|
||||
sel.innerHTML = '<option value="DIRECT">DIRECT</option><option value="REJECT">REJECT</option>';
|
||||
getGroups().forEach(g => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.name;
|
||||
opt.textContent = '📋 ' + g.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
getProxies().forEach(p => {
|
||||
if (p.type !== 'direct') {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.name;
|
||||
opt.textContent = '🔗 ' + p.name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('ruleType').addEventListener('change', updateRuleFields);
|
||||
|
||||
function openRuleModal() {
|
||||
document.getElementById('ruleModalTitle').textContent = 'Добавить правило';
|
||||
document.getElementById('ruleType').value = 'DOMAIN-SUFFIX';
|
||||
document.getElementById('ruleValue').value = '';
|
||||
document.getElementById('ruleProxy').value = 'DIRECT';
|
||||
document.getElementById('ruleNoResolve').checked = true;
|
||||
updateRuleFields();
|
||||
document.getElementById('ruleModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRuleModal() {
|
||||
document.getElementById('ruleModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('addRuleBtn').addEventListener('click', openRuleModal);
|
||||
document.getElementById('addBlockBtn').addEventListener('click', () => {
|
||||
openRuleModal();
|
||||
document.getElementById('ruleType').value = 'DOMAIN-KEYWORD';
|
||||
document.getElementById('ruleProxy').value = 'REJECT';
|
||||
updateRuleFields();
|
||||
});
|
||||
document.getElementById('closeRuleModal').addEventListener('click', closeRuleModal);
|
||||
document.getElementById('cancelRuleBtn').addEventListener('click', closeRuleModal);
|
||||
document.getElementById('ruleModalBackdrop').addEventListener('click', closeRuleModal);
|
||||
|
||||
document.getElementById('saveRuleBtn').addEventListener('click', () => {
|
||||
const type = document.getElementById('ruleType').value;
|
||||
const value = document.getElementById('ruleValue').value.trim();
|
||||
const proxy = document.getElementById('ruleProxy').value;
|
||||
const noResolve = document.getElementById('ruleNoResolve').checked;
|
||||
|
||||
if (type !== 'MATCH' && !value) {
|
||||
showToast('Введите значение правила', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let rule;
|
||||
if (type === 'MATCH') {
|
||||
rule = `MATCH,${proxy}`;
|
||||
} else {
|
||||
rule = `${type},${value},${proxy}`;
|
||||
const needsNoResolve = ['IP-CIDR', 'IP-CIDR6', 'SRC-IP-CIDR', 'GEOIP'].includes(type);
|
||||
if (needsNoResolve && noResolve) {
|
||||
rule += ',no-resolve';
|
||||
}
|
||||
}
|
||||
|
||||
const rules = getRules();
|
||||
rules.push(rule);
|
||||
PS.config.rules = rules;
|
||||
|
||||
closeRuleModal();
|
||||
renderAll();
|
||||
saveFullConfig(false);
|
||||
});
|
||||
|
||||
// Rule delete delegated events
|
||||
document.getElementById('rulesList').addEventListener('click', e => {
|
||||
const delBtn = e.target.closest('[data-delete-rule]');
|
||||
if (delBtn) {
|
||||
const idx = parseInt(delBtn.dataset.deleteRule);
|
||||
if (isNaN(idx)) return;
|
||||
const rules = getRules();
|
||||
rules.splice(idx, 1);
|
||||
PS.config.rules = rules;
|
||||
renderAll();
|
||||
saveFullConfig(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Core upload ───
|
||||
document.getElementById('uploadCoreForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const fileInput = document.getElementById('coreFile');
|
||||
if (!fileInput.files[0]) {
|
||||
showToast('Выберите файл', 'error');
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('core', fileInput.files[0]);
|
||||
try {
|
||||
const result = await fetch('/api/mihomo/upload-core', { method: 'POST', body: fd });
|
||||
const json = await result.json();
|
||||
if (!json.success) {
|
||||
throw new Error(json.error || 'Upload failed');
|
||||
}
|
||||
showToast('Ядро загружено: ' + (json.data.arch || json.data.path), 'success');
|
||||
await loadStatus();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── YAML editor ───
|
||||
document.getElementById('yamlLoadBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/api/mihomo/config.yaml');
|
||||
if (res.status === 404) {
|
||||
document.getElementById('yamlEditor').value = '# Config not found.';
|
||||
return;
|
||||
}
|
||||
const text = await res.text();
|
||||
document.getElementById('yamlEditor').value = text;
|
||||
showToast('Конфиг загружен', 'info');
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('yamlSaveBtn').addEventListener('click', async () => {
|
||||
const content = document.getElementById('yamlEditor').value;
|
||||
try {
|
||||
const res = await fetch('/api/mihomo/config.yaml', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/yaml' },
|
||||
body: content,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error || 'Save failed');
|
||||
showToast('Конфиг сохранён', 'success');
|
||||
await loadConfig();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Logs polling ───
|
||||
let logPollTimer = null;
|
||||
let lastLogCount = 0;
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const lines = await get('/api/mihomo/logs');
|
||||
const el = document.getElementById('logOutput');
|
||||
if (lines.length !== lastLogCount) {
|
||||
lastLogCount = lines.length;
|
||||
el.textContent = lines.join('\n');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function startLogPoll() {
|
||||
if (logPollTimer) return;
|
||||
fetchLogs();
|
||||
logPollTimer = setInterval(fetchLogs, 500);
|
||||
}
|
||||
|
||||
function stopLogPoll() {
|
||||
if (logPollTimer) {
|
||||
clearInterval(logPollTimer);
|
||||
logPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.ptab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.tab === 'logs') {
|
||||
lastLogCount = 0;
|
||||
startLogPoll();
|
||||
} else {
|
||||
stopLogPoll();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('clearLogsBtn').addEventListener('click', () => {
|
||||
document.getElementById('logOutput').textContent = '';
|
||||
lastLogCount = 0;
|
||||
});
|
||||
|
||||
// ─── Init ───
|
||||
(async () => {
|
||||
try { await loadStatus(); } catch(e) { console.error('status', e); }
|
||||
try { await loadConfig(); } catch(e) { console.error('config', e); }
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
})();
|
||||
Reference in New Issue
Block a user