Files

2423 lines
88 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, "&#39;");
}
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();
})();