(() => {
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();
})();