2423 lines
88 KiB
JavaScript
2423 lines
88 KiB
JavaScript
(() => {
|
||
const THEME_KEY = "ef_theme";
|
||
function getTheme() {
|
||
return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
||
}
|
||
function applyTheme(theme) {
|
||
const t = theme === "light" ? "light" : "dark";
|
||
document.documentElement.setAttribute("data-theme", t);
|
||
document.documentElement.setAttribute("data-bs-theme", t);
|
||
try {
|
||
localStorage.setItem(THEME_KEY, t);
|
||
} catch (_) {}
|
||
}
|
||
document.getElementById("btn-theme-toggle")?.addEventListener("click", () => {
|
||
applyTheme(getTheme() === "dark" ? "light" : "dark");
|
||
});
|
||
|
||
const TOKEN_KEY = "vpn_admin_token";
|
||
const $ = (sel) => document.querySelector(sel);
|
||
|
||
let lastGenerated = "";
|
||
let usersOffset = 0;
|
||
const usersLimit = 30;
|
||
let usersTotal = 0;
|
||
let usersQuery = "";
|
||
let searchDebounce = null;
|
||
|
||
function toast(msg) {
|
||
const el = $("#admin-toast");
|
||
el.textContent = msg;
|
||
el.classList.remove("hidden");
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => el.classList.add("hidden"), 3200);
|
||
}
|
||
|
||
async function copyText(text) {
|
||
if (!text) return false;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
} catch {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
ta.style.position = "fixed";
|
||
ta.style.left = "-9999px";
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
const ok = document.execCommand("copy");
|
||
document.body.removeChild(ta);
|
||
return ok;
|
||
}
|
||
}
|
||
|
||
function getToken() {
|
||
try {
|
||
return localStorage.getItem(TOKEN_KEY) || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function setToken(token) {
|
||
try {
|
||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||
else localStorage.removeItem(TOKEN_KEY);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
...(options.headers || {}),
|
||
};
|
||
const token = getToken();
|
||
if (token) headers.Authorization = `Bearer ${token}`;
|
||
const res = await fetch(path, {
|
||
...options,
|
||
headers,
|
||
credentials: "same-origin",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || data.ok === false) {
|
||
throw new Error(data.error || `HTTP ${res.status}`);
|
||
}
|
||
if (data.token) setToken(data.token);
|
||
return data;
|
||
}
|
||
|
||
function showPanel(authed) {
|
||
$("#admin-login").classList.toggle("hidden", authed);
|
||
$("#admin-panel").classList.toggle("hidden", !authed);
|
||
$("#btn-admin-logout").classList.toggle("hidden", !authed);
|
||
if (authed) {
|
||
const hour = new Date().getHours();
|
||
const greet =
|
||
hour < 6 ? "Доброй ночи" : hour < 12 ? "Доброе утро" : hour < 18 ? "Добрый день" : "Добрый вечер";
|
||
const title = $("#admin-hello-title");
|
||
const sub = $("#admin-hello-sub");
|
||
if (title) title.textContent = `${greet} · панель управления`;
|
||
if (sub) sub.textContent = "Выберите раздел слева";
|
||
const fromHash = (location.hash || "").replace(/^#/, "");
|
||
let saved = "";
|
||
try {
|
||
saved = localStorage.getItem("admin_section") || "";
|
||
} catch (_) {}
|
||
const valid = new Set([
|
||
"stats",
|
||
"xui",
|
||
"awg",
|
||
"tariffs",
|
||
"payments",
|
||
"smtp",
|
||
"codes",
|
||
"users",
|
||
"growth",
|
||
"db",
|
||
"tickets",
|
||
"ai",
|
||
]);
|
||
const initial = valid.has(fromHash) ? fromHash : valid.has(saved) ? saved : "stats";
|
||
jumpToSection(initial);
|
||
}
|
||
}
|
||
|
||
function jumpToSection(key) {
|
||
const hints = {
|
||
stats: "Обзор статистики и быстрый статус сервиса",
|
||
xui: "Серверы 3x-ui · WireGuard",
|
||
awg: "AmneziaWG 2.0 · серверы и клиенты",
|
||
tariffs: "Тарифные планы и цены",
|
||
payments: "Платежи, YooKassa и журнал",
|
||
smtp: "Почта для сброса пароля и уведомлений",
|
||
codes: "Активационные и промокоды — клик по коду копирует",
|
||
users: "Поиск, бан, удаление, баланс и сессии",
|
||
growth: "Рефералка, trial и настройки роста",
|
||
db: "Экспорт и импорт Postgres · бэкапы",
|
||
tickets: "Поддержка и переписка с пользователями",
|
||
ai: "OpenRouter · автоответы в тикетах",
|
||
};
|
||
const k = key || "stats";
|
||
document.querySelectorAll(".admin-section").forEach((el) => {
|
||
el.classList.toggle("is-active", el.dataset.section === k);
|
||
});
|
||
document.querySelectorAll(".admin-nav-btn").forEach((btn) => {
|
||
btn.classList.toggle("active", btn.dataset.jump === k);
|
||
});
|
||
const hint = $("#admin-section-hint");
|
||
if (hint) hint.textContent = hints[k] || "";
|
||
try {
|
||
localStorage.setItem("admin_section", k);
|
||
if (location.hash !== `#${k}`) {
|
||
history.replaceState(null, "", `#${k}`);
|
||
}
|
||
} catch (_) {}
|
||
if (k === "db") {
|
||
loadBackups().catch(() => {});
|
||
}
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
|
||
let activeAdminTicketId = null;
|
||
let aiCatalog = [];
|
||
let aiSelectedModels = [];
|
||
let aiClearKey = false;
|
||
|
||
function renderAiModelSelect(active) {
|
||
const sel = $("#ai-model");
|
||
const ids = [...new Set([...(aiSelectedModels || []), active].filter(Boolean))];
|
||
sel.innerHTML = ids
|
||
.map((id) => `<option value="${escapeAttr(id)}">${escapeHtml(id)}</option>`)
|
||
.join("");
|
||
if (active) sel.value = active;
|
||
}
|
||
|
||
function renderAiModelsPick(filter = "") {
|
||
const box = $("#ai-models-pick");
|
||
const q = filter.trim().toLowerCase();
|
||
const selected = new Set(aiSelectedModels);
|
||
const fromCatalog = aiCatalog.length
|
||
? aiCatalog
|
||
: aiSelectedModels.map((id) => ({ id, name: id }));
|
||
let list = fromCatalog;
|
||
if (q) {
|
||
list = list.filter(
|
||
(m) =>
|
||
String(m.id).toLowerCase().includes(q) ||
|
||
String(m.name || "").toLowerCase().includes(q)
|
||
);
|
||
}
|
||
// всегда показываем выбранные сверху
|
||
const selectedItems = aiSelectedModels.map((id) => {
|
||
const found = fromCatalog.find((m) => m.id === id);
|
||
return found || { id, name: id };
|
||
});
|
||
const rest = list.filter((m) => !selected.has(m.id)).slice(0, 80);
|
||
const show = [...selectedItems, ...rest];
|
||
box.innerHTML = "";
|
||
if (!show.length) {
|
||
box.innerHTML = '<p class="muted small">Нажмите «Каталог» или добавьте модель вручную</p>';
|
||
return;
|
||
}
|
||
for (const m of show) {
|
||
const label = document.createElement("label");
|
||
label.className = "ai-model-chip";
|
||
if (selected.has(m.id)) label.classList.add("active");
|
||
label.innerHTML = `
|
||
<input type="checkbox" value="${escapeAttr(m.id)}" ${selected.has(m.id) ? "checked" : ""} />
|
||
<span>${escapeHtml(m.name || m.id)}</span>
|
||
<code>${escapeHtml(m.id)}</code>
|
||
`;
|
||
box.appendChild(label);
|
||
}
|
||
}
|
||
|
||
async function loadAiSettings() {
|
||
const data = await api("/api/admin/ai");
|
||
const ai = data.ai || {};
|
||
$("#ai-enabled").checked = Boolean(ai.enabled);
|
||
$("#ai-auto-reply").checked = ai.auto_reply !== false;
|
||
$("#ai-enabled-bar")?.classList.toggle("is-off", !ai.enabled);
|
||
$("#ai-auto-bar")?.classList.toggle("is-off", !ai.auto_reply);
|
||
const enStatus = $("#ai-enabled-status");
|
||
if (enStatus) enStatus.textContent = ai.enabled ? "Включены" : "Выключены";
|
||
$("#ai-api-key").value = "";
|
||
$("#ai-api-key").placeholder = ai.api_key_set
|
||
? ai.api_key_masked || "ключ сохранён"
|
||
: "sk-or-v1-…";
|
||
$("#ai-key-hint").textContent = ai.api_key_set
|
||
? `Сохранён: ${ai.api_key_masked}`
|
||
: "Ключ не задан — возьмите на openrouter.ai/keys";
|
||
aiSelectedModels = Array.isArray(ai.models) && ai.models.length
|
||
? ai.models
|
||
: ai.defaults || [];
|
||
aiClearKey = false;
|
||
$("#ai-system-prompt").value = ai.system_prompt || "";
|
||
renderAiModelSelect(ai.model || aiSelectedModels[0] || "");
|
||
renderAiModelsPick($("#ai-model-search").value || "");
|
||
}
|
||
|
||
async function loadStats() {
|
||
const data = await api("/api/admin/stats");
|
||
const s = data.stats || {};
|
||
const t = data.tickets || {};
|
||
$("#admin-stats").innerHTML = `
|
||
<div class="admin-stat admin-stat--total" id="section-stats">
|
||
<div class="admin-stat-top">
|
||
<span class="admin-stat-label">Всего кодов</span>
|
||
<span class="admin-stat-ico" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h6M7 13h10"/></svg>
|
||
</span>
|
||
</div>
|
||
<span class="admin-stat-value">${s.total || 0}</span>
|
||
<p class="admin-stat-hint">Сгенерировано за всё время</p>
|
||
</div>
|
||
<div class="admin-stat admin-stat--unused">
|
||
<div class="admin-stat-top">
|
||
<span class="admin-stat-label">Свободно</span>
|
||
<span class="admin-stat-ico" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>
|
||
</span>
|
||
</div>
|
||
<span class="admin-stat-value">${s.unused || 0}</span>
|
||
<p class="admin-stat-hint">Можно выдать пользователям</p>
|
||
</div>
|
||
<div class="admin-stat admin-stat--used">
|
||
<div class="admin-stat-top">
|
||
<span class="admin-stat-label">Использовано</span>
|
||
<span class="admin-stat-ico" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 8v4l3 3"/><circle cx="12" cy="12" r="9"/></svg>
|
||
</span>
|
||
</div>
|
||
<span class="admin-stat-value">${s.used || 0}</span>
|
||
<p class="admin-stat-hint">Уже активированы</p>
|
||
</div>
|
||
<div class="admin-stat admin-stat--tickets">
|
||
<div class="admin-stat-top">
|
||
<span class="admin-stat-label">Тикеты открыты</span>
|
||
<span class="admin-stat-ico" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3a2 2 0 1 0 0 4v3a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-3a2 2 0 1 0 0-4V7z"/></svg>
|
||
</span>
|
||
</div>
|
||
<span class="admin-stat-value">${t.open || 0}</span>
|
||
<p class="admin-stat-hint">Ждут ответа поддержки</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function loadTickets() {
|
||
const status = $("#filter-tickets").value;
|
||
const data = await api(
|
||
`/api/admin/tickets?status=${encodeURIComponent(status)}&limit=50`
|
||
);
|
||
const st = data.stats || {};
|
||
$("#tickets-stats-line").textContent =
|
||
`Открыто ${st.open || 0} · с ответом ${st.answered || 0} · закрыто ${st.closed || 0}`;
|
||
const box = $("#admin-tickets-list");
|
||
box.innerHTML = "";
|
||
for (const t of data.tickets || []) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "admin-ticket-row";
|
||
if (activeAdminTicketId === t.id) btn.classList.add("active");
|
||
btn.dataset.id = t.id;
|
||
const user = t.user?.display_name || `ID ${t.telegram_id}`;
|
||
btn.innerHTML = `
|
||
<div>
|
||
<strong>#${t.id} · ${escapeHtml(t.subject)}</strong>
|
||
<div class="muted small">${escapeHtml(user)} · ${fmtDate(t.updated_at)}</div>
|
||
</div>
|
||
<span class="badge badge--${escapeHtml(t.status)}">${escapeHtml(t.status_label || t.status)}</span>
|
||
`;
|
||
box.appendChild(btn);
|
||
}
|
||
if (!box.children.length) {
|
||
box.innerHTML = `
|
||
<div class="admin-empty">
|
||
<p class="empty-title">Тикетов пока нет</p>
|
||
<p class="muted small">Когда пользователи напишут — обращения появятся здесь</p>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function renderAdminTicket(data) {
|
||
const t = data.ticket || {};
|
||
activeAdminTicketId = t.id;
|
||
const detail = $("#admin-ticket-detail");
|
||
const empty = $("#admin-ticket-empty");
|
||
detail.classList.remove("hidden");
|
||
empty?.classList.add("hidden");
|
||
const user = t.user?.display_name || `ID ${t.telegram_id}`;
|
||
$("#admin-ticket-title").textContent = `#${t.id} · ${t.subject || ""}`;
|
||
$("#admin-ticket-meta").textContent =
|
||
`${user} · ID ${t.telegram_id} · ${t.status_label || t.status}`;
|
||
const closed = t.status === "closed";
|
||
$("#btn-admin-ticket-close").classList.toggle("hidden", closed);
|
||
$("#btn-admin-ticket-reopen").classList.toggle("hidden", !closed);
|
||
$("#form-admin-ticket-reply").classList.toggle("hidden", closed);
|
||
const box = $("#admin-ticket-messages");
|
||
box.innerHTML = "";
|
||
(data.messages || []).forEach((m) => {
|
||
const el = document.createElement("div");
|
||
const who = m.author_role === "admin" ? "Админ" : m.author_role === "ai" ? "ИИ" : "Пользователь";
|
||
el.className = `ticket-msg ticket-msg--${m.author_role === "admin" ? "admin" : m.author_role === "ai" ? "ai" : "user"}`;
|
||
el.innerHTML = `
|
||
<div class="ticket-msg-meta"><strong>${who}</strong> · ${fmtDate(m.created_at)}</div>
|
||
<div class="ticket-msg-body">${escapeHtml(m.body)}</div>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
box.scrollTop = box.scrollHeight;
|
||
document.querySelectorAll(".admin-ticket-row").forEach((row) => {
|
||
row.classList.toggle("active", Number(row.dataset.id) === t.id);
|
||
});
|
||
}
|
||
|
||
async function openAdminTicket(id) {
|
||
const data = await api(`/api/admin/tickets/${id}`);
|
||
renderAdminTicket(data);
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
if (!iso) return "—";
|
||
try {
|
||
return new Date(iso).toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
async function loadCodes() {
|
||
const status = $("#filter-status").value;
|
||
const data = await api(`/api/admin/codes?status=${encodeURIComponent(status)}&limit=100`);
|
||
const tbody = $("#codes-tbody");
|
||
tbody.innerHTML = "";
|
||
for (const row of data.codes || []) {
|
||
const tr = document.createElement("tr");
|
||
const used = Boolean(row.used_at);
|
||
const badge = used
|
||
? `<span class="badge badge--used">Использован</span>`
|
||
: `<span class="badge badge--free">Свободен</span>`;
|
||
const sub = used && row.used_by ? `<div class="muted small">ID ${row.used_by}</div>` : "";
|
||
tr.innerHTML = `
|
||
<td><span class="code-cell" data-code="${row.code}" title="Копировать">${row.code}</span></td>
|
||
<td><strong>${Math.round(row.amount)} ₽</strong></td>
|
||
<td class="muted small">${fmtDate(row.created_at)}</td>
|
||
<td>${badge}${sub}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
if (!tbody.children.length) {
|
||
tbody.innerHTML =
|
||
'<tr><td colspan="4" class="codes-empty">Кодов пока нет — создайте первую партию слева</td></tr>';
|
||
}
|
||
}
|
||
|
||
function bindCodeCopy() {
|
||
$("#codes-tbody").addEventListener("click", async (e) => {
|
||
const cell = e.target.closest(".code-cell");
|
||
if (!cell) return;
|
||
const code = cell.dataset.code || "";
|
||
if (await copyText(code)) toast("Код скопирован");
|
||
});
|
||
}
|
||
|
||
async function loadUsers() {
|
||
const q = usersQuery.trim();
|
||
const data = await api(
|
||
`/api/admin/users?q=${encodeURIComponent(q)}&limit=${usersLimit}&offset=${usersOffset}`
|
||
);
|
||
usersTotal = data.total || 0;
|
||
const tbody = $("#users-tbody");
|
||
tbody.innerHTML = "";
|
||
for (const u of data.users || []) {
|
||
const tr = document.createElement("tr");
|
||
const sub = [u.login && `login: ${u.login}`, u.username && `@${u.username}`]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
tr.innerHTML = `
|
||
<td>
|
||
<div class="user-display">${escapeHtml(u.display_name || "—")}</div>
|
||
${sub ? `<div class="user-sub">${escapeHtml(sub)}</div>` : ""}
|
||
</td>
|
||
<td class="muted small">${u.telegram_id}</td>
|
||
<td><strong>${Math.round(u.balance)} ₽</strong>${u.banned ? ' <span class="chip">BAN</span>' : ""}</td>
|
||
<td>
|
||
<button type="button" class="btn secondary small-btn btn-credit" data-credit-id="${u.telegram_id}" data-credit-name="${escapeAttr(u.display_name || u.telegram_id)}">
|
||
+ Баланс
|
||
</button>
|
||
<button type="button" class="btn ghost small-btn" data-ban-id="${u.telegram_id}" data-ban="${u.banned ? "0" : "1"}">
|
||
${u.banned ? "Разбан" : "Бан"}
|
||
</button>
|
||
<button type="button" class="btn ghost small-btn" data-del-user="${u.telegram_id}" data-del-name="${escapeAttr(u.display_name || u.telegram_id)}" title="Удалить навсегда">
|
||
<i class="fa-solid fa-trash"></i>
|
||
</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
if (!tbody.children.length) {
|
||
tbody.innerHTML =
|
||
'<tr><td colspan="4" class="codes-empty">Никого не нашли — попробуйте другой запрос</td></tr>';
|
||
}
|
||
const from = usersTotal ? usersOffset + 1 : 0;
|
||
const to = Math.min(usersOffset + usersLimit, usersTotal);
|
||
$("#users-summary").textContent = usersTotal
|
||
? `Показано ${from}–${to} из ${usersTotal}`
|
||
: "Нет пользователей";
|
||
$("#users-page-label").textContent = usersTotal
|
||
? `${Math.floor(usersOffset / usersLimit) + 1} / ${Math.max(1, Math.ceil(usersTotal / usersLimit))}`
|
||
: "—";
|
||
$("#btn-users-prev").disabled = usersOffset <= 0;
|
||
$("#btn-users-next").disabled = usersOffset + usersLimit >= usersTotal;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function escapeAttr(s) {
|
||
return escapeHtml(s).replace(/'/g, "'");
|
||
}
|
||
|
||
function openCreditDialog(telegramId, name) {
|
||
$("#credit-telegram-id").value = String(telegramId);
|
||
$("#credit-user-label").textContent = `${name} · ID ${telegramId}`;
|
||
const form = $("#form-credit");
|
||
form.reset();
|
||
$("#credit-telegram-id").value = String(telegramId);
|
||
$("#dialog-credit").showModal();
|
||
}
|
||
|
||
// ─── 3x-ui servers ─────────────────────────────────────────────────────
|
||
|
||
let xuiServers = [];
|
||
let xuiInboundOptions = [];
|
||
|
||
function updateXuiSubPreview() {
|
||
const base = ($("#xui-subscription-url").value || "").trim().replace(/\/+$/, "");
|
||
const el = $("#xui-sub-preview");
|
||
el.textContent = base
|
||
? `Итоговая ссылка: ${base}/exampleSubId`
|
||
: "Итоговая ссылка: base/{subId}";
|
||
}
|
||
|
||
function fillXuiInboundSelect(inbounds, selectedId) {
|
||
const sel = $("#xui-inbound-id");
|
||
xuiInboundOptions = inbounds || [];
|
||
const opts = [
|
||
'<option value="">— выберите WireGuard inbound —</option>',
|
||
...xuiInboundOptions.map((i) => {
|
||
const label = `#${i.id} ${i.remark || i.protocol} · :${i.port} · clients ${i.clients_count || 0}`;
|
||
return `<option value="${escapeAttr(String(i.id))}">${escapeHtml(label)}</option>`;
|
||
}),
|
||
];
|
||
sel.innerHTML = opts.join("");
|
||
if (selectedId != null && selectedId !== "") {
|
||
sel.value = String(selectedId);
|
||
}
|
||
}
|
||
|
||
function syncXuiInboundRemark() {
|
||
const id = $("#xui-inbound-id").value;
|
||
const found = xuiInboundOptions.find((i) => String(i.id) === String(id));
|
||
$("#xui-inbound-remark").value = found ? found.remark || "" : "";
|
||
}
|
||
|
||
function hideXuiForm() {
|
||
const form = $("#form-xui");
|
||
form.classList.add("hidden");
|
||
form.reset();
|
||
$("#xui-id").value = "";
|
||
$("#xui-password").required = false;
|
||
$("#xui-password-hint").textContent = "Только если нет API Token";
|
||
$("#xui-api-key-hint").textContent = "Рекомендуется для 3.5.0 (логин не нужен)";
|
||
$("#xui-test-out").classList.add("hidden");
|
||
fillXuiInboundSelect([], "");
|
||
}
|
||
|
||
function showXuiForm(server) {
|
||
const form = $("#form-xui");
|
||
form.classList.remove("hidden");
|
||
$("#xui-test-out").classList.add("hidden");
|
||
if (server) {
|
||
$("#xui-id").value = String(server.id);
|
||
$("#xui-name").value = server.name || "";
|
||
$("#xui-api-url").value = server.api_url || "";
|
||
$("#xui-username").value = server.username || "";
|
||
$("#xui-password").value = "";
|
||
$("#xui-api-key").value = "";
|
||
$("#xui-password").required = false;
|
||
$("#xui-password-hint").textContent = server.password_set
|
||
? "Оставьте пустым, чтобы не менять"
|
||
: "Только если нет API Token";
|
||
$("#xui-api-key-hint").textContent = server.api_key_set
|
||
? "Токен сохранён — оставьте пустым, чтобы не менять"
|
||
: "Рекомендуется для 3.5.0 (логин не нужен)";
|
||
$("#xui-subscription-url").value = server.subscription_url || "";
|
||
$("#xui-enabled").checked = !!server.enabled;
|
||
$("#xui-verify-ssl").checked = server.verify_ssl !== false;
|
||
const opts = [];
|
||
if (server.inbound_id != null) {
|
||
opts.push({
|
||
id: server.inbound_id,
|
||
remark: server.inbound_remark || "WireGuard",
|
||
protocol: "wireguard",
|
||
port: "?",
|
||
clients_count: "?",
|
||
});
|
||
}
|
||
fillXuiInboundSelect(opts, server.inbound_id);
|
||
$("#xui-inbound-remark").value = server.inbound_remark || "";
|
||
} else {
|
||
form.reset();
|
||
$("#xui-id").value = "";
|
||
$("#xui-enabled").checked = true;
|
||
$("#xui-verify-ssl").checked = true;
|
||
$("#xui-password").required = false;
|
||
$("#xui-password-hint").textContent = "Только если нет API Token";
|
||
$("#xui-api-key-hint").textContent = "Рекомендуется для 3.5.0 (логин не нужен)";
|
||
fillXuiInboundSelect([], "");
|
||
}
|
||
updateXuiSubPreview();
|
||
form.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
|
||
function renderXuiServers() {
|
||
const box = $("#xui-servers-list");
|
||
if (!xuiServers.length) {
|
||
box.innerHTML = `
|
||
<div class="admin-empty">
|
||
<p class="empty-title">Серверов пока нет</p>
|
||
<p class="muted small">Добавьте панель 3x-ui 3.5.0 с WireGuard inbound — кнопка «+ Сервер»</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
box.innerHTML = xuiServers
|
||
.map((s) => {
|
||
const statusBadge =
|
||
s.last_check_ok === true
|
||
? '<span class="xui-badge ok">online</span>'
|
||
: s.last_check_ok === false
|
||
? '<span class="xui-badge bad">fail</span>'
|
||
: "";
|
||
const enBadge = s.enabled
|
||
? '<span class="xui-badge">on</span>'
|
||
: '<span class="xui-badge off">off</span>';
|
||
const authBadge = s.api_key_set
|
||
? '<span class="xui-badge">api key</span>'
|
||
: '<span class="xui-badge off">login</span>';
|
||
const inbound =
|
||
s.inbound_id != null
|
||
? `WG #${s.inbound_id}${s.inbound_remark ? " · " + s.inbound_remark : ""}`
|
||
: "inbound не выбран";
|
||
const sub = s.subscription_url || "— URL подписки не задан —";
|
||
const check = s.last_check_msg
|
||
? `<br/>Проверка: ${escapeHtml(s.last_check_msg)}`
|
||
: "";
|
||
return `
|
||
<div class="xui-server-row" data-id="${s.id}">
|
||
<div class="xui-server-main">
|
||
<div class="xui-server-title">
|
||
${escapeHtml(s.name)}
|
||
${enBadge}
|
||
${authBadge}
|
||
${statusBadge}
|
||
<span class="xui-badge off">3.5.0</span>
|
||
</div>
|
||
<div class="xui-server-meta">
|
||
API: <code>${escapeHtml(s.api_url)}</code><br/>
|
||
${escapeHtml(inbound)} · sub: <code>${escapeHtml(sub)}</code>${check}
|
||
</div>
|
||
</div>
|
||
<div class="xui-server-actions">
|
||
<button type="button" class="btn ghost small-btn" data-xui-act="test">Проверить</button>
|
||
<button type="button" class="btn ghost small-btn" data-xui-act="edit">Изменить</button>
|
||
<button type="button" class="btn ghost small-btn" data-xui-act="delete">Удалить</button>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
async function loadXuiServers() {
|
||
const data = await api("/api/admin/xui/servers");
|
||
xuiServers = data.servers || [];
|
||
renderXuiServers();
|
||
}
|
||
|
||
async function loadWgSettings() {
|
||
const data = await api("/api/admin/xui/wg-settings");
|
||
applyWgSaleUi(data);
|
||
}
|
||
|
||
function applyWgSaleUi(data) {
|
||
const enabled = !!data?.enabled;
|
||
const price = data?.price ?? 0;
|
||
const priceLabel = data?.price_label || (price > 0 ? `${price} ₽` : "бесплатно");
|
||
const toggle = $("#wg-purchase-enabled");
|
||
const priceInput = $("#wg-config-price");
|
||
const status = $("#wg-sale-status");
|
||
const bar = $("#wg-sale-bar");
|
||
if (toggle) toggle.checked = enabled;
|
||
if (priceInput) priceInput.value = price;
|
||
if (status) {
|
||
status.textContent = enabled
|
||
? `Включена · ${priceLabel} · до ${data?.max_configs || 10} конфигов`
|
||
: "Выключена — пользователи не видят блок";
|
||
}
|
||
bar?.classList.toggle("is-off", !enabled);
|
||
}
|
||
|
||
async function saveWgSettings() {
|
||
const data = await api("/api/admin/xui/wg-settings", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
enabled: $("#wg-purchase-enabled")?.checked || false,
|
||
price: Number($("#wg-config-price")?.value || 0),
|
||
}),
|
||
});
|
||
applyWgSaleUi(data);
|
||
toast(
|
||
data.enabled
|
||
? `Покупка WG включена · ${data.price_label}`
|
||
: "Покупка WG выключена"
|
||
);
|
||
return data;
|
||
}
|
||
|
||
// ─── AmneziaWG 2.0 ─────────────────────────────────────────────────────
|
||
|
||
let awgServers = [];
|
||
|
||
function hideAwgForm() {
|
||
const form = $("#form-awg");
|
||
form.classList.add("hidden");
|
||
form.reset();
|
||
$("#awg-id").value = "";
|
||
$("#awg-api-token-hint").textContent = "Создайте токен в панели Amnezia Web Panel";
|
||
$("#awg-test-out").classList.add("hidden");
|
||
$("#awg-discover-box").classList.add("hidden");
|
||
$("#awg-discover-box").innerHTML = "";
|
||
}
|
||
|
||
function showAwgForm(server) {
|
||
const form = $("#form-awg");
|
||
form.classList.remove("hidden");
|
||
$("#awg-test-out").classList.add("hidden");
|
||
$("#awg-discover-box").classList.add("hidden");
|
||
if (server) {
|
||
$("#awg-id").value = String(server.id);
|
||
$("#awg-name").value = server.name || "";
|
||
$("#awg-api-url").value = server.api_url || "https://panel.example.com";
|
||
$("#awg-api-token").value = "";
|
||
$("#awg-api-token-hint").textContent = server.api_token_set
|
||
? "Токен сохранён — оставьте пустым, чтобы не менять"
|
||
: "Создайте токен в панели Amnezia Web Panel";
|
||
$("#awg-panel-server-id").value = server.panel_server_id ?? 0;
|
||
$("#awg-protocol").value = server.protocol || "awg2";
|
||
$("#awg-enabled").checked = !!server.enabled;
|
||
$("#awg-verify-ssl").checked = server.verify_ssl !== false;
|
||
} else {
|
||
form.reset();
|
||
$("#awg-id").value = "";
|
||
$("#awg-api-url").value = "https://panel.example.com";
|
||
$("#awg-protocol").value = "awg2";
|
||
$("#awg-enabled").checked = true;
|
||
$("#awg-verify-ssl").checked = true;
|
||
$("#awg-api-token-hint").textContent = "Создайте токен в панели Amnezia Web Panel";
|
||
}
|
||
form.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
|
||
function renderAwgServers() {
|
||
const box = $("#awg-servers-list");
|
||
if (!box) return;
|
||
if (!awgServers.length) {
|
||
box.innerHTML = `
|
||
<div class="admin-empty">
|
||
<p class="empty-title">Серверов AWG пока нет</p>
|
||
<p class="muted small">Добавьте панель panel5 · API Token · выберите ноду с awg2</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
box.innerHTML = awgServers
|
||
.map((s) => {
|
||
const statusBadge =
|
||
s.last_check_ok === true
|
||
? '<span class="xui-badge ok">online</span>'
|
||
: s.last_check_ok === false
|
||
? '<span class="xui-badge bad">fail</span>'
|
||
: "";
|
||
const enBadge = s.enabled
|
||
? '<span class="xui-badge">on</span>'
|
||
: '<span class="xui-badge off">off</span>';
|
||
const check = s.last_check_msg
|
||
? `<br/>Проверка: ${escapeHtml(s.last_check_msg)}`
|
||
: "";
|
||
return `
|
||
<div class="xui-server-row" data-awg-id="${s.id}">
|
||
<div class="xui-server-main">
|
||
<div class="xui-server-title">
|
||
${escapeHtml(s.name)}
|
||
${enBadge}
|
||
<span class="xui-badge">${escapeHtml(s.protocol || "awg2")}</span>
|
||
${statusBadge}
|
||
</div>
|
||
<div class="xui-server-meta">
|
||
API: <code>${escapeHtml(s.api_url)}</code><br/>
|
||
panel server #${s.panel_server_id}${check}
|
||
</div>
|
||
</div>
|
||
<div class="xui-server-actions">
|
||
<button type="button" class="btn ghost small-btn" data-awg-act="test">Проверить</button>
|
||
<button type="button" class="btn ghost small-btn" data-awg-act="edit">Изменить</button>
|
||
<button type="button" class="btn ghost small-btn" data-awg-act="delete">Удалить</button>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
async function loadAwgServers() {
|
||
const data = await api("/api/admin/awg/servers");
|
||
awgServers = data.servers || [];
|
||
renderAwgServers();
|
||
}
|
||
|
||
async function loadAwgSettings() {
|
||
const data = await api("/api/admin/awg/settings");
|
||
applyAwgSaleUi(data);
|
||
}
|
||
|
||
function applyAwgSaleUi(data) {
|
||
const enabled = !!data?.enabled;
|
||
const price = data?.price ?? 0;
|
||
const priceLabel = data?.price_label || (price > 0 ? `${price} ₽` : "бесплатно");
|
||
const toggle = $("#awg-purchase-enabled");
|
||
const priceInput = $("#awg-config-price");
|
||
const status = $("#awg-sale-status");
|
||
const bar = $("#awg-sale-bar");
|
||
if (toggle) toggle.checked = enabled;
|
||
if (priceInput) priceInput.value = price;
|
||
if (status) {
|
||
status.textContent = enabled
|
||
? `Включена · ${priceLabel} · до ${data?.max_configs || 10} конфигов`
|
||
: "Выключена — пользователи не видят блок";
|
||
}
|
||
bar?.classList.toggle("is-off", !enabled);
|
||
}
|
||
|
||
async function saveAwgSettings() {
|
||
const data = await api("/api/admin/awg/settings", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
enabled: $("#awg-purchase-enabled")?.checked || false,
|
||
price: Number($("#awg-config-price")?.value || 0),
|
||
}),
|
||
});
|
||
applyAwgSaleUi(data);
|
||
toast(
|
||
data.enabled
|
||
? `Покупка AWG включена · ${data.price_label}`
|
||
: "Покупка AWG выключена"
|
||
);
|
||
return data;
|
||
}
|
||
|
||
// ─── Тарифы ────────────────────────────────────────────────────────────
|
||
|
||
let adminTariffs = [];
|
||
|
||
function hideTariffForm() {
|
||
const form = $("#form-tariff");
|
||
if (!form) return;
|
||
form.classList.add("hidden");
|
||
form.reset();
|
||
$("#tariff-edit-id").value = "";
|
||
$("#tariff-id").disabled = false;
|
||
}
|
||
|
||
function showTariffForm(tariff) {
|
||
const form = $("#form-tariff");
|
||
if (!form) return;
|
||
form.classList.remove("hidden");
|
||
if (tariff) {
|
||
$("#tariff-edit-id").value = tariff.id;
|
||
$("#tariff-id").value = tariff.id;
|
||
$("#tariff-id").disabled = true;
|
||
$("#tariff-title").value = tariff.title || "";
|
||
$("#tariff-price").value = tariff.price ?? 0;
|
||
$("#tariff-days").value = tariff.days ?? 30;
|
||
$("#tariff-traffic").value = tariff.traffic_gb ?? 1000;
|
||
$("#tariff-devices").value = tariff.device_limit ?? 10;
|
||
$("#tariff-squads").value = (tariff.squad_uuids || []).join(", ");
|
||
$("#tariff-description").value = tariff.description || "";
|
||
} else {
|
||
form.reset();
|
||
$("#tariff-edit-id").value = "";
|
||
$("#tariff-id").disabled = false;
|
||
const first = adminTariffs[0];
|
||
if (first?.squad_uuids?.length) {
|
||
$("#tariff-squads").value = first.squad_uuids.join(", ");
|
||
}
|
||
$("#tariff-traffic").value = 1000;
|
||
$("#tariff-devices").value = 10;
|
||
$("#tariff-days").value = 30;
|
||
$("#tariff-price").value = 199;
|
||
}
|
||
form.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
|
||
function renderAdminTariffs() {
|
||
const box = $("#tariffs-admin-list");
|
||
if (!box) return;
|
||
if (!adminTariffs.length) {
|
||
box.innerHTML = `
|
||
<div class="admin-empty">
|
||
<p class="empty-title">Тарифов пока нет</p>
|
||
<p class="muted small">Добавьте первый тариф для продажи подписок</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
box.innerHTML = adminTariffs
|
||
.map(
|
||
(t) => `
|
||
<div class="xui-server-row" data-tariff-id="${escapeAttr(t.id)}">
|
||
<div class="xui-server-main">
|
||
<div class="xui-server-title">
|
||
${escapeHtml(t.title)}
|
||
<span class="xui-badge">${escapeHtml(t.price_label || `${t.price} ₽`)}</span>
|
||
<span class="xui-badge">${t.days} дн.</span>
|
||
</div>
|
||
<div class="xui-server-meta">
|
||
ID: <code>${escapeHtml(t.id)}</code> ·
|
||
${t.traffic_gb || 0} ГБ · ${t.device_limit || 0} устр.
|
||
${t.description ? `<br/>${escapeHtml(t.description)}` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="xui-server-actions">
|
||
<button type="button" class="btn ghost small-btn" data-tariff-act="edit">Изменить</button>
|
||
<button type="button" class="btn ghost small-btn" data-tariff-act="delete">Удалить</button>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
async function loadAdminTariffs() {
|
||
const data = await api("/api/admin/tariffs");
|
||
adminTariffs = data.tariffs || [];
|
||
renderAdminTariffs();
|
||
}
|
||
|
||
function collectTariffBody() {
|
||
return {
|
||
id: ($("#tariff-id").value || "").trim(),
|
||
title: ($("#tariff-title").value || "").trim(),
|
||
price: Number($("#tariff-price").value || 0),
|
||
days: Number($("#tariff-days").value || 0),
|
||
traffic_gb: Number($("#tariff-traffic").value || 0),
|
||
device_limit: Number($("#tariff-devices").value || 1),
|
||
squad_uuids: ($("#tariff-squads").value || "")
|
||
.split(/[,;]+/)
|
||
.map((x) => x.trim())
|
||
.filter(Boolean),
|
||
description: ($("#tariff-description").value || "").trim(),
|
||
};
|
||
}
|
||
|
||
// ─── Платежи / .env ────────────────────────────────────────────────────
|
||
|
||
const paymentSecretClears = {
|
||
"pay-crypto-token": false,
|
||
"pay-heleket-key": false,
|
||
"pay-lava-secret": false,
|
||
"pay-lava-additional": false,
|
||
"pay-anypay-key": false,
|
||
"pay-digi-key": false,
|
||
"smtp-password": false,
|
||
};
|
||
|
||
function applyPaymentsUi(p) {
|
||
if (!p) return;
|
||
const hint = $("#payments-env-hint");
|
||
if (hint) {
|
||
hint.textContent = p.env_writable
|
||
? `Файл: ${p.env_path} · запись доступна`
|
||
: `Файл: ${p.env_path || "—"} · запись недоступна (проверьте mount .env:rw)`;
|
||
}
|
||
|
||
$("#pay-crypto-token").value = "";
|
||
$("#pay-crypto-token").placeholder = p.crypto?.token_set
|
||
? p.crypto.token_hint || "сохранён"
|
||
: "токен приложения";
|
||
$("#pay-crypto-hint").textContent = p.crypto?.token_set
|
||
? `Сохранён: ${p.crypto.token_hint}`
|
||
: "не задан";
|
||
$("#pay-crypto-testnet").checked = !!p.crypto?.testnet;
|
||
$("#pay-crypto-testnet-bar")?.classList.toggle("is-off", !p.crypto?.testnet);
|
||
|
||
$("#pay-heleket-merchant").value = p.heleket?.merchant_id || "";
|
||
$("#pay-heleket-currency").value = p.heleket?.currency || "RUB";
|
||
$("#pay-heleket-callback").value = p.heleket?.callback_url || "";
|
||
$("#pay-heleket-key").value = "";
|
||
$("#pay-heleket-key").placeholder = p.heleket?.api_key_set
|
||
? p.heleket.api_key_hint || "сохранён"
|
||
: "API key";
|
||
$("#pay-heleket-hint").textContent = p.heleket?.api_key_set
|
||
? `Сохранён: ${p.heleket.api_key_hint}`
|
||
: "не задан";
|
||
|
||
$("#pay-lava-shop").value = p.lava?.shop_id || "";
|
||
$("#pay-lava-expire").value = p.lava?.expire_minutes ?? 60;
|
||
$("#pay-lava-hook").value = p.lava?.hook_url || "";
|
||
$("#pay-lava-success").value = p.lava?.success_url || "";
|
||
$("#pay-lava-fail").value = p.lava?.fail_url || "";
|
||
$("#pay-lava-secret").value = "";
|
||
$("#pay-lava-secret").placeholder = p.lava?.secret_key_set
|
||
? p.lava.secret_key_hint || "сохранён"
|
||
: "secret key";
|
||
$("#pay-lava-secret-hint").textContent = p.lava?.secret_key_set
|
||
? `Сохранён: ${p.lava.secret_key_hint}`
|
||
: "не задан";
|
||
$("#pay-lava-additional").value = "";
|
||
$("#pay-lava-additional").placeholder = p.lava?.additional_key_set
|
||
? p.lava.additional_key_hint || "сохранён"
|
||
: "additional key";
|
||
$("#pay-lava-additional-hint").textContent = p.lava?.additional_key_set
|
||
? `Сохранён: ${p.lava.additional_key_hint}`
|
||
: "не задан";
|
||
|
||
$("#pay-anypay-api-id").value = p.anypay?.api_id || "";
|
||
$("#pay-anypay-project").value = p.anypay?.project_id || "";
|
||
$("#pay-anypay-currency").value = p.anypay?.currency || "RUB";
|
||
$("#pay-anypay-method").value = p.anypay?.method || "";
|
||
$("#pay-anypay-success").value = p.anypay?.success_url || "";
|
||
$("#pay-anypay-fail").value = p.anypay?.fail_url || "";
|
||
$("#pay-anypay-email").value = p.anypay?.email || "";
|
||
$("#pay-anypay-key").value = "";
|
||
$("#pay-anypay-key").placeholder = p.anypay?.api_key_set
|
||
? p.anypay.api_key_hint || "сохранён"
|
||
: "API key";
|
||
$("#pay-anypay-hint").textContent = p.anypay?.api_key_set
|
||
? `Сохранён: ${p.anypay.api_key_hint}`
|
||
: "не задан";
|
||
$("#pay-anypay-check-ip").checked = !!p.anypay?.check_ip;
|
||
$("#pay-anypay-ip-bar")?.classList.toggle("is-off", !p.anypay?.check_ip);
|
||
const notify = $("#pay-anypay-notify");
|
||
if (notify && p.anypay?.notify_url) notify.textContent = p.anypay.notify_url;
|
||
|
||
$("#pay-digi-seller").value = p.digiseller?.seller_id || "";
|
||
$("#pay-digi-credit").value = p.digiseller?.credit_from || "cnt_goods";
|
||
$("#pay-digi-products").value = p.digiseller?.product_ids || "";
|
||
$("#pay-digi-payurl").value = p.digiseller?.pay_url || "";
|
||
$("#pay-digi-callback").value = p.digiseller?.callback_url || "";
|
||
$("#pay-digi-key").value = "";
|
||
$("#pay-digi-key").placeholder = p.digiseller?.api_key_set
|
||
? p.digiseller.api_key_hint || "сохранён"
|
||
: "API key";
|
||
$("#pay-digi-hint").textContent = p.digiseller?.api_key_set
|
||
? `Сохранён: ${p.digiseller.api_key_hint}`
|
||
: "не задан";
|
||
|
||
$("#pay-stars-enabled").checked = p.stars?.enabled !== false;
|
||
$("#pay-stars-rate").value = p.stars?.rub_per_star ?? 1.5;
|
||
$("#pay-stars-bar")?.classList.toggle("is-off", p.stars?.enabled === false);
|
||
$("#pay-topup-url").value = p.topup_code_pay_url || "";
|
||
|
||
Object.keys(paymentSecretClears).forEach((k) => {
|
||
paymentSecretClears[k] = false;
|
||
});
|
||
}
|
||
|
||
async function loadPayments() {
|
||
const data = await api("/api/admin/payments");
|
||
applyPaymentsUi(data.payments || {});
|
||
}
|
||
|
||
function applySmtpUi(s) {
|
||
if (!s) return;
|
||
const hint = $("#smtp-env-hint");
|
||
if (hint) {
|
||
const status = s.configured ? "настроен" : "не настроен";
|
||
hint.textContent = s.env_writable
|
||
? `Файл: ${s.env_path} · запись доступна · SMTP ${status}`
|
||
: `Файл: ${s.env_path || "—"} · запись недоступна · SMTP ${status}`;
|
||
}
|
||
$("#smtp-host").value = s.host || "";
|
||
$("#smtp-port").value = s.port ?? 587;
|
||
$("#smtp-user").value = s.user || "";
|
||
$("#smtp-from").value = s.from_email || "";
|
||
$("#smtp-tls").checked = s.tls !== false;
|
||
$("#smtp-ssl").checked = !!s.ssl;
|
||
$("#smtp-tls-bar")?.classList.toggle("is-off", s.tls === false);
|
||
$("#smtp-ssl-bar")?.classList.toggle("is-off", !s.ssl);
|
||
$("#smtp-password").value = "";
|
||
$("#smtp-password").placeholder = s.password_set
|
||
? s.password_hint || "сохранён"
|
||
: "пароль SMTP";
|
||
$("#smtp-password-hint").textContent = s.password_set
|
||
? `Сохранён: ${s.password_hint}`
|
||
: "не задан";
|
||
paymentSecretClears["smtp-password"] = false;
|
||
}
|
||
|
||
async function loadSmtp() {
|
||
const data = await api("/api/admin/smtp");
|
||
applySmtpUi(data.smtp || {});
|
||
}
|
||
|
||
function secretPayload(inputId, fieldName, body) {
|
||
if (paymentSecretClears[inputId]) {
|
||
body[fieldName] = "__clear__";
|
||
return;
|
||
}
|
||
const v = ($(`#${inputId}`)?.value || "").trim();
|
||
if (v) body[fieldName] = v;
|
||
}
|
||
|
||
function collectPaymentsBody() {
|
||
const body = {
|
||
crypto_pay_testnet: !!$("#pay-crypto-testnet")?.checked,
|
||
heleket_merchant_id: ($("#pay-heleket-merchant").value || "").trim(),
|
||
heleket_currency: ($("#pay-heleket-currency").value || "").trim(),
|
||
heleket_callback_url: ($("#pay-heleket-callback").value || "").trim(),
|
||
lava_shop_id: ($("#pay-lava-shop").value || "").trim(),
|
||
lava_expire_minutes: Number($("#pay-lava-expire").value || 60),
|
||
lava_hook_url: ($("#pay-lava-hook").value || "").trim(),
|
||
lava_success_url: ($("#pay-lava-success").value || "").trim(),
|
||
lava_fail_url: ($("#pay-lava-fail").value || "").trim(),
|
||
anypay_api_id: ($("#pay-anypay-api-id").value || "").trim(),
|
||
anypay_project_id: ($("#pay-anypay-project").value || "").trim(),
|
||
anypay_currency: ($("#pay-anypay-currency").value || "").trim(),
|
||
anypay_method: ($("#pay-anypay-method").value || "").trim(),
|
||
anypay_success_url: ($("#pay-anypay-success").value || "").trim(),
|
||
anypay_fail_url: ($("#pay-anypay-fail").value || "").trim(),
|
||
anypay_email: ($("#pay-anypay-email").value || "").trim(),
|
||
anypay_check_ip: !!$("#pay-anypay-check-ip")?.checked,
|
||
digiseller_seller_id: ($("#pay-digi-seller").value || "").trim(),
|
||
digiseller_credit_from: ($("#pay-digi-credit").value || "cnt_goods").trim(),
|
||
digiseller_product_ids: ($("#pay-digi-products").value || "").trim(),
|
||
digiseller_pay_url: ($("#pay-digi-payurl").value || "").trim(),
|
||
digiseller_callback_url: ($("#pay-digi-callback").value || "").trim(),
|
||
stars_enabled: !!$("#pay-stars-enabled")?.checked,
|
||
stars_rub_per_star: Number($("#pay-stars-rate").value || 1.5),
|
||
topup_code_pay_url: ($("#pay-topup-url").value || "").trim(),
|
||
};
|
||
secretPayload("pay-crypto-token", "crypto_bot_token", body);
|
||
secretPayload("pay-heleket-key", "heleket_api_key", body);
|
||
secretPayload("pay-lava-secret", "lava_secret_key", body);
|
||
secretPayload("pay-lava-additional", "lava_additional_key", body);
|
||
secretPayload("pay-anypay-key", "anypay_api_key", body);
|
||
secretPayload("pay-digi-key", "digiseller_api_key", body);
|
||
return body;
|
||
}
|
||
|
||
function collectAwgFormBody(requireToken) {
|
||
const id = ($("#awg-id").value || "").trim();
|
||
const apiToken = ($("#awg-api-token").value || "").trim();
|
||
const body = {
|
||
name: ($("#awg-name").value || "").trim(),
|
||
api_url: ($("#awg-api-url").value || "").trim(),
|
||
panel_server_id: Number($("#awg-panel-server-id").value || 0),
|
||
protocol: ($("#awg-protocol").value || "awg2").trim(),
|
||
enabled: $("#awg-enabled").checked,
|
||
verify_ssl: $("#awg-verify-ssl").checked,
|
||
};
|
||
if (apiToken) body.api_token = apiToken;
|
||
if (!body.name || !body.api_url) {
|
||
throw new Error("Заполните название и API URL");
|
||
}
|
||
if (requireToken && !apiToken) {
|
||
throw new Error("Укажите API Token");
|
||
}
|
||
return { id, body };
|
||
}
|
||
|
||
function collectXuiFormBody(requireCreds) {
|
||
const id = ($("#xui-id").value || "").trim();
|
||
const password = ($("#xui-password").value || "").trim();
|
||
const apiKey = ($("#xui-api-key").value || "").trim();
|
||
const username = ($("#xui-username").value || "").trim();
|
||
const body = {
|
||
name: ($("#xui-name").value || "").trim(),
|
||
api_url: ($("#xui-api-url").value || "").trim(),
|
||
username,
|
||
subscription_url: ($("#xui-subscription-url").value || "").trim(),
|
||
inbound_id: $("#xui-inbound-id").value || null,
|
||
inbound_remark: ($("#xui-inbound-remark").value || "").trim(),
|
||
enabled: $("#xui-enabled").checked,
|
||
verify_ssl: $("#xui-verify-ssl").checked,
|
||
};
|
||
if (password) body.password = password;
|
||
if (apiKey) body.api_key = apiKey;
|
||
if (!body.name || !body.api_url) {
|
||
throw new Error("Заполните название и API URL");
|
||
}
|
||
if (requireCreds) {
|
||
const hasToken = !!apiKey;
|
||
const hasLogin = !!(username && password);
|
||
if (!hasToken && !hasLogin) {
|
||
throw new Error("Укажите API Token или логин+пароль");
|
||
}
|
||
}
|
||
return { id, body };
|
||
}
|
||
|
||
async function bootstrap() {
|
||
try {
|
||
const me = await api("/api/admin/me");
|
||
if (!me.enabled) {
|
||
const err = $("#admin-login-error");
|
||
err.textContent = "Админка выключена — задайте WEB_ADMIN_LOGIN и WEB_ADMIN_PASSWORD в .env";
|
||
err.classList.remove("hidden");
|
||
return;
|
||
}
|
||
if (me.authenticated) {
|
||
showPanel(true);
|
||
await loadStats();
|
||
await loadCodes();
|
||
await loadUsers();
|
||
await loadTickets();
|
||
await loadAiSettings();
|
||
await loadXuiServers().catch(() => {
|
||
$("#xui-servers-list").innerHTML =
|
||
'<p class="muted small">Не удалось загрузить серверы 3x-ui</p>';
|
||
});
|
||
await loadWgSettings().catch(() => {});
|
||
await loadAwgServers().catch(() => {
|
||
$("#awg-servers-list").innerHTML =
|
||
'<p class="muted small">Не удалось загрузить серверы AWG</p>';
|
||
});
|
||
await loadAwgSettings().catch(() => {});
|
||
await loadAdminTariffs().catch(() => {
|
||
$("#tariffs-admin-list").innerHTML =
|
||
'<p class="muted small">Не удалось загрузить тарифы</p>';
|
||
});
|
||
await loadPayments().catch(() => {});
|
||
await loadSmtp().catch(() => {});
|
||
await loadGrowth().catch(() => {});
|
||
await loadJournal().catch(() => {});
|
||
await loadBackups().catch(() => {});
|
||
} else {
|
||
showPanel(false);
|
||
}
|
||
} catch {
|
||
showPanel(false);
|
||
}
|
||
}
|
||
|
||
$("#form-admin-login").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const err = $("#admin-login-error");
|
||
err.classList.add("hidden");
|
||
try {
|
||
await api("/api/admin/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
login: fd.get("login"),
|
||
password: fd.get("password"),
|
||
}),
|
||
});
|
||
showPanel(true);
|
||
await loadStats();
|
||
await loadCodes();
|
||
await loadUsers();
|
||
await loadTickets();
|
||
await loadAiSettings();
|
||
await loadXuiServers().catch(() => {});
|
||
await loadWgSettings().catch(() => {});
|
||
await loadAwgServers().catch(() => {});
|
||
await loadAwgSettings().catch(() => {});
|
||
await loadAdminTariffs().catch(() => {});
|
||
await loadPayments().catch(() => {});
|
||
await loadSmtp().catch(() => {});
|
||
toast("Добро пожаловать");
|
||
} catch (ex) {
|
||
err.textContent = ex.message || "Ошибка входа";
|
||
err.classList.remove("hidden");
|
||
}
|
||
});
|
||
|
||
$("#btn-admin-logout").addEventListener("click", async () => {
|
||
try {
|
||
await api("/api/admin/logout", { method: "POST", body: "{}" });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setToken("");
|
||
showPanel(false);
|
||
$("#generated-wrap").classList.add("hidden");
|
||
});
|
||
|
||
$("#form-generate").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
const data = await api("/api/admin/codes", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
amount: Number(fd.get("amount")),
|
||
count: Number(fd.get("count")),
|
||
note: fd.get("note") || "web_admin",
|
||
}),
|
||
});
|
||
const codes = data.codes || [];
|
||
lastGenerated = codes.join("\n");
|
||
const box = $("#generated-codes");
|
||
box.textContent = lastGenerated;
|
||
$("#generated-wrap").classList.remove("hidden");
|
||
toast(`Создано кодов: ${data.inserted || 0}`);
|
||
await loadStats();
|
||
await loadCodes();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-copy-codes").addEventListener("click", async () => {
|
||
if (await copyText(lastGenerated)) toast("Все коды скопированы");
|
||
else toast("Нечего копировать");
|
||
});
|
||
|
||
$("#btn-refresh-codes").addEventListener("click", () =>
|
||
loadCodes().catch((ex) => toast(ex.message))
|
||
);
|
||
$("#filter-status").addEventListener("change", () =>
|
||
loadCodes().catch((ex) => toast(ex.message))
|
||
);
|
||
|
||
bindCodeCopy();
|
||
|
||
$("#users-tbody").addEventListener("click", async (e) => {
|
||
const banBtn = e.target.closest("[data-ban-id]");
|
||
if (banBtn) {
|
||
try {
|
||
await api("/api/admin/users/ban", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
telegram_id: Number(banBtn.dataset.banId),
|
||
banned: banBtn.dataset.ban === "1",
|
||
}),
|
||
});
|
||
toast(banBtn.dataset.ban === "1" ? "Пользователь забанен" : "Бан снят");
|
||
await loadUsers();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
return;
|
||
}
|
||
const delBtn = e.target.closest("[data-del-user]");
|
||
if (delBtn) {
|
||
const tid = Number(delBtn.dataset.delUser);
|
||
const name = delBtn.dataset.delName || tid;
|
||
const ok = confirm(
|
||
`Удалить пользователя «${name}» (ID ${tid}) навсегда?\n\nБудут удалены баланс, сессии, тикеты, конфиги WG/AWG и история покупок в боте. Это нельзя отменить.`
|
||
);
|
||
if (!ok) return;
|
||
const ok2 = confirm(`Точно удалить ID ${tid}?`);
|
||
if (!ok2) return;
|
||
try {
|
||
const data = await api("/api/admin/users/delete", {
|
||
method: "POST",
|
||
body: JSON.stringify({ telegram_id: tid, confirm: true }),
|
||
});
|
||
toast(data.message || "Удалён");
|
||
await loadUsers();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка удаления");
|
||
}
|
||
return;
|
||
}
|
||
const btn = e.target.closest("[data-credit-id]");
|
||
if (!btn) return;
|
||
openCreditDialog(btn.dataset.creditId, btn.dataset.creditName || btn.dataset.creditId);
|
||
});
|
||
|
||
async function loadGrowth() {
|
||
try {
|
||
const data = await api("/api/admin/growth-settings");
|
||
const s = data.settings || {};
|
||
$("#growth-ref-referrer").value = s.referral_bonus_referrer || "50";
|
||
$("#growth-ref-referee").value = s.referral_bonus_referee || "50";
|
||
$("#growth-trial-days").value = s.trial_days || "3";
|
||
$("#growth-trial-tariff").value = s.trial_tariff_id || "";
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
const data = await api("/api/admin/promos");
|
||
const box = $("#promos-list");
|
||
if (!box) return;
|
||
const items = data.promos || [];
|
||
box.innerHTML = items.length
|
||
? items
|
||
.map(
|
||
(p) =>
|
||
`<div>${p.code} · ${p.kind}=${p.value} · used ${p.used_count}${p.max_uses != null ? "/" + p.max_uses : ""} · ${p.active ? "ON" : "OFF"} <button type="button" class="btn ghost small-btn" data-promo-toggle="${p.id}" data-active="${p.active ? "0" : "1"}">${p.active ? "Выкл" : "Вкл"}</button></div>`
|
||
)
|
||
.join("")
|
||
: "Нет промокодов";
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function loadJournal() {
|
||
const box = $("#payments-journal");
|
||
if (!box) return;
|
||
try {
|
||
const data = await api("/api/admin/payments/journal");
|
||
const items = data.events || [];
|
||
box.innerHTML = items.length
|
||
? items
|
||
.slice(0, 30)
|
||
.map(
|
||
(e) =>
|
||
`<div>${e.created_at} · ${e.provider}/${e.event_type} · tg=${e.telegram_id || "—"} · ${e.amount ?? "—"} · ${e.status}</div>`
|
||
)
|
||
.join("")
|
||
: "Пусто";
|
||
} catch (ex) {
|
||
box.textContent = ex.message || "Ошибка";
|
||
}
|
||
}
|
||
|
||
async function loadBackups() {
|
||
const box = $("#backups-list");
|
||
if (!box) return;
|
||
try {
|
||
const data = await api("/api/admin/backups");
|
||
const items = data.backups || [];
|
||
if (!items.length) {
|
||
box.innerHTML = '<p class="muted small">Нет бэкапов — создайте первый</p>';
|
||
return;
|
||
}
|
||
box.innerHTML = items
|
||
.map((b) => {
|
||
const kb = (b.size / 1024).toFixed(1);
|
||
const when = (b.mtime || "").replace("T", " ").slice(0, 19);
|
||
const name = escapeAttr(b.name);
|
||
return `<div class="backup-row" data-backup-name="${name}">
|
||
<div class="backup-row-main">
|
||
<strong class="backup-name">${escapeHtml(b.name)}</strong>
|
||
<span class="muted small">${kb} KB · ${escapeHtml(when)} UTC</span>
|
||
</div>
|
||
<div class="backup-row-actions">
|
||
<button type="button" class="btn ghost small-btn" data-backup-act="download" title="Скачать">
|
||
<i class="fa-solid fa-download"></i>
|
||
</button>
|
||
<button type="button" class="btn ghost small-btn" data-backup-act="restore" title="Восстановить">
|
||
<i class="fa-solid fa-rotate-left"></i>
|
||
</button>
|
||
<button type="button" class="btn ghost small-btn" data-backup-act="delete" title="Удалить файл бэкапа">
|
||
<i class="fa-solid fa-trash"></i> Удалить
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
} catch (ex) {
|
||
box.textContent = ex.message || "Ошибка";
|
||
}
|
||
}
|
||
|
||
async function downloadBackup(name) {
|
||
const token = getToken();
|
||
const res = await fetch(`/api/admin/backups/${encodeURIComponent(name)}/download`, {
|
||
credentials: "same-origin",
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || `HTTP ${res.status}`);
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = name;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function restoreBackupByName(name) {
|
||
const ok = confirm(
|
||
`Восстановить базу из «${name}»?\n\nТекущие данные будут заменены. Перед этим создастся страховочный бэкап.`
|
||
);
|
||
if (!ok) return;
|
||
const status = $("#backup-status");
|
||
if (status) status.textContent = "Восстановление…";
|
||
try {
|
||
const data = await api("/api/admin/backups/restore", {
|
||
method: "POST",
|
||
body: JSON.stringify({ name, confirm: true }),
|
||
});
|
||
toast(data.message || "База восстановлена");
|
||
if (status) {
|
||
status.textContent = data.safety_backup
|
||
? `Готово. Страховочный бэкап: ${data.safety_backup}`
|
||
: "Готово.";
|
||
}
|
||
await loadBackups();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка восстановления");
|
||
if (status) status.textContent = ex.message || "Ошибка";
|
||
}
|
||
}
|
||
|
||
$("#form-growth")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await api("/api/admin/growth-settings", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
referral_bonus_referrer: $("#growth-ref-referrer").value,
|
||
referral_bonus_referee: $("#growth-ref-referee").value,
|
||
trial_days: $("#growth-trial-days").value,
|
||
trial_tariff_id: $("#growth-trial-tariff").value,
|
||
}),
|
||
});
|
||
toast("Настройки роста сохранены");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#form-promo")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
await api("/api/admin/promos", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
code: fd.get("code"),
|
||
kind: fd.get("kind"),
|
||
value: Number(fd.get("value")),
|
||
max_uses: fd.get("max_uses") || null,
|
||
}),
|
||
});
|
||
e.target.reset();
|
||
toast("Промокод создан");
|
||
await loadGrowth();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#promos-list")?.addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("[data-promo-toggle]");
|
||
if (!btn) return;
|
||
try {
|
||
await api(`/api/admin/promos/${btn.dataset.promoToggle}/toggle`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ active: btn.dataset.active === "1" }),
|
||
});
|
||
await loadGrowth();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-refresh-journal")?.addEventListener("click", () => loadJournal());
|
||
$("#btn-backup-refresh")?.addEventListener("click", () => loadBackups());
|
||
$("#btn-backup-run")?.addEventListener("click", async () => {
|
||
const btn = $("#btn-backup-run");
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const data = await api("/api/admin/backups", { method: "POST", body: "{}" });
|
||
toast(data.file ? `Бэкап: ${data.file}` : "Бэкап создан");
|
||
await loadBackups();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка бэкапа");
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
$("#backups-list")?.addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("[data-backup-act]");
|
||
if (!btn) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const row = btn.closest("[data-backup-name]");
|
||
const name = row?.getAttribute("data-backup-name") || "";
|
||
const act = btn.getAttribute("data-backup-act") || "";
|
||
if (!name) {
|
||
toast("Не удалось определить файл бэкапа");
|
||
return;
|
||
}
|
||
const status = $("#backup-status");
|
||
|
||
if (act === "download") {
|
||
try {
|
||
if (status) status.textContent = `Скачивание ${name}…`;
|
||
await downloadBackup(name);
|
||
toast("Скачивание начато");
|
||
if (status) status.textContent = "";
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка скачивания");
|
||
if (status) status.textContent = ex.message || "Ошибка скачивания";
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (act === "restore") {
|
||
await restoreBackupByName(name);
|
||
return;
|
||
}
|
||
|
||
if (act === "delete") {
|
||
const ok = window.confirm(`Удалить файл бэкапа «${name}» с сервера?\nТекущая база данных не изменится.`);
|
||
if (!ok) return;
|
||
btn.disabled = true;
|
||
if (status) status.textContent = `Удаление ${name}…`;
|
||
try {
|
||
const data = await api("/api/admin/backups/delete", {
|
||
method: "POST",
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
toast(data.message || "Бэкап удалён");
|
||
if (status) status.textContent = data.message || "Удалено";
|
||
await loadBackups();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка удаления");
|
||
if (status) status.textContent = ex.message || "Ошибка удаления";
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
});
|
||
|
||
$("#form-backup-restore")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fileInput = $("#backup-file");
|
||
const confirmEl = $("#backup-confirm");
|
||
const status = $("#backup-status");
|
||
const file = fileInput?.files?.[0];
|
||
if (!file) {
|
||
toast("Выберите файл");
|
||
return;
|
||
}
|
||
if (!confirmEl?.checked) {
|
||
toast("Нужно подтверждение");
|
||
return;
|
||
}
|
||
if (!confirm(`Восстановить базу из файла «${file.name}»?\nДанные будут перезаписаны.`)) {
|
||
return;
|
||
}
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
fd.append("confirm", "true");
|
||
const token = getToken();
|
||
if (status) status.textContent = "Загрузка и восстановление…";
|
||
const btn = $("#btn-backup-restore");
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const res = await fetch("/api/admin/backups/restore", {
|
||
method: "POST",
|
||
body: fd,
|
||
credentials: "same-origin",
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || data.ok === false) {
|
||
throw new Error(data.error || `HTTP ${res.status}`);
|
||
}
|
||
toast(data.message || "База восстановлена");
|
||
if (status) {
|
||
status.textContent = data.safety_backup
|
||
? `Готово. Страховочный бэкап: ${data.safety_backup}`
|
||
: "Готово.";
|
||
}
|
||
e.target.reset();
|
||
await loadBackups();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка импорта");
|
||
if (status) status.textContent = ex.message || "Ошибка";
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
$("#btn-credit-cancel").addEventListener("click", () => $("#dialog-credit").close());
|
||
|
||
$("#form-credit").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
const data = await api("/api/admin/users/balance", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
telegram_id: Number(fd.get("telegram_id")),
|
||
amount: Number(fd.get("amount")),
|
||
comment: fd.get("comment"),
|
||
}),
|
||
});
|
||
$("#dialog-credit").close();
|
||
toast(`Зачислено ${Math.round(data.amount)} ₽`);
|
||
await loadUsers();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-refresh-users").addEventListener("click", () =>
|
||
loadUsers().catch((ex) => toast(ex.message))
|
||
);
|
||
|
||
$("#users-search").addEventListener("input", (e) => {
|
||
usersQuery = e.target.value;
|
||
usersOffset = 0;
|
||
clearTimeout(searchDebounce);
|
||
searchDebounce = setTimeout(() => {
|
||
loadUsers().catch((err) => toast(err.message));
|
||
}, 350);
|
||
});
|
||
|
||
$("#btn-users-prev").addEventListener("click", () => {
|
||
usersOffset = Math.max(0, usersOffset - usersLimit);
|
||
loadUsers().catch((ex) => toast(ex.message));
|
||
});
|
||
|
||
$("#btn-users-next").addEventListener("click", () => {
|
||
if (usersOffset + usersLimit < usersTotal) {
|
||
usersOffset += usersLimit;
|
||
loadUsers().catch((ex) => toast(ex.message));
|
||
}
|
||
});
|
||
|
||
$("#admin-tickets-list").addEventListener("click", (e) => {
|
||
const row = e.target.closest(".admin-ticket-row");
|
||
if (!row) return;
|
||
openAdminTicket(row.dataset.id).catch((ex) => toast(ex.message));
|
||
});
|
||
|
||
$("#filter-tickets").addEventListener("change", () =>
|
||
loadTickets().catch((ex) => toast(ex.message))
|
||
);
|
||
$("#btn-refresh-tickets").addEventListener("click", () =>
|
||
loadTickets().catch((ex) => toast(ex.message))
|
||
);
|
||
|
||
$("#form-admin-ticket-reply").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
if (!activeAdminTicketId) return;
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
const data = await api(`/api/admin/tickets/${activeAdminTicketId}/messages`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ body: fd.get("body") }),
|
||
});
|
||
e.target.reset();
|
||
renderAdminTicket(data);
|
||
await loadTickets();
|
||
await loadStats();
|
||
toast("Ответ отправлен");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-admin-ticket-close").addEventListener("click", async () => {
|
||
if (!activeAdminTicketId) return;
|
||
try {
|
||
const data = await api(`/api/admin/tickets/${activeAdminTicketId}/close`, {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
renderAdminTicket(data);
|
||
await loadTickets();
|
||
await loadStats();
|
||
toast("Тикет закрыт");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-admin-ticket-reopen").addEventListener("click", async () => {
|
||
if (!activeAdminTicketId) return;
|
||
try {
|
||
const data = await api(`/api/admin/tickets/${activeAdminTicketId}/reopen`, {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
renderAdminTicket(data);
|
||
await loadTickets();
|
||
await loadStats();
|
||
toast("Тикет открыт");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#ai-models-pick").addEventListener("change", (e) => {
|
||
const input = e.target.closest('input[type="checkbox"]');
|
||
if (!input) return;
|
||
const id = input.value;
|
||
if (input.checked) {
|
||
if (!aiSelectedModels.includes(id)) aiSelectedModels.push(id);
|
||
} else {
|
||
aiSelectedModels = aiSelectedModels.filter((x) => x !== id);
|
||
}
|
||
renderAiModelSelect($("#ai-model").value || aiSelectedModels[0] || "");
|
||
renderAiModelsPick($("#ai-model-search").value || "");
|
||
});
|
||
|
||
$("#ai-model-search").addEventListener("input", (e) => {
|
||
renderAiModelsPick(e.target.value || "");
|
||
});
|
||
|
||
$("#btn-ai-load-models").addEventListener("click", async () => {
|
||
try {
|
||
const data = await api("/api/admin/ai/models");
|
||
aiCatalog = data.models || [];
|
||
renderAiModelsPick($("#ai-model-search").value || "");
|
||
toast(
|
||
data.source === "openrouter"
|
||
? `Загружено моделей: ${aiCatalog.length}`
|
||
: data.error || "Показаны модели по умолчанию"
|
||
);
|
||
} catch (ex) {
|
||
toast(ex.message || "Не удалось загрузить модели");
|
||
}
|
||
});
|
||
|
||
$("#btn-ai-add-model").addEventListener("click", () => {
|
||
const id = ($("#ai-model-custom").value || "").trim();
|
||
if (!id) {
|
||
toast("Введите id модели");
|
||
return;
|
||
}
|
||
if (!aiSelectedModels.includes(id)) aiSelectedModels.push(id);
|
||
$("#ai-model-custom").value = "";
|
||
renderAiModelSelect($("#ai-model").value || id);
|
||
renderAiModelsPick($("#ai-model-search").value || "");
|
||
toast(`Добавлено: ${id}`);
|
||
});
|
||
|
||
$("#btn-ai-clear-key").addEventListener("click", () => {
|
||
aiClearKey = true;
|
||
$("#ai-api-key").value = "";
|
||
$("#ai-api-key").placeholder = "ключ будет удалён после сохранения";
|
||
$("#ai-key-hint").textContent = "Ключ будет сброшен — нажмите «Сохранить»";
|
||
toast("Ключ помечен на удаление");
|
||
});
|
||
|
||
$("#form-ai").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const payload = {
|
||
enabled: $("#ai-enabled").checked,
|
||
auto_reply: $("#ai-auto-reply").checked,
|
||
model: $("#ai-model").value,
|
||
models: aiSelectedModels,
|
||
system_prompt: $("#ai-system-prompt").value,
|
||
};
|
||
const key = ($("#ai-api-key").value || "").trim();
|
||
if (aiClearKey) payload.api_key = "__clear__";
|
||
else if (key) payload.api_key = key;
|
||
try {
|
||
const data = await api("/api/admin/ai", {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
});
|
||
aiClearKey = false;
|
||
const ai = data.ai || {};
|
||
aiSelectedModels = ai.models || aiSelectedModels;
|
||
$("#ai-api-key").value = "";
|
||
$("#ai-api-key").placeholder = ai.api_key_set
|
||
? ai.api_key_masked || "ключ сохранён"
|
||
: "sk-or-v1-…";
|
||
$("#ai-key-hint").textContent = ai.api_key_set
|
||
? `Сохранён: ${ai.api_key_masked}`
|
||
: "Ключ не задан";
|
||
renderAiModelSelect(ai.model);
|
||
renderAiModelsPick($("#ai-model-search").value || "");
|
||
toast(ai.ready ? "ИИ сохранён и готов" : "Сохранено — проверьте ключ и модель");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка сохранения");
|
||
}
|
||
});
|
||
|
||
$("#btn-ai-test").addEventListener("click", async () => {
|
||
const out = $("#ai-test-out");
|
||
out.classList.remove("hidden");
|
||
out.textContent = "Запрос…";
|
||
try {
|
||
// сначала сохраним текущие настройки без ключа, если ключ уже есть
|
||
await api("/api/admin/ai", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
enabled: $("#ai-enabled").checked,
|
||
auto_reply: $("#ai-auto-reply").checked,
|
||
model: $("#ai-model").value,
|
||
models: aiSelectedModels,
|
||
system_prompt: $("#ai-system-prompt").value,
|
||
...(aiClearKey
|
||
? { api_key: "__clear__" }
|
||
: ($("#ai-api-key").value || "").trim()
|
||
? { api_key: ($("#ai-api-key").value || "").trim() }
|
||
: {}),
|
||
}),
|
||
});
|
||
aiClearKey = false;
|
||
const data = await api("/api/admin/ai/test", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
prompt: "Как подключить VPN на телефоне? Коротко.",
|
||
}),
|
||
});
|
||
out.textContent = [
|
||
`model: ${data.model || "—"}`,
|
||
`action: ${data.action || "—"}`,
|
||
"",
|
||
data.reply || data.raw || "",
|
||
].join("\n");
|
||
toast("Тест OK");
|
||
} catch (ex) {
|
||
out.textContent = ex.message || "Ошибка теста";
|
||
toast(ex.message || "Ошибка теста");
|
||
}
|
||
});
|
||
|
||
$("#btn-xui-new").addEventListener("click", () => showXuiForm(null));
|
||
$("#btn-xui-cancel").addEventListener("click", () => hideXuiForm());
|
||
$("#btn-refresh-xui").addEventListener("click", async () => {
|
||
try {
|
||
await loadXuiServers();
|
||
toast("Серверы обновлены");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
$("#xui-subscription-url").addEventListener("input", updateXuiSubPreview);
|
||
$("#xui-inbound-id").addEventListener("change", syncXuiInboundRemark);
|
||
|
||
$("#xui-servers-list").addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("[data-xui-act]");
|
||
if (!btn) return;
|
||
const row = btn.closest(".xui-server-row");
|
||
if (!row) return;
|
||
const id = Number(row.dataset.id);
|
||
const server = xuiServers.find((s) => s.id === id);
|
||
if (!server) return;
|
||
const act = btn.getAttribute("data-xui-act");
|
||
if (act === "edit") {
|
||
showXuiForm(server);
|
||
return;
|
||
}
|
||
if (act === "delete") {
|
||
if (!confirm(`Удалить сервер «${server.name}»?`)) return;
|
||
try {
|
||
await api(`/api/admin/xui/servers/${id}`, { method: "DELETE" });
|
||
hideXuiForm();
|
||
await loadXuiServers();
|
||
toast("Удалено");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка удаления");
|
||
}
|
||
return;
|
||
}
|
||
if (act === "test") {
|
||
try {
|
||
toast("Проверяю…");
|
||
const data = await api(`/api/admin/xui/servers/${id}/test`, {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
await loadXuiServers();
|
||
const wg = (data.wireguard || [])
|
||
.map((i) => `#${i.id} ${i.remark || ""} :${i.port}`)
|
||
.join(", ");
|
||
toast(data.message || (data.ok ? "OK" : "Fail"));
|
||
if (wg) console.info("WireGuard:", wg);
|
||
} catch (ex) {
|
||
await loadXuiServers().catch(() => {});
|
||
toast(ex.message || "Ошибка проверки");
|
||
}
|
||
}
|
||
});
|
||
|
||
$("#btn-xui-test").addEventListener("click", async () => {
|
||
const out = $("#xui-test-out");
|
||
out.classList.remove("hidden");
|
||
out.textContent = "Проверка…";
|
||
try {
|
||
const id = ($("#xui-id").value || "").trim();
|
||
const freshKey = ($("#xui-api-key").value || "").trim();
|
||
const freshPass = ($("#xui-password").value || "").trim();
|
||
let data;
|
||
if (id && !freshKey && !freshPass) {
|
||
data = await api(`/api/admin/xui/servers/${id}/test`, {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
} else {
|
||
const { body } = collectXuiFormBody(true);
|
||
data = await api("/api/admin/xui/test", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
fillXuiInboundSelect(data.wireguard || [], $("#xui-inbound-id").value);
|
||
syncXuiInboundRemark();
|
||
out.textContent = [
|
||
data.message || (data.ok ? "OK" : "Fail"),
|
||
data.version ? `version: ${data.version}` : "target panel: 3.5.0+",
|
||
`inbounds: ${data.inbounds_total ?? "—"}`,
|
||
`wireguard: ${(data.wireguard || []).length}`,
|
||
...(data.wireguard || []).map(
|
||
(i) => ` #${i.id} ${i.remark || "—"} :${i.port} clients=${i.clients_count}`
|
||
),
|
||
].join("\n");
|
||
toast(data.ok ? "Панель доступна" : "Ошибка панели");
|
||
if (id) await loadXuiServers().catch(() => {});
|
||
} catch (ex) {
|
||
out.textContent = ex.message || "Ошибка";
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-xui-load-inbounds").addEventListener("click", async () => {
|
||
try {
|
||
const id = ($("#xui-id").value || "").trim();
|
||
if (id && !($("#xui-api-key").value || "").trim() && !($("#xui-password").value || "").trim()) {
|
||
const data = await api(`/api/admin/xui/servers/${id}/inbounds`);
|
||
fillXuiInboundSelect(data.inbounds || [], $("#xui-inbound-id").value);
|
||
syncXuiInboundRemark();
|
||
toast(`WireGuard: ${(data.inbounds || []).length}`);
|
||
return;
|
||
}
|
||
const { body } = collectXuiFormBody(true);
|
||
const data = await api("/api/admin/xui/test", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
fillXuiInboundSelect(data.wireguard || [], $("#xui-inbound-id").value);
|
||
syncXuiInboundRemark();
|
||
toast(`WireGuard: ${(data.wireguard || []).length}`);
|
||
} catch (ex) {
|
||
toast(ex.message || "Не удалось загрузить inbound");
|
||
}
|
||
});
|
||
|
||
$("#form-xui").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const editingId = ($("#xui-id").value || "").trim();
|
||
const server = editingId
|
||
? xuiServers.find((s) => String(s.id) === editingId)
|
||
: null;
|
||
const needCreds = !editingId || !(server && (server.api_key_set || server.password_set));
|
||
const { id, body } = collectXuiFormBody(needCreds);
|
||
syncXuiInboundRemark();
|
||
body.inbound_remark = ($("#xui-inbound-remark").value || "").trim();
|
||
if (id) {
|
||
await api(`/api/admin/xui/servers/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
});
|
||
toast("Сервер обновлён");
|
||
} else {
|
||
await api("/api/admin/xui/servers", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
toast("Сервер добавлен");
|
||
}
|
||
hideXuiForm();
|
||
await loadXuiServers();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка сохранения");
|
||
}
|
||
});
|
||
|
||
$("#btn-wg-settings-save")?.addEventListener("click", async () => {
|
||
try {
|
||
await saveWgSettings();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#wg-purchase-enabled")?.addEventListener("change", async () => {
|
||
try {
|
||
await saveWgSettings();
|
||
} catch (ex) {
|
||
$("#wg-purchase-enabled").checked = !$("#wg-purchase-enabled").checked;
|
||
applyWgSaleUi({
|
||
enabled: $("#wg-purchase-enabled").checked,
|
||
price: Number($("#wg-config-price")?.value || 0),
|
||
});
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-tariff-new")?.addEventListener("click", () => showTariffForm(null));
|
||
$("#btn-tariff-cancel")?.addEventListener("click", () => hideTariffForm());
|
||
$("#btn-refresh-tariffs")?.addEventListener("click", async () => {
|
||
try {
|
||
await loadAdminTariffs();
|
||
toast("Тарифы обновлены");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#tariffs-admin-list")?.addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("[data-tariff-act]");
|
||
if (!btn) return;
|
||
const row = btn.closest("[data-tariff-id]");
|
||
if (!row) return;
|
||
const id = row.dataset.tariffId;
|
||
const tariff = adminTariffs.find((t) => t.id === id);
|
||
if (!tariff) return;
|
||
const act = btn.getAttribute("data-tariff-act");
|
||
if (act === "edit") {
|
||
showTariffForm(tariff);
|
||
return;
|
||
}
|
||
if (act === "delete") {
|
||
if (!confirm(`Удалить тариф «${tariff.title}»?`)) return;
|
||
try {
|
||
await api(`/api/admin/tariffs/${encodeURIComponent(id)}`, {
|
||
method: "DELETE",
|
||
});
|
||
hideTariffForm();
|
||
await loadAdminTariffs();
|
||
toast("Тариф удалён");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка удаления");
|
||
}
|
||
}
|
||
});
|
||
|
||
$("#form-tariff")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const editId = ($("#tariff-edit-id").value || "").trim();
|
||
const body = collectTariffBody();
|
||
if (editId) {
|
||
await api(`/api/admin/tariffs/${encodeURIComponent(editId)}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
});
|
||
toast("Тариф обновлён");
|
||
} else {
|
||
await api("/api/admin/tariffs", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
toast("Тариф создан");
|
||
}
|
||
hideTariffForm();
|
||
await loadAdminTariffs();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка сохранения");
|
||
}
|
||
});
|
||
|
||
$("#form-payments")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const data = await api("/api/admin/payments", {
|
||
method: "POST",
|
||
body: JSON.stringify(collectPaymentsBody()),
|
||
});
|
||
applyPaymentsUi(data.payments || {});
|
||
toast("Платежи сохранены в .env");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка сохранения");
|
||
}
|
||
});
|
||
|
||
$("#form-smtp")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const body = {
|
||
smtp_host: ($("#smtp-host")?.value || "").trim(),
|
||
smtp_port: Number($("#smtp-port")?.value || 587),
|
||
smtp_user: ($("#smtp-user")?.value || "").trim(),
|
||
smtp_from: ($("#smtp-from")?.value || "").trim(),
|
||
smtp_tls: !!$("#smtp-tls")?.checked,
|
||
smtp_ssl: !!$("#smtp-ssl")?.checked,
|
||
};
|
||
secretPayload("smtp-password", "smtp_password", body);
|
||
const data = await api("/api/admin/smtp", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
applySmtpUi(data.smtp || {});
|
||
toast("SMTP сохранён в .env");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка сохранения");
|
||
}
|
||
});
|
||
|
||
$("#btn-smtp-test")?.addEventListener("click", async () => {
|
||
try {
|
||
await api("/api/admin/smtp/test", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
to: ($("#smtp-test-to")?.value || "").trim(),
|
||
}),
|
||
});
|
||
toast("Тестовое письмо отправлено");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка отправки");
|
||
}
|
||
});
|
||
|
||
$("#smtp-tls")?.addEventListener("change", () => {
|
||
$("#smtp-tls-bar")?.classList.toggle("is-off", !$("#smtp-tls").checked);
|
||
});
|
||
$("#smtp-ssl")?.addEventListener("change", () => {
|
||
$("#smtp-ssl-bar")?.classList.toggle("is-off", !$("#smtp-ssl").checked);
|
||
});
|
||
|
||
function onClearSecretClick(e) {
|
||
const btn = e.target.closest("[data-clear-secret]");
|
||
if (!btn) return;
|
||
e.preventDefault();
|
||
const id = btn.getAttribute("data-clear-secret");
|
||
if (!id || !(id in paymentSecretClears)) return;
|
||
paymentSecretClears[id] = true;
|
||
const input = $(`#${id}`);
|
||
if (input) {
|
||
input.value = "";
|
||
input.placeholder = "будет очищен при сохранении";
|
||
}
|
||
toast("Секрет будет сброшен после «Сохранить»");
|
||
}
|
||
|
||
$("#form-payments")?.addEventListener("click", onClearSecretClick);
|
||
$("#form-smtp")?.addEventListener("click", onClearSecretClick);
|
||
|
||
$("#pay-anypay-check-ip")?.addEventListener("change", () => {
|
||
$("#pay-anypay-ip-bar")?.classList.toggle(
|
||
"is-off",
|
||
!$("#pay-anypay-check-ip").checked
|
||
);
|
||
});
|
||
$("#pay-crypto-testnet")?.addEventListener("change", () => {
|
||
$("#pay-crypto-testnet-bar")?.classList.toggle(
|
||
"is-off",
|
||
!$("#pay-crypto-testnet").checked
|
||
);
|
||
});
|
||
$("#pay-stars-enabled")?.addEventListener("change", () => {
|
||
$("#pay-stars-bar")?.classList.toggle("is-off", !$("#pay-stars-enabled").checked);
|
||
});
|
||
$("#ai-enabled")?.addEventListener("change", () => {
|
||
$("#ai-enabled-bar")?.classList.toggle("is-off", !$("#ai-enabled").checked);
|
||
const st = $("#ai-enabled-status");
|
||
if (st) st.textContent = $("#ai-enabled").checked ? "Включены" : "Выключены";
|
||
});
|
||
$("#ai-auto-reply")?.addEventListener("change", () => {
|
||
$("#ai-auto-bar")?.classList.toggle("is-off", !$("#ai-auto-reply").checked);
|
||
});
|
||
|
||
$("#btn-awg-new")?.addEventListener("click", () => showAwgForm(null));
|
||
$("#btn-awg-cancel")?.addEventListener("click", () => hideAwgForm());
|
||
$("#btn-refresh-awg")?.addEventListener("click", async () => {
|
||
try {
|
||
await loadAwgServers();
|
||
toast("AWG серверы обновлены");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#awg-servers-list")?.addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("[data-awg-act]");
|
||
if (!btn) return;
|
||
const row = btn.closest("[data-awg-id]");
|
||
if (!row) return;
|
||
const id = Number(row.dataset.awgId);
|
||
const server = awgServers.find((s) => s.id === id);
|
||
if (!server) return;
|
||
const act = btn.getAttribute("data-awg-act");
|
||
if (act === "edit") {
|
||
showAwgForm(server);
|
||
return;
|
||
}
|
||
if (act === "delete") {
|
||
if (!confirm(`Удалить сервер «${server.name}»?`)) return;
|
||
try {
|
||
await api(`/api/admin/awg/servers/${id}`, { method: "DELETE" });
|
||
hideAwgForm();
|
||
await loadAwgServers();
|
||
toast("Удалено");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка удаления");
|
||
}
|
||
return;
|
||
}
|
||
if (act === "test") {
|
||
try {
|
||
toast("Проверяю…");
|
||
const data = await api(`/api/admin/awg/servers/${id}/test`, {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
await loadAwgServers();
|
||
toast(data.message || (data.ok ? "OK" : "Ошибка"));
|
||
} catch (ex) {
|
||
await loadAwgServers().catch(() => {});
|
||
toast(ex.message || "Ошибка проверки");
|
||
}
|
||
}
|
||
});
|
||
|
||
$("#btn-awg-discover")?.addEventListener("click", async () => {
|
||
const box = $("#awg-discover-box");
|
||
try {
|
||
const apiUrl = ($("#awg-api-url").value || "").trim();
|
||
const apiToken = ($("#awg-api-token").value || "").trim();
|
||
if (!apiUrl || !apiToken) {
|
||
toast("Для загрузки нод нужны URL и Token");
|
||
return;
|
||
}
|
||
box.classList.remove("hidden");
|
||
box.innerHTML = '<p class="muted small">Загрузка нод…</p>';
|
||
const data = await api("/api/admin/awg/discover", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
api_url: apiUrl,
|
||
api_token: apiToken,
|
||
verify_ssl: $("#awg-verify-ssl").checked,
|
||
}),
|
||
});
|
||
const servers = data.servers || [];
|
||
if (!servers.length) {
|
||
box.innerHTML = '<p class="muted small">На панели нет серверов</p>';
|
||
return;
|
||
}
|
||
box.innerHTML = servers
|
||
.map((s) => {
|
||
const protos = (s.protocols || []).join(", ") || "—";
|
||
const badge = s.has_awg2
|
||
? '<span class="xui-badge ok">awg2</span>'
|
||
: '<span class="xui-badge off">no awg2</span>';
|
||
return `
|
||
<button type="button" class="admin-ticket-row" data-pick-panel="${s.id}" data-pick-name="${escapeAttr(s.name)}">
|
||
<div>
|
||
<strong>#${s.id} · ${escapeHtml(s.name)}</strong>
|
||
<div class="muted small">${escapeHtml(s.host || "")} · ${escapeHtml(protos)}</div>
|
||
</div>
|
||
${badge}
|
||
</button>`;
|
||
})
|
||
.join("");
|
||
} catch (ex) {
|
||
box.classList.remove("hidden");
|
||
box.innerHTML = `<p class="muted small">${escapeHtml(ex.message || "Ошибка")}</p>`;
|
||
}
|
||
});
|
||
|
||
$("#awg-discover-box")?.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("[data-pick-panel]");
|
||
if (!btn) return;
|
||
$("#awg-panel-server-id").value = btn.dataset.pickPanel;
|
||
if (!$("#awg-name").value) {
|
||
$("#awg-name").value = btn.dataset.pickName || `AWG #${btn.dataset.pickPanel}`;
|
||
}
|
||
toast(`Выбран server #${btn.dataset.pickPanel}`);
|
||
});
|
||
|
||
$("#btn-awg-test")?.addEventListener("click", async () => {
|
||
const out = $("#awg-test-out");
|
||
out.classList.remove("hidden");
|
||
out.textContent = "Проверка…";
|
||
try {
|
||
const existingId = ($("#awg-id").value || "").trim();
|
||
const { id, body } = collectAwgFormBody(!existingId);
|
||
let data;
|
||
if (id) {
|
||
if (body.api_token) {
|
||
await api(`/api/admin/awg/servers/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
data = await api(`/api/admin/awg/servers/${id}/test`, {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
} else {
|
||
data = await api("/api/admin/awg/test", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
out.textContent = [
|
||
data.ok ? "OK" : "FAIL",
|
||
data.message || data.error || "",
|
||
data.ping_ms != null ? `ping: ${data.ping_ms} ms` : "",
|
||
(data.protocols || []).length ? `protocols: ${(data.protocols || []).join(", ")}` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
toast(data.ok ? "Проверка OK" : data.message || "Ошибка");
|
||
if (id) await loadAwgServers().catch(() => {});
|
||
} catch (ex) {
|
||
out.textContent = ex.message || "Ошибка";
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#form-awg")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const existingId = ($("#awg-id").value || "").trim();
|
||
const { id, body } = collectAwgFormBody(!existingId);
|
||
if (id) {
|
||
await api(`/api/admin/awg/servers/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(body),
|
||
});
|
||
toast("Сервер обновлён");
|
||
} else {
|
||
await api("/api/admin/awg/servers", {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
toast("Сервер добавлен");
|
||
}
|
||
hideAwgForm();
|
||
await loadAwgServers();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка сохранения");
|
||
}
|
||
});
|
||
|
||
$("#form-awg-settings")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await saveAwgSettings();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-awg-settings-save")?.addEventListener("click", async () => {
|
||
try {
|
||
await saveAwgSettings();
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#awg-purchase-enabled")?.addEventListener("change", async () => {
|
||
try {
|
||
await saveAwgSettings();
|
||
} catch (ex) {
|
||
$("#awg-purchase-enabled").checked = !$("#awg-purchase-enabled").checked;
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#admin-nav")?.addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".admin-nav-btn");
|
||
if (!btn) return;
|
||
jumpToSection(btn.dataset.jump || "stats");
|
||
});
|
||
|
||
bootstrap();
|
||
})();
|