(() => { 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) => ``) .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 = '

Нажмите «Каталог» или добавьте модель вручную

'; 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 = ` ${escapeHtml(m.name || m.id)} ${escapeHtml(m.id)} `; 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 = `
Всего кодов
${s.total || 0}

Сгенерировано за всё время

Свободно
${s.unused || 0}

Можно выдать пользователям

Использовано
${s.used || 0}

Уже активированы

Тикеты открыты
${t.open || 0}

Ждут ответа поддержки

`; } 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 = `
#${t.id} · ${escapeHtml(t.subject)}
${escapeHtml(user)} · ${fmtDate(t.updated_at)}
${escapeHtml(t.status_label || t.status)} `; box.appendChild(btn); } if (!box.children.length) { box.innerHTML = `

Тикетов пока нет

Когда пользователи напишут — обращения появятся здесь

`; } } 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 = `
${who} · ${fmtDate(m.created_at)}
${escapeHtml(m.body)}
`; 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 ? `Использован` : `Свободен`; const sub = used && row.used_by ? `
ID ${row.used_by}
` : ""; tr.innerHTML = ` ${row.code} ${Math.round(row.amount)} ₽ ${fmtDate(row.created_at)} ${badge}${sub} `; tbody.appendChild(tr); } if (!tbody.children.length) { tbody.innerHTML = 'Кодов пока нет — создайте первую партию слева'; } } 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 = `
${escapeHtml(u.display_name || "—")}
${sub ? `
${escapeHtml(sub)}
` : ""} ${u.telegram_id} ${Math.round(u.balance)} ₽${u.banned ? ' BAN' : ""} `; tbody.appendChild(tr); } if (!tbody.children.length) { tbody.innerHTML = 'Никого не нашли — попробуйте другой запрос'; } 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, """); } 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 = [ '', ...xuiInboundOptions.map((i) => { const label = `#${i.id} ${i.remark || i.protocol} · :${i.port} · clients ${i.clients_count || 0}`; return ``; }), ]; 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 = `

Серверов пока нет

Добавьте панель 3x-ui 3.5.0 с WireGuard inbound — кнопка «+ Сервер»

`; return; } box.innerHTML = xuiServers .map((s) => { const statusBadge = s.last_check_ok === true ? 'online' : s.last_check_ok === false ? 'fail' : ""; const enBadge = s.enabled ? 'on' : 'off'; const authBadge = s.api_key_set ? 'api key' : 'login'; 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 ? `
Проверка: ${escapeHtml(s.last_check_msg)}` : ""; return `
${escapeHtml(s.name)} ${enBadge} ${authBadge} ${statusBadge} 3.5.0
API: ${escapeHtml(s.api_url)}
${escapeHtml(inbound)} · sub: ${escapeHtml(sub)}${check}
`; }) .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 = `

Серверов AWG пока нет

Добавьте панель panel5 · API Token · выберите ноду с awg2

`; return; } box.innerHTML = awgServers .map((s) => { const statusBadge = s.last_check_ok === true ? 'online' : s.last_check_ok === false ? 'fail' : ""; const enBadge = s.enabled ? 'on' : 'off'; const check = s.last_check_msg ? `
Проверка: ${escapeHtml(s.last_check_msg)}` : ""; return `
${escapeHtml(s.name)} ${enBadge} ${escapeHtml(s.protocol || "awg2")} ${statusBadge}
API: ${escapeHtml(s.api_url)}
panel server #${s.panel_server_id}${check}
`; }) .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 = `

Тарифов пока нет

Добавьте первый тариф для продажи подписок

`; return; } box.innerHTML = adminTariffs .map( (t) => `
${escapeHtml(t.title)} ${escapeHtml(t.price_label || `${t.price} ₽`)} ${t.days} дн.
ID: ${escapeHtml(t.id)} · ${t.traffic_gb || 0} ГБ · ${t.device_limit || 0} устр. ${t.description ? `
${escapeHtml(t.description)}` : ""}
` ) .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 = '

Не удалось загрузить серверы 3x-ui

'; }); await loadWgSettings().catch(() => {}); await loadAwgServers().catch(() => { $("#awg-servers-list").innerHTML = '

Не удалось загрузить серверы AWG

'; }); await loadAwgSettings().catch(() => {}); await loadAdminTariffs().catch(() => { $("#tariffs-admin-list").innerHTML = '

Не удалось загрузить тарифы

'; }); 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) => `
${p.code} · ${p.kind}=${p.value} · used ${p.used_count}${p.max_uses != null ? "/" + p.max_uses : ""} · ${p.active ? "ON" : "OFF"}
` ) .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) => `
${e.created_at} · ${e.provider}/${e.event_type} · tg=${e.telegram_id || "—"} · ${e.amount ?? "—"} · ${e.status}
` ) .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 = '

Нет бэкапов — создайте первый

'; 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 `
${escapeHtml(b.name)} ${kb} KB · ${escapeHtml(when)} UTC
`; }) .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 = '

Загрузка нод…

'; 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 = '

На панели нет серверов

'; return; } box.innerHTML = servers .map((s) => { const protos = (s.protocols || []).join(", ") || "—"; const badge = s.has_awg2 ? 'awg2' : 'no awg2'; return ` `; }) .join(""); } catch (ex) { box.classList.remove("hidden"); box.innerHTML = `

${escapeHtml(ex.message || "Ошибка")}

`; } }); $("#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(); })();