Template
Issue invite configs as subscription URLs with redeem-based lifetime.
Admin picks inbound once, sets duration in days from Get config, and configures the 3x-ui /sub base URL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+95
-81
@@ -4,18 +4,30 @@
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<div class="flex items-center justify-between"
|
||||
style="margin-bottom: var(--space-lg); gap: var(--space-md); flex-wrap: wrap;">
|
||||
<h1 class="section-title" style="margin-bottom:0;">
|
||||
<span class="icon">🔗</span>
|
||||
{{ _('invites_title') }}
|
||||
</h1>
|
||||
<div style="display:flex; align-items:flex-end; justify-content:space-between; gap:var(--space-md); flex-wrap:wrap; margin-bottom:var(--space-lg);">
|
||||
<div>
|
||||
<h1 class="section-title" style="margin-bottom:var(--space-xs);">
|
||||
<span class="icon">🔗</span>
|
||||
{{ _('invites_title') }}
|
||||
</h1>
|
||||
<p style="color:var(--text-muted); margin:0; max-width:42rem; line-height:1.45;">{{ _('invites_hint') }}</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="openCreateInvite()">
|
||||
<span>+</span> {{ _('invite_create') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="form-hint" style="margin-bottom: var(--space-lg);">{{ _('invites_hint') }}</p>
|
||||
{% if not xui_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">🔗</div>
|
||||
@@ -23,36 +35,43 @@
|
||||
<div class="empty-desc">{{ _('invites_empty_desc') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="clients-list" id="invitesGrid"
|
||||
style="display: grid; gap: var(--space-md); {% if not invites %}display:none;{% endif %}">
|
||||
<div id="invitesGrid" style="display:{% if invites %}grid{% else %}none{% endif %}; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-md);">
|
||||
{% for inv in invites %}
|
||||
<div class="client-item" id="invite-{{ inv.id }}" style="{% if not inv.enabled %}opacity:0.55;{% endif %}">
|
||||
<div class="client-info">
|
||||
<div class="client-avatar">🔗</div>
|
||||
<div class="card invite-card" id="invite-{{ inv.id }}" style="padding:var(--space-md); {% if not inv.enabled %}opacity:0.55;{% endif %}">
|
||||
<div style="display:flex; justify-content:space-between; gap:var(--space-sm); align-items:flex-start; margin-bottom:var(--space-sm);">
|
||||
<div>
|
||||
<div class="client-name">
|
||||
{{ inv.name }}
|
||||
{% if inv.available %}
|
||||
<span class="badge badge-success">{{ _('invite_active') }}</span>
|
||||
{% elif inv.expired %}
|
||||
<span class="badge badge-warn">{{ _('invite_expired') }}</span>
|
||||
{% elif inv.exhausted %}
|
||||
<span class="badge badge-warn">{{ _('invite_exhausted') }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">{{ _('disabled') }}</span>
|
||||
{% endif %}
|
||||
<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;">
|
||||
{% if inv.protocol == 'xui' %}3x-ui VLESS{% else %}{{ inv.protocol }}{% endif %}
|
||||
· inbound #{{ inv.xui_inbound_id or '—' }}
|
||||
</div>
|
||||
<div class="client-meta">
|
||||
<span>{{ _('invite_uses') }}:
|
||||
{% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %}
|
||||
</span>
|
||||
<span>{{ inv.protocol if inv.protocol != 'xui' else '3x-ui VLESS' }}</span>
|
||||
{% if inv.has_password %}<span>🔐</span>{% endif %}
|
||||
</div>
|
||||
{% if inv.available %}
|
||||
<span class="badge badge-success">{{ _('invite_active') }}</span>
|
||||
{% elif inv.exhausted %}
|
||||
<span class="badge badge-warn">{{ _('invite_exhausted') }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">{{ _('disabled') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm); margin-bottom:var(--space-md);">
|
||||
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-sm);">
|
||||
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em;">{{ _('invite_uses') }}</div>
|
||||
<div style="font-weight:700; margin-top:2px;">
|
||||
{% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-primary); border-radius:var(--radius-md); padding:var(--space-sm);">
|
||||
<div style="font-size:0.7rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em;">{{ _('invite_duration_short') }}</div>
|
||||
<div style="font-weight:700; margin-top:2px;">
|
||||
{% if inv.duration_days %}{{ inv.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="client-actions" style="display:flex; gap:var(--space-xs); flex-wrap:wrap;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyInviteUrl('{{ inv.token }}')" title="{{ _('copy') }}">📋</button>
|
||||
|
||||
<div style="display:flex; gap:var(--space-xs); flex-wrap:wrap;">
|
||||
<button class="btn btn-primary btn-sm" onclick="copyInviteUrl('{{ inv.token }}')">📋 {{ _('copy') }}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="editInvite('{{ inv.id }}')">✏️</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="toggleInvite('{{ inv.id }}', {{ 'false' if inv.enabled else 'true' }})">
|
||||
{% if inv.enabled %}⏸{% else %}▶️{% endif %}
|
||||
@@ -64,9 +83,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Create / Edit Modal -->
|
||||
<div class="modal-backdrop" id="inviteModal">
|
||||
<div class="modal" style="max-width: 520px;">
|
||||
<div class="modal" style="max-width: 560px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="inviteModalTitle">{{ _('invite_create') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('inviteModal')">×</button>
|
||||
@@ -79,10 +97,17 @@
|
||||
<input class="form-input" type="text" id="inviteName" placeholder="Promo / Friends">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_max_uses_label') }}</label>
|
||||
<input class="form-input" type="number" id="inviteMaxUses" min="0" value="1">
|
||||
<div class="form-hint">{{ _('invite_max_uses_hint') }}</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_max_uses_label') }}</label>
|
||||
<input class="form-input" type="number" id="inviteMaxUses" min="0" value="1">
|
||||
<div class="form-hint">{{ _('invite_max_uses_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_duration_label') }}</label>
|
||||
<input class="form-input" type="number" id="inviteDurationDays" min="0" value="0">
|
||||
<div class="form-hint">{{ _('invite_duration_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -114,8 +139,9 @@
|
||||
<div class="form-group" id="inviteInboundGroup">
|
||||
<label class="form-label">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="inviteInboundId">
|
||||
<option value="0">{{ _('xui_inbound_auto') }}</option>
|
||||
<option value="">{{ _('invite_inbound_loading') }}</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ _('invite_inbound_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -126,14 +152,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_expires_label') }}</label>
|
||||
<input class="form-input" type="datetime-local" id="inviteExpires">
|
||||
<label style="display:none; align-items:center; gap:var(--space-sm); margin-top:var(--space-xs); cursor:pointer;" id="inviteClearExpWrap">
|
||||
<input type="checkbox" id="inviteClearExpires"> {{ _('invite_clear_expires') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_note_label') }}</label>
|
||||
<input class="form-input" type="text" id="inviteNote" placeholder="{{ _('invite_note_placeholder') }}">
|
||||
@@ -158,6 +176,7 @@
|
||||
<script>
|
||||
const invitesData = {{ invites | tojson }};
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||
|
||||
function inviteUrl(token) {
|
||||
return `${window.location.origin}/invite/${token}`;
|
||||
@@ -171,22 +190,36 @@
|
||||
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="0">${_('xui_inbound_auto')}</option>`;
|
||||
if (!xuiConfigured) return;
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
||||
if (!xuiConfigured) {
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_need_xui')}</option>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiCall('/api/settings/xui/inbounds');
|
||||
(data.inbounds || []).forEach(ib => {
|
||||
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 (selected && String(ib.id) === String(selected)) opt.selected = true;
|
||||
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) { /* ignore */ }
|
||||
} catch (e) {
|
||||
select.innerHTML = `<option value="">${_('error')}: ${e.message}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseProtocolValue() {
|
||||
@@ -201,9 +234,8 @@
|
||||
|
||||
function setProtocolSelect(protocol, serverId) {
|
||||
const sel = document.getElementById('inviteProtocol');
|
||||
if (protocol === 'xui') {
|
||||
sel.value = 'xui';
|
||||
} else {
|
||||
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';
|
||||
@@ -216,15 +248,14 @@
|
||||
document.getElementById('inviteModalTitle').textContent = _('invite_create');
|
||||
document.getElementById('inviteName').value = '';
|
||||
document.getElementById('inviteMaxUses').value = '1';
|
||||
document.getElementById('inviteDurationDays').value = '0';
|
||||
document.getElementById('inviteUserId').value = '';
|
||||
document.getElementById('invitePassword').value = '';
|
||||
document.getElementById('inviteExpires').value = '';
|
||||
document.getElementById('inviteNote').value = '';
|
||||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||||
document.getElementById('inviteClearExpWrap').style.display = 'none';
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||||
setProtocolSelect('xui', 0);
|
||||
loadInviteInbounds(0);
|
||||
loadInviteInbounds(xuiDefaultInbound);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
|
||||
@@ -235,64 +266,47 @@
|
||||
document.getElementById('inviteModalTitle').textContent = _('invite_edit');
|
||||
document.getElementById('inviteName').value = inv.name || '';
|
||||
document.getElementById('inviteMaxUses').value = String(inv.max_uses ?? 1);
|
||||
document.getElementById('inviteDurationDays').value = String(inv.duration_days ?? 0);
|
||||
document.getElementById('inviteUserId').value = inv.user_id || '';
|
||||
document.getElementById('invitePassword').value = '';
|
||||
document.getElementById('inviteNote').value = inv.note || '';
|
||||
document.getElementById('inviteClearPwdWrap').style.display = inv.has_password ? 'flex' : 'none';
|
||||
document.getElementById('inviteClearPassword').checked = false;
|
||||
document.getElementById('inviteClearExpWrap').style.display = inv.expires_at ? 'flex' : 'none';
|
||||
document.getElementById('inviteClearExpires').checked = false;
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
||||
document.getElementById('inviteResetUsed').checked = false;
|
||||
if (inv.expires_at) {
|
||||
try {
|
||||
const d = new Date(inv.expires_at);
|
||||
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||
document.getElementById('inviteExpires').value = local.toISOString().slice(0, 16);
|
||||
} catch (e) {
|
||||
document.getElementById('inviteExpires').value = '';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('inviteExpires').value = '';
|
||||
}
|
||||
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
|
||||
loadInviteInbounds(inv.xui_inbound_id || 0);
|
||||
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
|
||||
async function saveInvite() {
|
||||
const btn = document.getElementById('inviteSaveBtn');
|
||||
const text = document.getElementById('inviteSaveText');
|
||||
const spinner = document.getElementById('inviteSaveSpinner');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const editId = document.getElementById('inviteEditId').value;
|
||||
const proto = parseProtocolValue();
|
||||
const expiresVal = document.getElementById('inviteExpires').value;
|
||||
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',
|
||||
max_uses: parseInt(document.getElementById('inviteMaxUses').value || '1'),
|
||||
duration_days: parseInt(document.getElementById('inviteDurationDays').value || '0'),
|
||||
user_id: document.getElementById('inviteUserId').value || '',
|
||||
protocol: proto.protocol,
|
||||
server_id: proto.server_id,
|
||||
xui_inbound_id: parseInt(document.getElementById('inviteInboundId').value || '0'),
|
||||
xui_inbound_id: inbound,
|
||||
note: document.getElementById('inviteNote').value || '',
|
||||
};
|
||||
const pwd = document.getElementById('invitePassword').value;
|
||||
if (pwd) body.password = pwd;
|
||||
if (expiresVal) body.expires_at = new Date(expiresVal).toISOString();
|
||||
|
||||
if (!body.user_id) throw new Error(_('invite_user_required'));
|
||||
|
||||
if (editId) {
|
||||
body.clear_password = document.getElementById('inviteClearPassword').checked;
|
||||
body.clear_expires = document.getElementById('inviteClearExpires').checked;
|
||||
body.reset_used = document.getElementById('inviteResetUsed').checked;
|
||||
if (!expiresVal && !body.clear_expires) {
|
||||
// keep existing — don't send expires_at
|
||||
delete body.expires_at;
|
||||
}
|
||||
await apiCall(`/api/invites/${editId}`, 'PUT', body);
|
||||
showToast(_('invite_updated'), 'success');
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user