From 57821ba2a908a4ccdf89f610407c6d19f251fa01 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Sun, 10 May 2026 23:36:05 +0300
Subject: [PATCH] feat: tune visual of web app admin panel
---
bot/app/web/admin_api.py | 255 +++++--
bot/app/web/frontend/src/App.svelte | 5 +
.../web/frontend/src/admin/AdminPanel.svelte | 700 +++++++++++++-----
bot/app/web/frontend/src/styles.css | 619 +++++++++++++---
locales/en.json | 101 ++-
locales/ru.json | 123 ++-
6 files changed, 1449 insertions(+), 354 deletions(-)
diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py
index f75d99d..1681c47 100644
--- a/bot/app/web/admin_api.py
+++ b/bot/app/web/admin_api.py
@@ -19,7 +19,7 @@ from typing import Any, Dict, List, Optional
from aiogram import Bot
from aiohttp import web
from pydantic import ValidationError
-from sqlalchemy import or_, select
+from sqlalchemy import func as sa_func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
@@ -163,6 +163,11 @@ def _serialize_user(user: User) -> Dict[str, Any]:
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
+ premium_limit_bytes = (
+ int(sub.premium_baseline_bytes or 0)
+ + int(sub.premium_topup_balance_bytes or 0)
+ + int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ )
return {
"subscription_id": int(sub.subscription_id),
"panel_user_uuid": sub.panel_user_uuid,
@@ -174,9 +179,18 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
"status_from_panel": sub.status_from_panel,
"traffic_limit_bytes": sub.traffic_limit_bytes,
"traffic_used_bytes": sub.traffic_used_bytes,
+ "tier_baseline_bytes": sub.tier_baseline_bytes,
+ "topup_balance_bytes": sub.topup_balance_bytes,
+ "premium_used_bytes": sub.premium_used_bytes,
+ "premium_limit_bytes": premium_limit_bytes,
+ "premium_baseline_bytes": sub.premium_baseline_bytes,
+ "premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
+ "premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
+ "premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
+ "is_throttled": bool(sub.is_throttled),
}
@@ -335,20 +349,20 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
query = (request.query.get("q") or "").strip()
- only_banned = (request.query.get("filter") or "").lower() == "banned"
+ filter_value = (request.query.get("filter") or "all").lower()
+ panel_status = (request.query.get("panel_status") or "all").lower()
+ sort_value = (request.query.get("sort") or "registered_desc").lower()
async with async_session_factory() as session:
- if query:
- users = await _search_users(session, query, page=page, page_size=page_size)
- total = len(users) # cheap upper bound; UI shows hasMore via len==page_size
- elif only_banned:
- banned = await user_dal.get_banned_users(session)
- total = len(banned)
- start = page * page_size
- users = banned[start : start + page_size]
- else:
- users = await user_dal.get_all_users_paginated(session, page=page, page_size=page_size)
- total = await user_dal.count_all_users(session)
+ users, total = await _filter_and_sort_users(
+ session,
+ query=query,
+ filter_value=filter_value,
+ panel_status=panel_status,
+ sort_value=sort_value,
+ page=page,
+ page_size=page_size,
+ )
statuses = await _bulk_user_statuses(session, [u.user_id for u in users])
cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users])
@@ -356,7 +370,10 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
serialized = []
for user in users:
payload = _serialize_user(user)
- payload["panel_status"] = statuses.get(user.user_id)
+ status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
+ payload["panel_status"] = status_payload.get("status")
+ if status_payload.get("status") == "expired" and status_payload.get("end_date"):
+ payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}"
if user.user_id in cached_avatar_ids
@@ -376,7 +393,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
async def _bulk_user_statuses(
session: AsyncSession, user_ids: List[int]
-) -> Dict[int, Optional[str]]:
+) -> Dict[int, Dict[str, Optional[str]]]:
"""Return active subscription status for a batch of users.
Returns the panel status (active/expired/limited/disabled) when an active
@@ -392,18 +409,21 @@ async def _bulk_user_statuses(
.order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast())
)
rows = (await session.execute(stmt)).all()
- out: Dict[int, str] = {}
- for uid, panel_status, is_active, _end in rows:
+ out: Dict[int, Dict[str, Optional[str]]] = {}
+ for uid, panel_status, is_active, end_date in rows:
if uid in out:
continue
if is_active:
status = (panel_status or "active").lower()
else:
status = (panel_status or "expired").lower()
- out[uid] = status
+ out[uid] = {
+ "status": status,
+ "end_date": end_date.isoformat() if end_date else None,
+ }
for uid in user_ids:
if uid not in out:
- out[uid] = "bot_only"
+ out[uid] = {"status": "bot_only", "end_date": None}
return out
@@ -466,59 +486,118 @@ async def admin_user_avatar_route(request: web.Request) -> web.Response:
return response
-async def _search_users(
- session: AsyncSession, query: str, *, page: int, page_size: int
-) -> List[User]:
- """Best-effort search that tries telegram id, username, email."""
+async def _filter_and_sort_users(
+ session: AsyncSession,
+ *,
+ query: str = "",
+ filter_value: str,
+ panel_status: str = "all",
+ sort_value: str,
+ page: int,
+ page_size: int,
+) -> tuple[List[User], int]:
+ """Return paginated users with optional search, filter and sort applied."""
- candidates: List[User] = []
- seen: set = set()
+ stmt = select(User)
+ count_stmt = select(sa_func.count(User.user_id))
- if query.isdigit():
- user = await user_dal.get_user_by_id(session, int(query))
- if user:
- candidates.append(user)
- seen.add(user.user_id)
- user = await user_dal.get_user_by_telegram_id(session, int(query))
- if user and user.user_id not in seen:
- candidates.append(user)
- seen.add(user.user_id)
+ search_cond = _user_search_condition(query)
+ if search_cond is not None:
+ stmt = stmt.where(search_cond)
+ count_stmt = count_stmt.where(search_cond)
- if "@" in query and not query.startswith("@"):
- user = await user_dal.get_user_by_email(session, query)
- if user and user.user_id not in seen:
- candidates.append(user)
- seen.add(user.user_id)
+ f = (filter_value or "all").lower()
+ if f == "banned":
+ cond = User.is_banned.is_(True)
+ elif f == "active":
+ cond = User.is_banned.is_(False)
+ elif f == "tg_linked":
+ cond = User.telegram_id.is_not(None)
+ elif f == "no_tg":
+ cond = User.telegram_id.is_(None)
+ elif f == "email_linked":
+ cond = User.email.is_not(None)
+ elif f == "no_email":
+ cond = User.email.is_(None)
+ elif f == "panel_linked":
+ cond = User.panel_user_uuid.is_not(None)
+ else:
+ cond = None
- raw = query.lstrip("@")
- user = await user_dal.get_user_by_username(session, raw)
- if user and user.user_id not in seen:
- candidates.append(user)
- seen.add(user.user_id)
+ if cond is not None:
+ stmt = stmt.where(cond)
+ count_stmt = count_stmt.where(cond)
- if not candidates:
- like = f"%{raw}%"
- stmt = (
- select(User)
- .where(
- or_(
- User.username.ilike(like),
- User.first_name.ilike(like),
- User.last_name.ilike(like),
- User.email.ilike(like),
- )
- )
- .order_by(User.registration_date.desc())
- .offset(page * page_size)
- .limit(page_size)
- )
- rows = (await session.execute(stmt)).scalars().all()
- for row in rows:
- if row.user_id not in seen:
- candidates.append(row)
- seen.add(row.user_id)
+ panel_cond = _user_panel_status_condition(panel_status)
+ if panel_cond is not None:
+ stmt = stmt.where(panel_cond)
+ count_stmt = count_stmt.where(panel_cond)
- return candidates
+ sort_map = {
+ "registered_desc": User.registration_date.desc().nullslast(),
+ "registered_asc": User.registration_date.asc().nullslast(),
+ "name_asc": (
+ sa_func.coalesce(User.first_name, User.username, User.email).asc(),
+ User.user_id.asc(),
+ ),
+ "name_desc": (
+ sa_func.coalesce(User.first_name, User.username, User.email).desc(),
+ User.user_id.desc(),
+ ),
+ "id_asc": User.user_id.asc(),
+ "id_desc": User.user_id.desc(),
+ }
+ order = sort_map.get(sort_value, sort_map["registered_desc"])
+ if isinstance(order, tuple):
+ stmt = stmt.order_by(*order)
+ else:
+ stmt = stmt.order_by(order)
+
+ stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1))
+
+ users = (await session.execute(stmt)).scalars().all()
+ total = (await session.execute(count_stmt)).scalar_one()
+ return users, int(total)
+
+
+def _user_panel_status_condition(panel_status: str):
+ status = (panel_status or "all").lower()
+ if status not in {"active", "expired", "limited"}:
+ return None
+
+ normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, ""))
+ blank_status = or_(Subscription.status_from_panel.is_(None), Subscription.status_from_panel == "")
+ if status == "active":
+ status_cond = or_(normalized_status == "active", blank_status & Subscription.is_active.is_(True))
+ elif status == "expired":
+ status_cond = or_(normalized_status == "expired", blank_status & Subscription.is_active.is_(False))
+ else:
+ status_cond = normalized_status == "limited"
+
+ return (
+ select(Subscription.subscription_id)
+ .where(Subscription.user_id == User.user_id, status_cond)
+ .exists()
+ )
+
+
+def _user_search_condition(query: str):
+ raw = (query or "").strip().lstrip("@")
+ if not raw:
+ return None
+
+ like = f"%{raw}%"
+ conditions = [
+ User.username.ilike(like),
+ User.first_name.ilike(like),
+ User.last_name.ilike(like),
+ User.email.ilike(like),
+ ]
+ if raw.isdigit():
+ numeric = int(raw)
+ conditions.extend([User.user_id == numeric, User.telegram_id == numeric])
+
+ return or_(*conditions)
async def admin_user_detail_route(request: web.Request) -> web.Response:
@@ -698,6 +777,49 @@ async def admin_user_message_route(request: web.Request) -> web.Response:
return _ok({})
+async def admin_user_message_preview_route(request: web.Request) -> web.Response:
+ actor_id = _require_admin_user_id(request)
+ admin_telegram_id = request.get("admin_telegram_id")
+ target_id = int(request.match_info["user_id"])
+ payload = await _read_json(request)
+ text = str(payload.get("text") or "").strip()
+ if not text:
+ return _error(400, "empty_text")
+ if not admin_telegram_id:
+ return _error(403, "admin_telegram_unavailable")
+
+ queue_manager = get_queue_manager()
+ if not queue_manager:
+ return _error(503, "queue_unavailable")
+
+ try:
+ await send_message_via_queue(
+ queue_manager,
+ int(admin_telegram_id),
+ MessageContent(content_type="text", text=text),
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ )
+ except Exception as exc:
+ logger.warning("Admin direct message preview failed: %s", exc)
+ return _error(502, "preview_failed", str(exc))
+
+ async_session_factory: sessionmaker = request.app["async_session_factory"]
+ async with async_session_factory() as session:
+ await message_log_dal.create_message_log(
+ session,
+ {
+ "user_id": actor_id,
+ "event_type": "admin_direct_message_preview_webapp",
+ "content": text[:4000],
+ "is_admin_event": True,
+ "target_user_id": target_id,
+ },
+ )
+
+ return _ok({})
+
+
async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -1347,6 +1469,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
+ router.add_post("/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte
index a07969d..ac7bc36 100644
--- a/bot/app/web/frontend/src/App.svelte
+++ b/bot/app/web/frontend/src/App.svelte
@@ -2963,6 +2963,11 @@
logoEmoji={brandEmoji}
appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl}
+ currentLang={currentLang}
+ languageOptions={languageOptions}
+ languageBusy={languageBusy}
+ onLanguageChange={updateAccountLanguage}
+ t={t}
/>
{:else}
diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte
index 79efb12..ac50f12 100644
--- a/bot/app/web/frontend/src/admin/AdminPanel.svelte
+++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte
@@ -3,6 +3,7 @@
ArrowLeft,
BarChart3,
Check,
+ ChevronsUpDown,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -15,7 +16,7 @@
Eye,
EyeOff,
FileText,
- Link2,
+ Globe2,
LayoutDashboard,
Megaphone,
Menu,
@@ -54,53 +55,60 @@
export let logoEmoji = "рџ«Ґ";
export let appVersion = "dev+local";
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
+ export let currentLang = "ru";
+ export let languageOptions = [];
+ export let languageBusy = false;
+ export let onLanguageChange = () => {};
+ export let t = (key, params = {}, fallback = "") => fallback || key;
+
+ const at = (key, params = {}, fallback = "") => t(`admin_${key}`, params, fallback || key);
const NAV_GROUPS = [
{
id: "overview",
- label: "Overview",
+ label: at("nav_overview", {}, "Overview"),
items: [
- { id: "stats", label: "Дашборд", icon: LayoutDashboard },
+ { id: "stats", label: at("nav_dashboard", {}, "Дашборд"), icon: LayoutDashboard },
],
},
{
id: "operations",
- label: "Управление",
+ label: at("nav_operations", {}, "Управление"),
items: [
- { id: "users", label: "Пользователи", icon: UsersRound },
- { id: "payments", label: "Платежи", icon: CreditCard },
- { id: "promos", label: "Промокоды", icon: Tag },
- { id: "ads", label: "Реклама", icon: Sparkles },
+ { id: "users", label: at("nav_users", {}, "Пользователи"), icon: UsersRound },
+ { id: "payments", label: at("nav_payments", {}, "Платежи"), icon: CreditCard },
+ { id: "promos", label: at("nav_promos", {}, "Промокоды"), icon: Tag },
+ { id: "ads", label: at("nav_ads", {}, "Реклама"), icon: Sparkles },
],
},
{
id: "communication",
- label: "Коммуникации",
+ label: at("nav_communication", {}, "Коммуникации"),
items: [
- { id: "broadcast", label: "Рассылка", icon: Megaphone },
- { id: "logs", label: "Логи", icon: FileText },
+ { id: "broadcast", label: at("nav_broadcast", {}, "Рассылка"), icon: Megaphone },
+ { id: "logs", label: at("nav_logs", {}, "Логи"), icon: FileText },
],
},
{
id: "system",
- label: "Система",
+ label: at("nav_system", {}, "Система"),
items: [
- { id: "tariffs", label: "Тарифы", icon: Coins },
- { id: "settings", label: "Настройки", icon: Sliders },
+ { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
+ { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
],
},
];
const SECTION_META = {
- stats: { title: "Дашборд", subtitle: "Сводка по магазину и панели" },
- users: { title: "Пользователи", subtitle: "Поиск, баны, действия над аккаунтами" },
- payments: { title: "Платежи", subtitle: "История транзакций и экспорт" },
- promos: { title: "Промокоды", subtitle: "Создание и управление кодами" },
- ads: { title: "Рекламные кампании", subtitle: "UTM-источники и атрибуция" },
- broadcast: { title: "Рассылка", subtitle: "Массовая отправка сообщений в Telegram" },
- logs: { title: "Логи активности", subtitle: "События пользователей и админ-действия" },
- tariffs: { title: "Тарифы", subtitle: "Каталог продаж, периоды, пакеты и лимиты" },
- settings: { title: "Настройки приложения", subtitle: "Оверрайды над .env, применяются мгновенно" },
+ stats: { title: at("section_stats_title", {}, "Дашборд"), subtitle: at("section_stats_subtitle", {}, "Сводка по магазину и панели") },
+ users: { title: at("section_users_title", {}, "Пользователи"), subtitle: at("section_users_subtitle", {}, "Поиск, баны, действия над аккаунтами") },
+ payments: { title: at("section_payments_title", {}, "Платежи"), subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт") },
+ promos: { title: at("section_promos_title", {}, "Промокоды"), subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами") },
+ ads: { title: at("section_ads_title", {}, "Рекламные кампании"), subtitle: at("section_ads_subtitle", {}, "UTM-источники и атрибуция") },
+ broadcast: { title: at("section_broadcast_title", {}, "Рассылка"), subtitle: at("section_broadcast_subtitle", {}, "Массовая отправка сообщений в Telegram") },
+ logs: { title: at("section_logs_title", {}, "Логи активности"), subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия") },
+ tariffs: { title: at("section_tariffs_title", {}, "Тарифы"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты") },
+ settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно") },
};
const VALID_SECTIONS = NAV_GROUPS.flatMap((g) => g.items.map((i) => i.id));
@@ -123,7 +131,42 @@
const USERS_PAGE_SIZE = 25;
let usersQuery = "";
let usersFilter = "all";
+ let usersPanelStatus = "all";
+ let usersSort = "registered_desc";
let usersLoading = false;
+
+ const USERS_FILTER_OPTIONS = [
+ { value: "all", label: at("filter_all", {}, "Все") },
+ { value: "active", label: at("filter_not_banned", {}, "Не забанены") },
+ { value: "banned", label: at("filter_banned", {}, "Забанены") },
+ { value: "tg_linked", label: at("filter_tg_linked", {}, "С Telegram") },
+ { value: "no_tg", label: at("filter_no_tg", {}, "Без Telegram") },
+ { value: "email_linked", label: at("filter_email_linked", {}, "С email") },
+ { value: "no_email", label: at("filter_no_email", {}, "Без email") },
+ { value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
+ ];
+
+ const USERS_SORT_OPTIONS = [
+ { value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") },
+ { value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") },
+ { value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") },
+ { value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
+ { value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
+ { value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
+ ];
+
+ const USERS_PANEL_STATUS_OPTIONS = [
+ { value: "all", label: at("panel_status_all", {}, "Все статусы") },
+ { value: "active", label: "active" },
+ { value: "expired", label: "expired" },
+ { value: "limited", label: "limited" },
+ ];
+
+ const BROADCAST_TARGET_OPTIONS = [
+ { value: "all", label: at("broadcast_target_all", {}, "Все активные") },
+ { value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
+ { value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
+ ];
let openedUser = null;
let openedUserDetail = null;
let userDetailLoading = false;
@@ -132,6 +175,7 @@
let userActionBusy = false;
let userDeleteOpen = false;
let userBanConfirmOpen = false;
+ let userMessageConfirmOpen = false;
let userDetailTab = "profile";
let tariffEditorTab = "general";
@@ -281,10 +325,10 @@
try {
const res = await api("/admin/sync", { method: "POST" });
if (res?.ok) {
- flash("Синхронизация запущена");
+ flash(at("sync_started", {}, "Синхронизация запущена"));
await loadStats();
} else {
- flash(res?.error || "Ошибка синхронизации");
+ flash(res?.error || at("sync_error", {}, "Ошибка синхронизации"));
}
} finally {
syncBusy = false;
@@ -300,7 +344,9 @@
page_size: String(USERS_PAGE_SIZE),
});
if (usersQuery.trim()) params.set("q", usersQuery.trim());
- if (usersFilter === "banned") params.set("filter", "banned");
+ if (usersFilter && usersFilter !== "all") params.set("filter", usersFilter);
+ if (usersPanelStatus && usersPanelStatus !== "all") params.set("panel_status", usersPanelStatus);
+ if (usersSort && usersSort !== "registered_desc") params.set("sort", usersSort);
const data = await api(`/admin/users?${params.toString()}`);
if (data?.ok) {
users = data.users || [];
@@ -317,6 +363,7 @@
openedUser = typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId };
openedUserDetail = null;
userMessageDraft = "";
+ userMessageConfirmOpen = false;
userExtendDays = 30;
userDetailLoading = true;
userDetailTab = "subscription";
@@ -342,6 +389,7 @@
openedUserDetail = null;
userDeleteOpen = false;
userBanConfirmOpen = false;
+ userMessageConfirmOpen = false;
if (wasOpen && !opts.skipPush) _pushUserPath(null);
}
@@ -354,7 +402,7 @@
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
}
- function copyToClipboard(text, successMessage = "Ссылка скопирована") {
+ function copyToClipboard(text, successMessage = at("link_copied", {}, "Ссылка скопирована")) {
if (!text) return;
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(
@@ -366,12 +414,6 @@
}
}
- function copyUserDeepLink() {
- if (!openedUser || typeof window === "undefined") return;
- const url = `${window.location.origin}/admin/users/${openedUser.user_id}`;
- copyToClipboard(url);
- }
-
function requestBanToggle() {
if (!openedUser) return;
if (openedUser.is_banned) {
@@ -396,8 +438,8 @@
u.user_id === openedUser.user_id ? { ...u, is_banned: banned } : u,
);
userBanConfirmOpen = false;
- flash(banned ? "Пользователь забанен" : "Пользователь разбанен");
- } else flash(res?.error || "Ошибка");
+ flash(banned ? at("user_banned", {}, "Пользователь забанен") : at("user_unbanned", {}, "Пользователь разбанен"));
+ } else flash(res?.error || at("error", {}, "Ошибка"));
} finally {
userActionBusy = false;
}
@@ -412,9 +454,30 @@
body: JSON.stringify({ text: userMessageDraft }),
});
if (res?.ok) {
- flash("Сообщение отправлено");
+ flash(at("message_sent", {}, "Сообщение отправлено"));
userMessageDraft = "";
- } else flash(res?.error || "Не удалось отправить");
+ userMessageConfirmOpen = false;
+ } else flash(res?.error || at("message_send_failed", {}, "Не удалось отправить"));
+ } finally {
+ userActionBusy = false;
+ }
+ }
+
+ function requestSendUserMessage() {
+ if (!openedUser || !userMessageDraft.trim()) return;
+ userMessageConfirmOpen = true;
+ }
+
+ async function previewUserMessage() {
+ if (!openedUser || !userMessageDraft.trim()) return;
+ userActionBusy = true;
+ try {
+ const res = await api(`/admin/users/${openedUser.user_id}/message/preview`, {
+ method: "POST",
+ body: JSON.stringify({ text: userMessageDraft }),
+ });
+ if (res?.ok) flash(at("message_preview_sent", {}, "Превью отправлено вам в Telegram"));
+ else flash(res?.error || at("message_preview_failed", {}, "Не удалось отправить превью"));
} finally {
userActionBusy = false;
}
@@ -431,9 +494,9 @@
body: JSON.stringify({ days }),
});
if (res?.ok) {
- flash(`Подписка продлена на ${days} дн.`);
+ flash(at("subscription_extended", { days }, `Подписка продлена на ${days} дн.`));
await openUser(openedUser, { skipPush: true });
- } else flash(res?.error || "Ошибка");
+ } else flash(res?.error || at("error", {}, "Ошибка"));
} finally {
userActionBusy = false;
}
@@ -444,8 +507,8 @@
userActionBusy = true;
try {
const res = await api(`/admin/users/${openedUser.user_id}/reset-trial`, { method: "POST" });
- if (res?.ok) flash("Триал сброшен");
- else flash(res?.error || "Ошибка");
+ if (res?.ok) flash(at("trial_reset", {}, "Триал сброшен"));
+ else flash(res?.error || at("error", {}, "Ошибка"));
} finally {
userActionBusy = false;
}
@@ -457,10 +520,10 @@
try {
const res = await api(`/admin/users/${openedUser.user_id}`, { method: "DELETE" });
if (res?.ok) {
- flash("Пользователь удалён");
+ flash(at("user_deleted", {}, "Пользователь удалён"));
users = users.filter((u) => u.user_id !== openedUser.user_id);
closeUser();
- } else flash(res?.error || "Ошибка");
+ } else flash(res?.error || at("error", {}, "Ошибка"));
} finally {
userActionBusy = false;
}
@@ -1110,6 +1173,33 @@
return `${num.toFixed(2)} ${sym}`.trim();
}
+ function fmtTrafficBytes(value) {
+ const bytes = Number(value || 0);
+ if (!bytes || bytes <= 0) return "0 GB";
+ const gb = bytes / 1073741824;
+ const formatted = gb >= 10 ? gb.toFixed(1) : gb.toFixed(2);
+ return `${formatted.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")} GB`;
+ }
+
+ function trafficPercentValue(used, limit) {
+ const usedBytes = Number(used || 0);
+ const limitBytes = Number(limit || 0);
+ if (!limitBytes || limitBytes <= 0) return 0;
+ return Math.max(0, Math.min(100, Math.round((usedBytes / limitBytes) * 100)));
+ }
+
+ function trafficLeftLabel(used, limit) {
+ const limitBytes = Number(limit || 0);
+ if (!limitBytes || limitBytes <= 0) return "Без лимита";
+ return fmtTrafficBytes(Math.max(0, limitBytes - Number(used || 0)));
+ }
+
+ function trafficOfLabel(used, limit) {
+ const limitBytes = Number(limit || 0);
+ if (!limitBytes || limitBytes <= 0) return `${fmtTrafficBytes(used)} / без лимита`;
+ return `${fmtTrafficBytes(used)} / ${fmtTrafficBytes(limit)}`;
+ }
+
function tariffName(tariff) {
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
}
@@ -1211,7 +1301,12 @@
case "active":
return { label: "Active", variant: "success" };
case "expired":
- return { label: "Expired", variant: "warning" };
+ return {
+ label: user?.panel_status_expired_at
+ ? at("expired_badge", { date: fmtDateShort(user.panel_status_expired_at) }, `Expired ${fmtDateShort(user.panel_status_expired_at)}`)
+ : "Expired",
+ variant: "warning",
+ };
case "limited":
return { label: "Limited", variant: "warning" };
case "disabled":
@@ -1231,20 +1326,25 @@
function sectionTitle(id) {
const map = {
- general: "Общие",
- appearance: "Внешний вид",
- pricing: "Тарифы и цены",
- payments: "Платёжные системы",
- trial: "Триал",
- referral: "Реферальная программа",
- notifications: "Уведомления",
- devices: "Устройства",
+ general: at("settings_section_general", {}, "Общие"),
+ appearance: at("settings_section_appearance", {}, "Внешний вид"),
+ pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
+ payments: at("settings_section_payments", {}, "Платёжные системы"),
+ trial: at("settings_section_trial", {}, "Триал"),
+ referral: at("settings_section_referral", {}, "Реферальная программа"),
+ notifications: at("settings_section_notifications", {}, "Уведомления"),
+ devices: at("settings_section_devices", {}, "Устройства"),
};
return map[id] || id;
}
+ function optionLabel(options, value) {
+ return options.find((option) => option.value === value)?.label || value;
+ }
+
$: dirtyCount = Object.keys(settingsDirty).length;
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
+ $: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: usersHasMore = users.length === USERS_PAGE_SIZE;
$: paymentsHasMore = payments.length === PAYMENTS_PAGE_SIZE;
$: logsHasMore = logs.length === LOGS_PAGE_SIZE;
@@ -1310,15 +1410,16 @@
{#if field.type === "bool"}
-
+
{:else if field.type === "color"}
(sidebarOpen = false)}
>
{/if}
-