Release v1.6.0: multi-server 3x-ui with API inbounds.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 07:39:06 +03:00
co-authored by Cursor
parent 3e8898ac9c
commit f91ccf1013
13 changed files with 1523 additions and 89 deletions
+9 -9
View File
@@ -219,16 +219,16 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork)
Recent panel fixes and changes:
* **Removed cascade (double VPN)** for now — the feature showed “active” while client traffic often had no working internet; will return after a solid redesign.
* **WG/AWG backups**: ZIP export of client `.conf` files + restore of protocol backups from the server UI (with loading feedback so the panel no longer freezes).
* **API performance**: in-memory data cache, faster server check/stats, heavy SSH work moved off the event loop.
* **Share / guest pages**: one-tap “Copy key” for configs.
* **User expiration**: optional countdown that starts on first config use (panel / share / guest / Telegram), with UTC-safe datetime comparison.
* **UI icons**: shared SVG icon system (emoji icons removed).
### v1.6.0
* **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui).
* **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow.
* **Removed for now**: Hysteria 2 and 3x-ui integration (kept out of the main panel path).
* **Cascade removed** for now (pending redesign — showed active while internet often did not work).
* **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
* **API performance**: in-memory data cache, faster server check/stats.
* **Share / guest**: one-tap “Copy key”.
* **User expiration**: countdown can start on first config use; UTC-safe comparisons.
* **UI**: shared SVG icon system.
* **Removed earlier**: Hysteria 2.
## 🔧 Project Details
+780 -57
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
"""3x-ui HTTP API client create VLESS clients and fetch share links.
"""3x-ui HTTP API client - create VLESS clients and fetch share links.
Works with modern panels (/panel/api/clients/*) and falls back to legacy
/panel/api/inbounds/addClient when needed.
+1 -1
View File
@@ -60,7 +60,7 @@ def ensure_xui_servers(settings: dict) -> list:
servers = [migrated]
settings['xui_servers'] = servers
# Mirror primary into legacy sync.* for older code paths (no get_xui_server avoids recursion)
# Mirror primary into legacy sync.* for older code paths (no get_xui_server - avoids recursion)
sync = settings.setdefault('sync', {})
primary = None
for s in servers:
+1
View File
@@ -104,6 +104,7 @@
if (p === 'awg2') return 'AmneziaWG 2.0';
if (p === 'awg_legacy') return 'AWG Legacy';
if (p === 'xray') return 'Xray';
if (p === 'xui') return '3x-ui VLESS';
return (p || '').toUpperCase();
}
+99 -7
View File
@@ -18,6 +18,18 @@
</button>
</div>
{% if not xui_has_sub_url and xui_configured %}
<div class="card" style="margin-bottom:var(--space-lg); border-color: rgba(245, 158, 11, 0.35); background: rgba(245, 158, 11, 0.06);">
<div style="display:flex; gap:var(--space-md); align-items:flex-start;">
<div style="font-size:1.4rem;">⚠️</div>
<div>
<div style="font-weight:600; margin-bottom:4px;">{{ _('invite_sub_url_missing_title') }}</div>
<div class="form-hint" style="margin:0;">{{ _('invite_sub_url_missing_hint') }}</div>
</div>
</div>
</div>
{% endif %}
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
<div class="empty-icon">{{ icon('link') }}</div>
<div class="empty-title">{{ _('invites_empty') }}</div>
@@ -31,7 +43,12 @@
<div>
<div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;">
{{ inv.protocol }}
{% if inv.protocol == 'xui' %}
3x-ui · {% for s in xui_servers if s.id == inv.xui_panel_id %}{{ s.name }}{% else %}{{ _('xui_server_select_label') }}{% endfor %}
· inbound #{{ inv.xui_inbound_id or '—' }}
{% else %}
{{ inv.protocol }}
{% endif %}
</div>
</div>
{% if inv.available %}
@@ -111,7 +128,8 @@
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="inviteProtocol">
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()">
<option value="xui">3x-ui VLESS</option>
{% for s in servers %}
{% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %}
@@ -123,6 +141,21 @@
</select>
</div>
<div class="form-group" id="inviteInboundGroup">
<label class="form-label">{{ _('xui_server_select_label') }}</label>
<select class="form-select" id="inviteXuiPanel" onchange="loadInviteInbounds()">
{% for s in xui_servers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
<div class="form-hint">{{ _('xui_server_select_hint') }}</div>
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="inviteInboundId">
<option value="">{{ _('invite_inbound_loading') }}</option>
</select>
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('invite_password_label') }}</label>
<input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password">
@@ -154,6 +187,9 @@
<script>
const invitesData = {{ invites | tojson }};
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
const xuiDefaultInbound = {{ xui_default_inbound | int }};
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
function inviteUrl(token) {
return `${window.location.origin}/invite/${token}`;
@@ -164,8 +200,46 @@
showToast(_('copied'), 'success');
}
function updateInviteProtoFields() {
const v = document.getElementById('inviteProtocol').value;
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
if (v === 'xui') loadInviteInbounds(document.getElementById('inviteInboundId').value || xuiDefaultInbound);
}
async function loadInviteInbounds(selected) {
const select = document.getElementById('inviteInboundId');
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
if (!xuiConfigured) {
select.innerHTML = `<option value="">${_('invite_inbound_need_xui')}</option>`;
return;
}
try {
const panelId = document.getElementById('inviteXuiPanel')?.value || xuiDefaultPanelId || '';
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
const data = await apiCall('/api/settings/xui/inbounds' + q);
const list = data.inbounds || [];
if (!list.length) {
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
return;
}
select.innerHTML = '';
list.forEach((ib, idx) => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
const prefer = selected || xuiDefaultInbound;
if (prefer && String(ib.id) === String(prefer)) opt.selected = true;
else if (!prefer && idx === 0) opt.selected = true;
select.appendChild(opt);
});
} catch (e) {
select.innerHTML = `<option value="">${_('error')}: ${e.message}</option>`;
}
}
function parseProtocolValue() {
const raw = document.getElementById('inviteProtocol').value;
if (raw === 'xui') return { protocol: 'xui', server_id: 0 };
if (raw.includes('|')) {
const [protocol, sid] = raw.split('|');
return { protocol, server_id: parseInt(sid || '0') };
@@ -175,9 +249,13 @@
function setProtocolSelect(protocol, serverId) {
const sel = document.getElementById('inviteProtocol');
const want = `${protocol}|${serverId || 0}`;
if ([...sel.options].some(o => o.value === want)) sel.value = want;
else if (sel.options.length) sel.value = sel.options[0].value;
if (protocol === 'xui') sel.value = 'xui';
else {
const want = `${protocol}|${serverId || 0}`;
if ([...sel.options].some(o => o.value === want)) sel.value = want;
else sel.value = 'xui';
}
updateInviteProtoFields();
}
function openCreateInvite() {
@@ -191,7 +269,12 @@
document.getElementById('inviteNote').value = '';
document.getElementById('inviteClearPwdWrap').style.display = 'none';
document.getElementById('inviteResetUsedWrap').style.display = 'none';
setProtocolSelect('awg', 0);
setProtocolSelect('xui', 0);
if (xuiDefaultPanelId) {
const p = document.getElementById('inviteXuiPanel');
if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId;
}
loadInviteInbounds(xuiDefaultInbound);
openModal('inviteModal');
}
@@ -210,7 +293,12 @@
document.getElementById('inviteClearPassword').checked = false;
document.getElementById('inviteResetUsedWrap').style.display = 'block';
document.getElementById('inviteResetUsed').checked = false;
setProtocolSelect(inv.protocol || 'awg', inv.server_id || 0);
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
const panelSel = document.getElementById('inviteXuiPanel');
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
panelSel.value = inv.xui_panel_id;
}
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
openModal('inviteModal');
}
@@ -222,6 +310,8 @@
try {
const editId = document.getElementById('inviteEditId').value;
const proto = parseProtocolValue();
const inbound = parseInt(document.getElementById('inviteInboundId').value || '0');
if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required'));
const body = {
name: document.getElementById('inviteName').value || 'Invite',
@@ -230,6 +320,8 @@
user_id: document.getElementById('inviteUserId').value || '',
protocol: proto.protocol,
server_id: proto.server_id,
xui_inbound_id: inbound,
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
note: document.getElementById('inviteNote').value || '',
};
const pwd = document.getElementById('invitePassword').value;
+398 -3
View File
@@ -112,6 +112,7 @@
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option>
{% for s in servers %}
{% set server_idx = loop.index0 %}
{% for key, info in (s.protocols or {}).items() %}
@@ -125,6 +126,19 @@
{% endfor %}
</select>
</div>
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
<label class="form-label">{{ _('xui_server_select_label') }}</label>
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
{% for s in xui_servers %}
<option value="{{ s.id }}" {% if settings.guest.create_xui_panel_id == s.id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="guest_create_inbound_id">
<option value="0">{{ _('invite_inbound_loading') }}</option>
</select>
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
</div>
</div>
</div>
</form>
@@ -334,6 +348,10 @@
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Marzban
</label>
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="xui_sync" {% if settings.sync.xui_sync %}checked{% endif %}>
3x-ui
</label>
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
<input type="checkbox" disabled> Hiddify
@@ -418,9 +436,106 @@
</div>
</div>
<div id="xuiFields"
style="{% if not settings.sync.xui_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md); margin-top: var(--space-md);">
<div class="form-hint" style="margin-bottom: var(--space-md);">
{{ _('xui_servers_manage_hint') }}
</div>
<div class="form-group">
<label
style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="checkbox" name="xui_sync_users" {% if settings.sync.xui_sync_users %}checked{% endif %}>
{{ _('enable_sync') }}
</label>
<div class="form-hint"
style="margin-left: var(--space-lg); line-height: 1.4; margin-bottom: var(--space-sm);">
{{ _('xui_sync_hint') }}
</div>
<div style="display: flex; gap: var(--space-sm); margin-left: var(--space-lg);">
<button type="button" class="btn btn-secondary btn-sm" onclick="syncXuiNow()" id="xuiSyncNowBtn">
<span id="xuiSyncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
<div class="spinner hidden" id="xuiSyncNowSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncXui()" id="xuiSyncDelBtn">
<span id="xuiSyncDelBtnText">{{ icon('trash') }} {{ _('delete_sync_btn') }}</span>
<div class="spinner hidden" id="xuiSyncDelSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="xui_create_conns" id="xuiSyncCreateConns" {% if
settings.sync.xui_create_conns %}checked{% endif %}>
{{ _('auto_create_conns') }}
</label>
</div>
<div id="xuiAutoConnFields"
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('sync_server_label') }}</label>
<select class="form-select" name="xui_server_id" onchange="updateProtocolsForXuiSync()">
{% for s in servers %}
<option value="{{ loop.index0 }}" {% if settings.sync.xui_server_id==loop.index0
%}selected{% endif %}>{{ s.name or s.host }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">{{ _('protocol_label') }}</label>
<select class="form-select" name="xui_protocol" id="xuiSyncProtocolSelect">
<option value="xray" {% if settings.sync.xui_protocol=='xray' %}selected{% endif %}>Xray</option>
<option value="awg" {% if settings.sync.xui_protocol=='awg' %}selected{% endif %}>AmneziaWG</option>
<option value="awg2" {% if settings.sync.xui_protocol=='awg2' %}selected{% endif %}>AmneziaWG 2.0</option>
</select>
</div>
</div>
<input type="hidden" name="xui_url" value="{{ settings.sync.xui_url or '' }}">
<input type="hidden" name="xui_sub_url" value="{{ settings.sync.xui_sub_url or '' }}">
<input type="hidden" name="xui_api_token" value="{{ settings.sync.xui_api_token or '' }}">
<input type="hidden" name="xui_username" value="{{ settings.sync.xui_username or '' }}">
<input type="hidden" name="xui_password" value="{{ settings.sync.xui_password or '' }}">
<input type="hidden" name="xui_inbound_id" value="{{ settings.sync.xui_inbound_id or 0 }}">
</div>
</form>
</div>
<!-- BLOCK: 3x-ui servers (multi) -->
<div class="card" style="margin-top: var(--space-lg);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-lg);">
<h3 class="card-title" style="margin:0;">{{ _('xui_servers_title') }}</h3>
<button type="button" class="btn btn-primary btn-sm" onclick="openXuiServerModal()">+ {{ _('xui_server_add') }}</button>
</div>
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('xui_servers_hint') }}</div>
<div id="xuiServersList">
{% if xui_servers %}
{% for s in xui_servers %}
<div class="client-item" style="margin-bottom:var(--space-sm);" data-xui-id="{{ s.id }}">
<div class="client-info" style="flex:1; min-width:0;">
<div class="client-avatar">🌐</div>
<div style="min-width:0;">
<div class="client-name">{{ s.name }}</div>
<div class="client-meta" style="word-break:break-all;">
<span>{{ s.url }}</span>
{% if s.sub_url %}<span>· sub: {{ s.sub_url }}</span>{% endif %}
</div>
</div>
</div>
<div class="client-actions">
<button class="btn btn-secondary btn-sm" type="button" onclick='editXuiServer({{ s | tojson }})'>{{ _('edit') }}</button>
<button class="btn btn-danger btn-sm" type="button" onclick="deleteXuiServer('{{ s.id }}')">{{ _('delete') }}</button>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align:center; padding:var(--space-lg); color:var(--text-muted);">{{ _('xui_servers_empty') }}</div>
{% endif %}
</div>
</div>
<!-- BLOCK: Simple Backup -->
<div class="card" style="margin-top: var(--space-lg);">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
@@ -500,6 +615,64 @@
</div>
<!-- ===== 3x-ui Server Modal ===== -->
<div class="modal-backdrop" id="xuiServerModal">
<div class="modal" style="max-width:520px;">
<div class="modal-header">
<h2 class="modal-title" id="xuiServerModalTitle">{{ _('xui_server_add') }}</h2>
<button class="modal-close" onclick="closeModal('xuiServerModal')">×</button>
</div>
<input type="hidden" id="xuiServerEditId" value="">
<div class="form-group">
<label class="form-label">{{ _('xui_server_name_label') }}</label>
<input class="form-input" type="text" id="xuiServerName" placeholder="US / NL / Home">
<div class="form-hint">{{ _('xui_server_name_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_url_label') }}</label>
<input class="form-input" type="url" id="xuiServerUrl" placeholder="https://panel.example.com:2053/path">
<div class="form-hint">{{ _('xui_url_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
<input class="form-input" type="url" id="xuiServerSubUrl" placeholder="https://sub.example.com:2096/sub">
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_api_token_label') }}</label>
<input class="form-input" type="password" id="xuiServerToken" placeholder="{{ _('xui_api_token_placeholder') }}" autocomplete="off">
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm);">
<div class="form-group">
<label class="form-label">{{ _('xui_username_label') }}</label>
<input class="form-input" type="text" id="xuiServerUser" autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">{{ _('xui_password_label') }}</label>
<input class="form-input" type="password" id="xuiServerPass" autocomplete="off">
</div>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="xuiServerEnabled" checked> {{ _('enabled') if False else 'Enabled' }}
</label>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="xuiServerSyncUsers"> {{ _('enable_sync') }}
</label>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('xuiServerModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="saveXuiServer()" id="xuiServerSaveBtn">
<span id="xuiServerSaveText">{{ _('save') }}</span>
<div class="spinner hidden" id="xuiServerSaveSpinner" style="width:14px;height:14px;"></div>
</button>
</div>
</div>
</div>
<!-- ===== Create API Token Modal ===== -->
<div class="modal-backdrop" id="createTokenModal">
<div class="modal" style="max-width: 460px;">
@@ -590,11 +763,139 @@
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
});
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
});
function updateProtocolsForXuiSync() {
const serverIdx = document.querySelector('[name="xui_server_id"]').value;
const protoSelect = document.getElementById('xuiSyncProtocolSelect');
protoSelect.innerHTML = '';
if (serverIdx === '' || !serversData[serverIdx]) return;
const protocols = serversData[serverIdx].protocols || {};
let count = 0;
for (const [key, info] of Object.entries(protocols)) {
if (info.installed) {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
if (key === "{{ settings.sync.xui_protocol }}") opt.selected = true;
protoSelect.appendChild(opt);
count++;
}
}
if (count === 0) {
const opt = document.createElement('option');
opt.textContent = _('no_protocols');
opt.disabled = true;
protoSelect.appendChild(opt);
}
}
function openXuiServerModal(server) {
document.getElementById('xuiServerEditId').value = server?.id || '';
document.getElementById('xuiServerModalTitle').textContent = server?.id ? (_('edit') + ' — 3x-ui') : _('xui_server_add');
document.getElementById('xuiServerName').value = server?.name || '';
document.getElementById('xuiServerUrl').value = server?.url || '';
document.getElementById('xuiServerSubUrl').value = server?.sub_url || '';
document.getElementById('xuiServerToken').value = '';
document.getElementById('xuiServerUser').value = server?.username || '';
document.getElementById('xuiServerPass').value = '';
document.getElementById('xuiServerEnabled').checked = server ? !!server.enabled : true;
document.getElementById('xuiServerSyncUsers').checked = !!(server && server.sync_users);
openModal('xuiServerModal');
}
function editXuiServer(server) { openXuiServerModal(server); }
async function saveXuiServer() {
const id = document.getElementById('xuiServerEditId').value;
const body = {
name: document.getElementById('xuiServerName').value.trim(),
url: document.getElementById('xuiServerUrl').value.trim(),
sub_url: document.getElementById('xuiServerSubUrl').value.trim(),
api_token: document.getElementById('xuiServerToken').value,
username: document.getElementById('xuiServerUser').value.trim(),
password: document.getElementById('xuiServerPass').value,
enabled: document.getElementById('xuiServerEnabled').checked,
sync_users: document.getElementById('xuiServerSyncUsers').checked,
default_inbound_id: 0,
};
if (!body.url) { showToast(_('error') + ': URL', 'error'); return; }
const btn = document.getElementById('xuiServerSaveBtn');
const text = document.getElementById('xuiServerSaveText');
const spinner = document.getElementById('xuiServerSaveSpinner');
btn.disabled = true; text.textContent = _('saving') || 'Saving...'; spinner.classList.remove('hidden');
try {
if (id) await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'PUT', body);
else await apiCall('/api/settings/xui/servers', 'POST', body);
showToast(_('saved') || 'OK', 'success');
closeModal('xuiServerModal');
setTimeout(() => location.reload(), 400);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false; text.textContent = _('save'); spinner.classList.add('hidden');
}
}
async function deleteXuiServer(id) {
if (!confirm(_('xui_server_delete_confirm') || 'Delete this 3x-ui server?')) return;
try {
await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'DELETE');
showToast(_('deleted') || 'OK', 'success');
setTimeout(() => location.reload(), 400);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
async function loadGuestInbounds() {
const select = document.getElementById('guest_create_inbound_id');
if (!select) return;
const preferred = {{ settings.guest.create_inbound_id | default(0) | int }};
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
try {
const panelSel = document.getElementById('guest_create_xui_panel');
const panelId = panelSel ? panelSel.value : '';
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
const res = await fetch('/api/settings/xui/inbounds' + q);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
const list = data.inbounds || [];
if (!list.length) {
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
return;
}
select.innerHTML = '';
list.forEach((ib, idx) => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
if (preferred && String(ib.id) === String(preferred)) opt.selected = true;
else if (!preferred && idx === 0) opt.selected = true;
select.appendChild(opt);
});
} catch (err) {
console.warn('loadGuestInbounds:', err);
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
}
}
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForSync();
});
document.getElementById('xuiSyncCreateConns').addEventListener('change', (e) => {
document.getElementById('xuiAutoConnFields').style.display = e.target.checked ? 'block' : 'none';
if (e.target.checked) updateProtocolsForXuiSync();
});
document.getElementById('ssl_enabled').addEventListener('change', (e) => {
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
});
@@ -844,6 +1145,78 @@
}
}
async function syncXuiNow() {
const btn = document.getElementById('xuiSyncNowBtn');
const text = document.getElementById('xuiSyncNowBtnText');
const spinner = document.getElementById('xuiSyncNowSpinner');
const syncForm = document.getElementById('syncForm');
btn.disabled = true;
text.textContent = _('sync_running');
spinner.classList.remove('hidden');
try {
const payload = {
xui_sync: true,
xui_sync_users: true,
xui_url: syncForm.xui_url.value.trim(),
xui_username: syncForm.xui_username.value.trim(),
xui_password: syncForm.xui_password.value,
xui_api_token: syncForm.xui_api_token.value.trim(),
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
};
const res = await fetch('/api/settings/xui_sync_now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_success').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
} else {
throw new Error(data.message || data.error || 'Error occurred');
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = `🔄 ${_('sync_now_btn')}`;
spinner.classList.add('hidden');
}
}
async function deleteSyncXui() {
if (!confirm(_('delete_sync_confirm'))) return;
const btn = document.getElementById('xuiSyncDelBtn');
const text = document.getElementById('xuiSyncDelBtnText');
const spinner = document.getElementById('xuiSyncDelSpinner');
btn.disabled = true;
text.textContent = _('deleting');
spinner.classList.remove('hidden');
try {
const res = await fetch('/api/settings/xui_sync_delete', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
showToast(_('sync_deleted').replace('{}', data.count), 'success');
setTimeout(() => window.location.reload(), 1500);
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.innerHTML = `${uiIcon('trash')} ${_('delete_sync_btn')}`;
spinner.classList.add('hidden');
}
}
async function saveSettings() {
const btn = document.getElementById('saveBtn');
const spinner = document.getElementById('saveSpinner');
@@ -867,6 +1240,17 @@
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
remnawave_protocol: syncForm.remnawave_protocol.value,
xui_sync: syncForm.xui_sync.checked,
xui_url: syncForm.xui_url.value,
xui_username: syncForm.xui_username.value,
xui_password: syncForm.xui_password.value,
xui_api_token: syncForm.xui_api_token.value,
xui_sync_users: syncForm.xui_sync_users.checked,
xui_create_conns: syncForm.xui_create_conns.checked,
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
xui_protocol: syncForm.xui_protocol.value,
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
};
@@ -889,10 +1273,12 @@
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
};
let createProtocol = 'awg';
let createProtocol = 'xui';
let createServerId = 0;
const guestProtoRaw = document.getElementById('guest_create_protocol').value;
if (guestProtoRaw.includes('|')) {
if (guestProtoRaw === 'xui') {
createProtocol = 'xui';
} else if (guestProtoRaw.includes('|')) {
const parts = guestProtoRaw.split('|');
createProtocol = parts[0];
createServerId = parseInt(parts[1] || '0');
@@ -907,6 +1293,8 @@
allow_create: document.getElementById('guest_allow_create').checked,
create_protocol: createProtocol,
create_server_id: createServerId,
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
};
try {
@@ -927,7 +1315,14 @@
}
function updateGuestCreateFields() {
// No-op: only server-backed protocols are selectable now.
const v = document.getElementById('guest_create_protocol')?.value;
const group = document.getElementById('guestInboundGroup');
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
if (v === 'xui') loadGuestInbounds();
}
if (document.getElementById('guest_create_protocol')?.value === 'xui') {
loadGuestInbounds();
}
function copyGuestLink() {
+74 -1
View File
@@ -306,6 +306,20 @@
</select>
</div>
<div class="form-group" id="ucXuiInboundGroup" style="display:none;">
<label class="form-label">{{ _('xui_server_select_label') }}</label>
<select class="form-select" id="ucXuiPanel" onchange="loadXuiInbounds()">
{% for s in xui_servers %}
<option value="{{ s.id }}" {% if s.id == xui_default_panel_id %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
<select class="form-select" id="ucXuiInbound">
<option value="">{{ _('invite_inbound_loading') }}</option>
</select>
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
</div>
<div class="form-group" id="ucExistingClientGroup" style="display:none;">
<label class="form-label">{{ _('select_existing_conn') }}</label>
<select class="form-select" id="ucExistingClient">
@@ -420,6 +434,9 @@
let searchTimeout = null;
const serversData = {{ servers | tojson }};
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
const xuiDefaultInboundId = {{ xui_inbound_id | int }};
const xuiServers = {{ xui_servers | default([]) | tojson }};
function populateTimeSelect(selectId) {
const select = document.getElementById(selectId);
@@ -626,6 +643,14 @@
count++;
}
if (xuiConfigured && selectId === 'ucProtocol') {
const opt = document.createElement('option');
opt.value = 'xui';
opt.textContent = '3x-ui VLESS';
select.appendChild(opt);
count++;
}
if (count > 0) {
if (group) group.style.display = '';
} else {
@@ -635,6 +660,48 @@
select.appendChild(opt);
if (group) group.style.display = '';
}
if (selectId === 'ucProtocol') {
updateXuiInboundVisibility();
}
}
async function loadXuiInbounds() {
const select = document.getElementById('ucXuiInbound');
if (!select) return;
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
try {
const panelId = document.getElementById('ucXuiPanel')?.value || '';
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
const data = await apiCall('/api/settings/xui/inbounds' + q);
const list = data.inbounds || [];
if (!list.length) {
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
return;
}
select.innerHTML = '';
list.forEach((ib, idx) => {
const opt = document.createElement('option');
opt.value = ib.id;
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
if (xuiDefaultInboundId && String(ib.id) === String(xuiDefaultInboundId)) opt.selected = true;
else if (!xuiDefaultInboundId && idx === 0) opt.selected = true;
select.appendChild(opt);
});
} catch (err) {
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
}
}
function updateXuiInboundVisibility() {
const proto = document.getElementById('ucProtocol')?.value;
const group = document.getElementById('ucXuiInboundGroup');
if (!group) return;
if (proto === 'xui') {
group.style.display = 'block';
loadXuiInbounds();
} else {
group.style.display = 'none';
}
}
// Show/hide connection fields
@@ -667,6 +734,7 @@
fetchExistingClients();
}
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
updateXuiInboundVisibility();
});
async function addUser(e) {
@@ -840,6 +908,11 @@
const clientId = document.getElementById('ucExistingClient').value;
if (!clientId) throw new Error(_('select_connection'));
body.client_id = clientId;
} else if (body.protocol === 'xui') {
const inbound = parseInt(document.getElementById('ucXuiInbound').value || '0');
if (!inbound) throw new Error(_('invite_inbound_required'));
body.xui_inbound_id = inbound;
body.xui_panel_id = document.getElementById('ucXuiPanel')?.value || '';
} else if (body.protocol === 'telemt') {
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
@@ -895,7 +968,7 @@
<div class="client-name">${c.name || 'Connection'}</div>
<div class="client-meta">
<span style="display:inline-flex;align-items:center;gap:4px;">${uiIcon('server')} ${c.server_name || ''}</span>
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : c.protocol.toUpperCase())))}</span>
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : (c.protocol === 'xui' ? '3x-ui VLESS' : c.protocol.toUpperCase()))))}</span>
</div>
</div>
</div>
+32 -2
View File
@@ -193,7 +193,6 @@
"guest_password_keep": "Leave blank to keep current password",
"guest_clear_password": "Remove password protection",
"guest_allow_create": "Allow guests to create a new config",
"guest_allow_create_hint": "Creates a config for the guest user using the selected server protocol.",
"guest_access_btn": "Continue as guest",
"guest_access_login_hint": "No account required",
"guest_title": "Guest access",
@@ -519,5 +518,36 @@
"restore_protocol_backup_confirm": "Restore this backup on the server? Current protocol files will be overwritten and the container will restart.",
"restoring_protocol_backup": "Restoring… please wait",
"restore_protocol_backup_done": "Backup restored",
"refresh": "Refresh"
"refresh": "Refresh",
"guest_allow_create_hint": "Creates a config for the guest user (3x-ui VLESS or selected server protocol).",
"invite_inbound_hint": "Inbounds are loaded from the selected 3x-ui panel. Only 3x-ui returns the config/share link.",
"invite_inbound_loading": "Loading inbounds from 3x-ui…",
"invite_inbound_empty": "No VLESS inbounds on this 3x-ui panel",
"invite_inbound_need_xui": "Configure 3x-ui in Settings first",
"invite_inbound_required": "Select a VLESS inbound from 3x-ui",
"invite_sub_url_missing_title": "Subscription URL is not set",
"invite_sub_url_missing_hint": "In Settings → 3x-ui set Subscription URL base so invite links issue /sub/… configs.",
"xui_url_label": "3x-ui URL",
"xui_url_hint": "Include scheme, port and webBasePath if configured (example: https://host:2053/secret)",
"xui_sub_url_label": "Subscription URL base",
"xui_sub_url_hint": "Public /sub base without trailing slash (example: https://host:2096/sub). Invite configs are issued as subscription links.",
"xui_api_token_label": "API Token (preferred)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "If set, username/password login is skipped",
"xui_username_label": "3x-ui Username",
"xui_password_label": "3x-ui Password",
"xui_sync_hint": "Clients from 3x-ui inbounds will be created, disabled, and deleted as panel users (matched by email)",
"xui_inbound_label": "VLESS inbound",
"xui_inbound_auto": "Auto (first VLESS inbound)",
"xui_inbound_hint": "Inbounds are loaded from 3x-ui. This site only creates the client and takes the ready share link — no local config generation.",
"xui_servers_title": "3x-ui servers",
"xui_servers_hint": "Add 3x-ui panels. When creating, pick a server and an inbound from the 3x-ui list — the panel returns the share link.",
"xui_servers_empty": "No 3x-ui servers yet. Click Add server.",
"xui_servers_manage_hint": "Panel URLs and subscription bases are managed in the “3x-ui servers” section below.",
"xui_server_add": "Add server",
"xui_server_name_label": "Name / description",
"xui_server_name_hint": "Shown in invites and user forms (e.g. US, NL, Home).",
"xui_server_select_label": "3x-ui server",
"xui_server_select_hint": "Pick a 3x-ui panel, then an inbound from the list that panel returns via API.",
"xui_server_delete_confirm": "Delete this 3x-ui server from the panel?"
}
+32 -2
View File
@@ -184,7 +184,6 @@
"guest_password_keep": "برای حفظ رمز فعلی خالی بگذارید",
"guest_clear_password": "حذف محافظت با رمز",
"guest_allow_create": "اجازه ساخت کانفیگ جدید به مهمان",
"guest_allow_create_hint": "برای کاربر مهمان با استفاده از پروتکل سرور انتخاب‌شده کانفیگ می‌سازد.",
"guest_access_btn": "ادامه به‌عنوان مهمان",
"guest_access_login_hint": "بدون حساب کاربری",
"guest_title": "دسترسی مهمان",
@@ -463,5 +462,36 @@
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh"
"refresh": "Refresh",
"guest_allow_create_hint": "برای کاربر مهمان کانفیگ می‌سازد (3x-ui VLESS یا پروتکل سرور).",
"invite_inbound_hint": "یک‌بار inbound مورد نیاز VLESS را انتخاب کنید",
"invite_inbound_loading": "در حال بارگذاری…",
"invite_inbound_empty": "inbound VLESS یافت نشد",
"invite_inbound_need_xui": "ابتدا 3x-ui را در تنظیمات پیکربندی کنید",
"invite_inbound_required": "یک inbound VLESS انتخاب کنید",
"invite_sub_url_missing_title": "آدرس اشتراک تنظیم نشده",
"invite_sub_url_missing_hint": "در تنظیمات → 3x-ui پایه /sub را وارد کنید.",
"xui_url_label": "آدرس 3x-ui",
"xui_url_hint": "شامل پروتکل، پورت و webBasePath در صورت وجود",
"xui_sub_url_label": "آدرس پایه اشتراک",
"xui_sub_url_hint": "پایه عمومی /sub بدون اسلش پایانی (مثال: https://host:2096/sub).",
"xui_api_token_label": "توکن API (ترجیحی)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "در صورت تنظیم، ورود با نام کاربری/رمز رد می‌شود",
"xui_username_label": "نام کاربری 3x-ui",
"xui_password_label": "رمز عبور 3x-ui",
"xui_sync_hint": "کلاینت‌های 3x-ui بر اساس email به کاربران پنل همگام می‌شوند",
"xui_inbound_label": "ورودی VLESS",
"xui_inbound_auto": "خودکار (اولین ورودی VLESS)",
"xui_inbound_hint": "برای ساخت کانفیگ 3x-ui VLESS استفاده می‌شود. ابتدا یک ورودی VLESS در 3x-ui بسازید.",
"xui_servers_title": "سرورهای 3x-ui",
"xui_servers_hint": "چند پنل 3x-ui اضافه کنید. هنگام ساخت، سرور را با نام انتخاب کنید تا URL اشتراک همان سرور داده شود.",
"xui_servers_empty": "هنوز سرور 3x-ui نیست. افزودن را بزنید.",
"xui_servers_manage_hint": "آدرس پنل و پایه اشتراک در بخش «سرورهای 3x-ui» مدیریت می‌شود.",
"xui_server_add": "افزودن سرور",
"xui_server_name_label": "نام / توضیح",
"xui_server_name_hint": "در دعوت‌ها نمایش داده می‌شود (مثل US، NL).",
"xui_server_select_label": "سرور 3x-ui",
"xui_server_select_hint": "پنل 3x-ui که URL اشتراک می‌دهد را انتخاب کنید.",
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟"
}
+32 -2
View File
@@ -184,7 +184,6 @@
"guest_password_keep": "Laisser vide pour conserver",
"guest_clear_password": "Retirer le mot de passe",
"guest_allow_create": "Autoriser la création d'une config",
"guest_allow_create_hint": "Crée une config pour l'utilisateur invité en utilisant le protocole du serveur sélectionné.",
"guest_access_btn": "Continuer en invité",
"guest_access_login_hint": "Sans compte",
"guest_title": "Accès invité",
@@ -463,5 +462,36 @@
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh"
"refresh": "Refresh",
"guest_allow_create_hint": "Crée une config pour l'utilisateur invité (3x-ui VLESS ou protocole serveur).",
"invite_inbound_hint": "Choisissez l'inbound VLESS une fois",
"invite_inbound_loading": "Chargement…",
"invite_inbound_empty": "Aucun inbound VLESS",
"invite_inbound_need_xui": "Configurez 3x-ui d'abord",
"invite_inbound_required": "Sélectionnez un inbound VLESS",
"invite_sub_url_missing_title": "URL d'abonnement manquante",
"invite_sub_url_missing_hint": "Dans Paramètres → 3x-ui, définissez l'URL de base /sub.",
"xui_url_label": "URL 3x-ui",
"xui_url_hint": "Inclure le schéma, le port et webBasePath si configuré",
"xui_sub_url_label": "URL de base d'abonnement",
"xui_sub_url_hint": "Base /sub publique sans slash final (ex: https://host:2096/sub).",
"xui_api_token_label": "Jeton API (préféré)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "Si défini, le login/mot de passe est ignoré",
"xui_username_label": "Identifiant 3x-ui",
"xui_password_label": "Mot de passe 3x-ui",
"xui_sync_hint": "Les clients 3x-ui deviennent des utilisateurs du panneau (liés par email)",
"xui_inbound_label": "Inbound VLESS",
"xui_inbound_auto": "Auto (premier inbound VLESS)",
"xui_inbound_hint": "Utilisé pour créer des configs 3x-ui VLESS. Créez d'abord un inbound VLESS dans 3x-ui.",
"xui_servers_title": "Serveurs 3x-ui",
"xui_servers_hint": "Ajoutez plusieurs panneaux 3x-ui. À la création, choisissez le serveur par nom — son URL d'abonnement est utilisée.",
"xui_servers_empty": "Aucun serveur 3x-ui. Cliquez sur Ajouter.",
"xui_servers_manage_hint": "Les URL et bases d'abonnement se gèrent dans la section « Serveurs 3x-ui » ci-dessous.",
"xui_server_add": "Ajouter un serveur",
"xui_server_name_label": "Nom / description",
"xui_server_name_hint": "Affiché dans les invitations (ex: US, NL).",
"xui_server_select_label": "Serveur 3x-ui",
"xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l'URL d'abonnement.",
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?"
}
+32 -2
View File
@@ -193,7 +193,6 @@
"guest_password_keep": "Оставьте пустым, чтобы сохранить текущий",
"guest_clear_password": "Убрать защиту паролем",
"guest_allow_create": "Разрешить гостям создавать новый конфиг",
"guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя с использованием протокола выбранного сервера.",
"guest_access_btn": "Войти как гость",
"guest_access_login_hint": "Регистрация не нужна",
"guest_title": "Гостевой доступ",
@@ -519,5 +518,36 @@
"restore_protocol_backup_confirm": "Восстановить этот бекап на сервер? Текущие файлы протокола будут перезаписаны, контейнер перезапустится.",
"restoring_protocol_backup": "Восстановление… подождите",
"restore_protocol_backup_done": "Бекап восстановлен",
"refresh": "Обновить"
"refresh": "Обновить",
"guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя (3x-ui VLESS или протокол выбранного сервера).",
"invite_inbound_hint": "Список inbound загружается из выбранной панели 3x-ui. Конфиг и ссылку отдаёт только 3x-ui.",
"invite_inbound_loading": "Загрузка inbound из 3x-ui…",
"invite_inbound_empty": "VLESS inbound не найдены на этой панели 3x-ui",
"invite_inbound_need_xui": "Сначала настройте 3x-ui в Настройках",
"invite_inbound_required": "Выберите VLESS inbound из 3x-ui",
"invite_sub_url_missing_title": "Не задан URL подписки",
"invite_sub_url_missing_hint": "В Настройки → 3x-ui укажите «Базовый URL подписки», чтобы ссылки выдавали /sub/… конфиги.",
"xui_url_label": "URL 3x-ui",
"xui_url_hint": "Укажите схему, порт и webBasePath при наличии (пример: https://host:2053/secret)",
"xui_sub_url_label": "Базовый URL подписки",
"xui_sub_url_hint": "Публичный /sub без слэша в конце (пример: https://host:2096/sub). Конфиги по ссылкам выдаются как subscription URL.",
"xui_api_token_label": "API Token (предпочтительно)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "Если задан, вход по логину/паролю не используется",
"xui_username_label": "Логин 3x-ui",
"xui_password_label": "Пароль 3x-ui",
"xui_sync_hint": "Клиенты из inbounds 3x-ui будут создаваться, отключаться и удаляться как пользователи панели (связь по email)",
"xui_inbound_label": "VLESS inbound",
"xui_inbound_auto": "Авто (первый VLESS inbound)",
"xui_inbound_hint": "Inbound подгружается из 3x-ui. Сайт только создаёт клиента и забирает готовую ссылку — без своей генерации конфига.",
"xui_servers_title": "Серверы 3x-ui",
"xui_servers_hint": "Добавьте панели 3x-ui. При создании выберите сервер и inbound из списка 3x-ui — ссылку вернёт панель.",
"xui_servers_empty": "Серверов 3x-ui пока нет. Нажмите «Добавить сервер».",
"xui_servers_manage_hint": "URL панелей и базовые URL подписок настраиваются в разделе «Серверы 3x-ui» ниже.",
"xui_server_add": "Добавить сервер",
"xui_server_name_label": "Название / описание",
"xui_server_name_hint": "Так сервер будет отображаться в инвайтах и у пользователей (например: US, NL, Home).",
"xui_server_select_label": "Сервер 3x-ui",
"xui_server_select_hint": "Выберите панель 3x-ui, затем inbound из списка, который она отдаёт по API.",
"xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?"
}
+32 -2
View File
@@ -184,7 +184,6 @@
"guest_password_keep": "留空以保留当前密码",
"guest_clear_password": "取消密码保护",
"guest_allow_create": "允许访客创建新配置",
"guest_allow_create_hint": "使用所选服务器协议为访客用户创建配置。",
"guest_access_btn": "以访客继续",
"guest_access_login_hint": "无需账号",
"guest_title": "访客访问",
@@ -463,5 +462,36 @@
"backup_created": "Backup created",
"no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.",
"refresh": "Refresh"
"refresh": "Refresh",
"guest_allow_create_hint": "为访客用户创建配置(3x-ui VLESS 或所选服务器协议)。",
"invite_inbound_hint": "一次性选择所需的 VLESS 入站",
"invite_inbound_loading": "加载入站中…",
"invite_inbound_empty": "未找到 VLESS 入站",
"invite_inbound_need_xui": "请先在设置中配置 3x-ui",
"invite_inbound_required": "请选择 VLESS 入站",
"invite_sub_url_missing_title": "未设置订阅 URL",
"invite_sub_url_missing_hint": "在 设置 → 3x-ui 中填写订阅基础 URL,以便发放 /sub/… 配置。",
"xui_url_label": "3x-ui 地址",
"xui_url_hint": "包含协议、端口和 webBasePath(如有)",
"xui_sub_url_label": "订阅基础 URL",
"xui_sub_url_hint": "公共 /sub 基础地址,末尾不要斜杠(如 https://host:2096/sub)。",
"xui_api_token_label": "API Token(推荐)",
"xui_api_token_placeholder": "Settings → Security → API Token",
"xui_api_token_hint": "若已设置,将跳过用户名/密码登录",
"xui_username_label": "3x-ui 用户名",
"xui_password_label": "3x-ui 密码",
"xui_sync_hint": "3x-ui 客户端将按 email 同步为面板用户",
"xui_inbound_label": "VLESS 入站",
"xui_inbound_auto": "自动(第一个 VLESS 入站)",
"xui_inbound_hint": "创建 3x-ui VLESS 配置时使用。请先在 3x-ui 中创建 VLESS 入站。",
"xui_servers_title": "3x-ui 服务器",
"xui_servers_hint": "可添加多个 3x-ui 面板。创建配置时按名称选择服务器,并使用其订阅基础 URL。",
"xui_servers_empty": "暂无 3x-ui 服务器。请点击添加。",
"xui_servers_manage_hint": "面板与订阅地址在下方「3x-ui 服务器」中管理。",
"xui_server_add": "添加服务器",
"xui_server_name_label": "名称 / 描述",
"xui_server_name_hint": "显示在邀请与用户表单中(如 US、NL)。",
"xui_server_select_label": "3x-ui 服务器",
"xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。",
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?"
}