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} -