feat: tune visual of web app admin panel, tune limit messages and etc

This commit is contained in:
3252a8
2026-05-12 15:42:17 +03:00
parent 8af59750c6
commit 475a1953d1
34 changed files with 2777 additions and 114 deletions
+275 -8
View File
@@ -14,7 +14,7 @@ import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from aiogram import Bot
from aiohttp import web
@@ -163,10 +163,15 @@ def _serialize_user(user: User) -> Dict[str, Any]:
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
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)
+ premium_bonus_bytes
)
return {
"subscription_id": int(sub.subscription_id),
@@ -186,6 +191,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
"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_bonus_bytes": premium_bonus_bytes,
"regular_bonus_bytes": regular_bonus_bytes,
"regular_unlimited_override": regular_unlimited_override,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"tariff_key": sub.tariff_key,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
@@ -194,17 +203,47 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
}
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
if payment.purchased_gb is None:
return None, None
try:
gb = float(payment.purchased_gb)
except (TypeError, ValueError):
return None, None
sm = (payment.sale_mode or "").strip()
if not sm:
return None, None
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
if base == "premium_topup":
return None, gb
if base in {"traffic", "traffic_package", "topup"}:
return gb, None
return None, None
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
# Some admin routes serialize payments after the session scope is closed.
user_label = str(payment.user_id)
telegram_id = None
loaded_user = payment.__dict__.get("user")
if loaded_user is not None:
user_label = loaded_user.username or loaded_user.first_name or str(payment.user_id)
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
try:
telegram_id = int(tid)
except (TypeError, ValueError):
telegram_id = None
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
return {
"payment_id": int(payment.payment_id),
"user_id": int(payment.user_id),
"user_label": user_label,
"telegram_id": telegram_id,
"traffic_regular_gb": reg_gb,
"traffic_premium_gb": prem_gb,
"provider": payment.provider,
"provider_payment_id": payment.provider_payment_id,
"amount": float(payment.amount),
@@ -992,6 +1031,204 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
return _ok({})
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
unlimited = bool(payload.get("unlimited"))
bonus_bytes_raw = payload.get("bonus_bytes")
bonus_gb_raw = payload.get("bonus_gb")
if bonus_bytes_raw is None and bonus_gb_raw is None:
bonus_bytes = 0
elif bonus_bytes_raw is not None:
try:
bonus_bytes = int(bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
else:
try:
bonus_bytes = int(round(float(bonus_gb_raw) * (1024 ** 3)))
except (TypeError, ValueError):
return _error(400, "invalid_bonus", "bonus_gb must be a number")
if bonus_bytes < 0:
return _error(400, "invalid_bonus", "bonus must be non-negative")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.premium_unlimited_override = bool(unlimited)
active.premium_bonus_bytes = int(bonus_bytes)
if active.premium_unlimited_override:
active.premium_is_limited = False
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_premium_override_webapp",
"content": (
f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"
),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
unlimited = bool(payload.get("unlimited"))
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
regular_bonus_bytes = 0
elif regular_bonus_bytes_raw is not None:
try:
regular_bonus_bytes = int(regular_bonus_bytes_raw)
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
else:
try:
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024 ** 3)))
except (TypeError, ValueError):
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
if regular_bonus_bytes < 0:
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
subscription_service = request.app.get("subscription_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
active.regular_unlimited_override = bool(unlimited)
active.regular_bonus_bytes = int(regular_bonus_bytes)
if subscription_service is not None:
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_regular_traffic_override_webapp",
"content": (
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
),
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
"""Credit regular or premium traffic to a user without a payment.
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
the chosen balance grows, panel limit/squads are refreshed, and an entry
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
"""
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
kind = str(payload.get("kind") or "regular").strip().lower()
if kind not in {"regular", "premium"}:
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
bytes_raw = payload.get("bytes")
gb_raw = payload.get("gb")
if bytes_raw is None and gb_raw is None:
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
try:
if bytes_raw is not None:
grant_bytes = int(bytes_raw)
gb_value = grant_bytes / (1024 ** 3)
else:
gb_value = float(gb_raw)
grant_bytes = int(round(gb_value * (1024 ** 3)))
except (TypeError, ValueError):
return _error(400, "invalid_amount", "amount must be a positive number")
if gb_value <= 0 or grant_bytes <= 0:
return _error(400, "invalid_amount", "amount must be positive")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
if kind == "regular":
result = await subscription_service.admin_grant_topup(
session, target_id, gb_value
)
else:
result = await subscription_service.admin_grant_premium_topup(
session, target_id, gb_value
)
if not result:
await session.rollback()
return _error(
422,
"grant_failed",
"Unable to credit traffic (missing tariff/squads or panel error)",
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_traffic_grant_webapp",
"content": f"kind={kind} bytes={grant_bytes}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
refreshed = await subscription_dal.get_active_subscription_by_user_id(
session, target_id
)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
"grant": {
"kind": kind,
"granted_bytes": grant_bytes,
"granted_gb": gb_value,
},
}
)
async def admin_user_extend_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -1003,14 +1240,22 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
if days <= 0:
return _error(400, "invalid_days")
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
return _error(503, "subscription_service_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if not active:
return _error(404, "no_active_subscription")
new_end = await subscription_service.extend_active_subscription_days(
session,
target_id,
days,
"admin_extend_subscription_webapp",
)
if not new_end:
await session.rollback()
return _error(500, "extend_failed")
new_end = (active.end_date or datetime.now(timezone.utc)) + timedelta(days=days)
active.end_date = new_end
await message_log_dal.create_message_log(
session,
{
@@ -1022,8 +1267,18 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
},
)
await session.commit()
await session.refresh(active)
return _ok({"subscription": _serialize_subscription(active)})
refreshed = await subscription_dal.get_active_subscription_by_user_id(
session, target_id
)
return _ok(
{
"subscription": _serialize_subscription(refreshed)
if refreshed
else None,
}
)
# ─── Payments ──────────────────────────────────────────────────────
@@ -1589,6 +1844,18 @@ def setup_admin_routes(app: web.Application) -> None:
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_post(
"/api/admin/users/{user_id:-?\\d+}/premium-override",
admin_user_premium_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
admin_user_regular_traffic_override_route,
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route,
)
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
router.add_get("/api/admin/payments", admin_payments_list_route)
+39
View File
@@ -454,6 +454,16 @@
});
}
function stripTopupQueryFromUrl() {
if (typeof window === "undefined") return;
const u = new URL(window.location.href);
if (!u.searchParams.has("topup")) return;
u.searchParams.delete("topup");
const search = u.searchParams.toString();
const qs = search ? `?${search}` : "";
window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`);
}
async function loadData() {
const payload = await api("/me");
if (!payload.ok) throw new Error(payload.error || "load_failed");
@@ -482,6 +492,35 @@
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
const topupDeep = new URLSearchParams(window.location.search).get("topup");
if (topupDeep === "regular" || topupDeep === "premium") {
const plansList = payload.plans?.length ? payload.plans : [];
const tariffCatalogLocal = buildTariffCatalog(plansList);
const sub = payload.subscription || {};
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
const hasTariffSub = Boolean(
tariffModeLocal &&
sub?.active &&
sub?.tariff_key &&
tariffCatalogLocal.some((t) => t.key === sub.tariff_key),
);
const canRegular =
hasTariffSub &&
(sub?.can_topup_regular_traffic ?? sub?.can_topup_traffic) &&
Number(sub?.traffic_limit_bytes || 0) > 0;
const canPremium =
hasTariffSub &&
(sub?.can_topup_premium_traffic ?? sub?.can_topup_traffic) &&
Number(sub?.premium_limit_bytes || 0) > 0;
if (topupDeep === "regular" && canRegular) {
billingStore.openTopupModal("regular", payload.payment_methods?.[0]?.id || "");
stripTopupQueryFromUrl();
} else if (topupDeep === "premium" && canPremium) {
billingStore.openTopupModal("premium", payload.payment_methods?.[0]?.id || "");
stripTopupQueryFromUrl();
}
}
}
function showLogin() {
@@ -219,6 +219,19 @@
window.open("/api/admin/payments/export.csv", "_blank", "noopener");
}
function openPaymentUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) return;
const next = normalizeSection("users");
sidebarOpen = false;
if (active !== next) {
active = next;
usersStore.closeUser();
onSectionChange(next);
}
usersStore.openUser(uid);
}
function resolvedAvatarUrl(user) {
return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "");
}
@@ -470,7 +483,7 @@
{/if}
{#if active === "payments"}
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} />
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} onOpenUserCard={openPaymentUserCard} />
{/if}
{#if active === "promos"}
@@ -2,16 +2,19 @@
import { getContext, onMount } from "svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminPagination,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let at = (key) => key;
export let fmtDate = (value) => value;
export let fmtMoney = (value) => value;
export let paymentStatusVariant = () => "muted";
export let onOpenUserCard = () => {};
const paymentsStore = getContext("paymentsStore");
@@ -23,9 +26,60 @@
} = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
/** @param {number|null|undefined} v */
function formatTrafficGbCell(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
let s;
if (Math.abs(n - Math.round(n)) < 1e-9) {
s = String(Math.round(n));
} else {
s = String(Math.round(n * 100) / 100);
}
return `${s} GB`;
}
/** @param {number|null|undefined} v */
function formatGbAmountPlain(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (Number.isNaN(n)) return "";
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return String(Math.round(n * 100) / 100);
}
/** @param {Record<string, unknown>} p */
function paymentDescriptionDisplay(p) {
const r = p.traffic_regular_gb;
const pr = p.traffic_premium_gb;
if (r != null && pr == null) {
const gb = formatGbAmountPlain(r);
return at(
"payments_desc_traffic_package_regular",
{ gb },
`Пакет трафика ${gb} ГБ (обычный)`,
);
}
if (pr != null && r == null) {
const gb = formatGbAmountPlain(pr);
return at(
"payments_desc_traffic_package_premium",
{ gb },
`Пакет трафика ${gb} ГБ (премиум)`,
);
}
const raw = p.description && String(p.description).trim();
return raw || "—";
}
$: paymentHeaders = [
at("id", {}, "ID"),
at("user", {}, "Пользователь"),
at("payments_col_telegram_id", {}, "Telegram ID"),
at("payments_col_traffic_regular", {}, "Основной трафик"),
at("payments_col_traffic_premium", {}, "Премиум"),
at("amount", {}, "Сумма"),
at("provider", {}, "Провайдер"),
at("description", {}, "Описание"),
@@ -40,7 +94,11 @@
<div class="admin-table-wrap">
{#if paymentsLoading}
<AdminTableSkeleton headers={paymentHeaders} rows={8} widths={["48px", "120px", "78px", "82px", "180px", "72px", "96px"]} />
<AdminTableSkeleton
headers={paymentHeaders}
rows={8}
widths={["48px", "148px", "88px", "72px", "72px", "78px", "82px", "140px", "72px", "96px"]}
/>
{:else if !payments.length}
<AdminEmptyState tone="card"><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState>
{:else}
@@ -49,6 +107,9 @@
<tr>
<th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("payments_col_telegram_id", {}, "Telegram ID")}</th>
<th>{at("payments_col_traffic_regular", {}, "Основной трафик")}</th>
<th>{at("payments_col_traffic_premium", {}, "Премиум")}</th>
<th>{at("amount", {}, "Сумма")}</th>
<th>{at("provider", {}, "Провайдер")}</th>
<th>{at("description", {}, "Описание")}</th>
@@ -60,10 +121,33 @@
{#each payments as p}
<tr>
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
<td data-label={at("user", {}, "Пользователь")}>{p.user_label || p.user_id}</td>
<td class="admin-cell-user-with-action" data-label={at("user", {}, "Пользователь")}>
<span class="admin-payments-user-cell">
<AdminButton
class="admin-payments-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Открыть карточку пользователя")}
aria-label={at("payments_open_user", {}, "Открыть карточку пользователя")}
onclick={() => onOpenUserCard(p.user_id)}
>
<User size={14} />
</AdminButton>
<span class="admin-payments-user-name">{p.user_label || p.user_id}</span>
</span>
</td>
<td class="admin-cell-mono" data-label={at("payments_col_telegram_id", {}, "Telegram ID")}>
{p.telegram_id != null ? p.telegram_id : "—"}
</td>
<td class="admin-cell-traffic-gb" data-label={at("payments_col_traffic_regular", {}, "Основной трафик")}>
{formatTrafficGbCell(p.traffic_regular_gb)}
</td>
<td class="admin-cell-traffic-gb" data-label={at("payments_col_traffic_premium", {}, "Премиум")}>
{formatTrafficGbCell(p.traffic_premium_gb)}
</td>
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}>{p.description || "—"}</td>
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}>{paymentDescriptionDisplay(p)}</td>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
</td>
@@ -81,6 +165,47 @@
nextLabel={at("next", {}, "Далее")}
prevDisabled={paymentsPage === 0}
nextDisabled={!paymentsHasMore}
onPrev={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }}
onNext={() => { paymentsStore.setPage(paymentsPage + 1); }}
onPrev={() => {
paymentsStore.setPage(Math.max(0, paymentsPage - 1));
}}
onNext={() => {
paymentsStore.setPage(paymentsPage + 1);
}}
/>
<style>
.admin-payments-user-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-payments-user-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn.admin-btn) {
width: 30px;
height: 30px;
min-width: 30px;
min-height: 30px;
flex-shrink: 0;
padding: 0;
border-radius: 7px;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn svg) {
width: 14px;
height: 14px;
}
.admin-cell-traffic-gb {
font-size: 12px;
white-space: nowrap;
color: var(--admin-muted);
}
</style>
@@ -1,7 +1,7 @@
<script>
import { Label, Select, Separator, Tabs } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import { AdminBadge, AdminButton, AdminTrafficCard } from "$components/patterns/admin/index.js";
import { AdminBadge, AdminButton, AdminSectionHeader, AdminSelect, AdminTrafficCard } from "$components/patterns/admin/index.js";
import {
CalendarDays, Copy, CreditCard, ExternalLink, Eye, Info, Key,
Mail, Map, MessageSquare, MousePointerClick, QrCode, RefreshCw, Send,
@@ -41,6 +41,8 @@
userBanConfirmOpen,
userMessageConfirmOpen,
userDetailTab,
premiumUnlimitedDraft,
premiumBonusGbDraft,
} = $usersStore);
</script>
@@ -177,7 +179,17 @@
warning={openedUserDetail.active_subscription.is_throttled}
label={at("aria_label_main_traffic", {}, "Использование основного трафика")}
/>
{#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
{#if openedUserDetail.active_subscription.premium_unlimited_override}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
value={at("user_premium_unlimited_value", { used: trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) }, "∞ (использовано " + trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) + ")")}
left={at("user_premium_unlimited_hint", {}, "Безлимит (админ-оверрайд)")}
percent={0}
warning={false}
label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}
/>
{:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
@@ -249,6 +261,149 @@
</Label.Root>
</div>
{#if openedUserDetail?.active_subscription}
<section class="admin-user-action-sheet admin-user-action-sheet--premium-override">
<AdminSectionHeader
title={at("user_premium_override_card_title", {}, "Премиум-трафик")}
description={at("user_premium_override_card_hint", {}, "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.")}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span>
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={premiumUnlimitedDraft}
aria-label={at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}
bind:value={$usersStore.premiumBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<input type="checkbox" bind:checked={$usersStore.premiumUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton variant="primary" onclick={usersStore.savePremiumTrafficOverride} disabled={userActionBusy}>
{at("user_premium_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.premium_unlimited_override}
<span class="admin-meta-truncate">
{at("user_premium_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.premium_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_premium_override_status_bonus",
{ gb: +(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2) },
`Премиум сейчас: +${+(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
)}
</span>
{:else}
<span class="admin-muted">{at("user_premium_override_status_none", {}, "Премиум-оверрайд не задан")}</span>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--regular-override">
<AdminSectionHeader
title={at("user_regular_override_card_title", {}, "Основной трафик")}
description={at("user_regular_override_card_hint", {}, "Безлимит и постоянный бонус к лимиту основного трафика.")}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span>
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={$usersStore.regularUnlimitedDraft}
aria-label={at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}
bind:value={$usersStore.regularBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<input type="checkbox" bind:checked={$usersStore.regularUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton variant="primary" onclick={usersStore.saveRegularTrafficOverride} disabled={userActionBusy}>
{at("user_regular_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.regular_unlimited_override}
<span class="admin-meta-truncate">
{at("user_regular_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.regular_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_regular_override_status_bonus",
{ gb: +(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2) },
`Основной сейчас: +${+(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
)}
</span>
{:else}
<span class="admin-muted">{at("user_regular_override_status_none", {}, "Бонус основного трафика не задан")}</span>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
<AdminSectionHeader
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
description={at("user_traffic_grant_hint", {}, "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.")}
/>
<div class="admin-user-action-sheet-body admin-user-grant-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_kind", {}, "Тип трафика")}</span>
<AdminSelect
class="admin-grant-kind-select"
value={$usersStore.grantTrafficKindDraft}
items={[
{ value: "regular", label: at("user_traffic_grant_kind_regular", {}, "Обычный") },
{ value: "premium", label: at("user_traffic_grant_kind_premium", {}, "Премиум") },
]}
onValueChange={(v) => usersStore.updateState({ grantTrafficKindDraft: v })}
ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")}
/>
</Label.Root>
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
<div class="admin-extend-control">
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
aria-label={at("user_traffic_grant_gb", {}, "ГБ к выдаче")}
bind:value={$usersStore.grantTrafficGbDraft}
/>
<AdminButton variant="primary" onclick={usersStore.grantTraffic} disabled={userActionBusy}>
<Plus size={14} /> {at("user_traffic_grant_submit", {}, "Выдать")}
</AdminButton>
</div>
</Label.Root>
</div>
</section>
{/if}
<Label.Root class="admin-field-label">
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
<small>{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}</small>
@@ -339,3 +494,106 @@
</AdminButton>
</div>
</Dialog>
<style>
.admin-user-action-sheet {
border: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.08));
border-radius: 12px;
margin-bottom: 14px;
overflow: hidden;
background: var(--admin-surface-1, rgba(255, 255, 255, 0.02));
}
.admin-user-action-sheet :global(.admin-dashboard-section-head) {
padding: 12px 14px 10px;
margin: 0;
border-bottom: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.06));
}
.admin-user-action-sheet-body {
padding: 12px 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-user-override-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-grant-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-action-sheet-footer {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
padding: 4px 14px 12px;
}
.admin-override-card-footer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.admin-override-card-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
width: 100%;
}
.admin-override-card-toolbar :global(.admin-btn) {
flex: 0 0 auto;
min-height: 36px;
padding-left: 16px;
padding-right: 16px;
}
.admin-override-unlimited-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--admin-text, inherit);
cursor: pointer;
user-select: none;
min-height: 36px;
}
.admin-override-unlimited-label input[type="checkbox"] {
width: 18px;
height: 18px;
flex-shrink: 0;
}
@media (max-width: 520px) {
.admin-override-card-toolbar {
flex-direction: column;
align-items: stretch;
}
.admin-override-card-toolbar :global(.admin-btn) {
width: 100%;
}
}
.admin-override-status-lines {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.35;
}
:global(.admin-user-dialog .admin-actions-tab) {
padding-bottom: 14px;
}
.admin-user-action-sheet--regular-override {
margin-top: 10px;
}
.admin-user-action-sheet--traffic-grant {
margin-top: 10px;
}
.admin-user-action-sheet :global(.admin-grant-kind-select) {
width: 100%;
max-width: 100%;
}
</style>
@@ -23,6 +23,12 @@ export function createUsersStore({ api, onToast, at }) {
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userDetailTab: "profile",
premiumUnlimitedDraft: false,
premiumBonusGbDraft: "",
regularUnlimitedDraft: false,
regularBonusGbDraft: "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
});
let _activeRef = "stats"; // fallback if active isn't tracked
@@ -86,10 +92,19 @@ export function createUsersStore({ api, onToast, at }) {
try {
const res = await api(`/admin/users/${userId}`);
if (res?.ok) {
const sub = res.active_subscription || null;
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
state.update(s => ({
...s,
openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / (1024 ** 3)).toFixed(2) : "",
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / (1024 ** 3)).toFixed(2) : "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
}));
} else {
onToast(res?.error || "load_failed");
@@ -249,6 +264,103 @@ export function createUsersStore({ api, onToast, at }) {
}
}
async function savePremiumTrafficOverride() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const bonusGbRaw = s.premiumBonusGbDraft;
const bonusGb = bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
? 0
: Number(bonusGbRaw);
if (Number.isNaN(bonusGb) || bonusGb < 0) {
onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.premiumUnlimitedDraft),
bonus_gb: bonusGb,
}),
});
if (res?.ok) {
onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function saveRegularTrafficOverride() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const regGbRaw = s.regularBonusGbDraft;
const regularGb = regGbRaw === "" || regGbRaw === null || regGbRaw === undefined
? 0
: Number(regGbRaw);
if (Number.isNaN(regularGb) || regularGb < 0) {
onToast(at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика"));
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.regularUnlimitedDraft),
regular_bonus_gb: regularGb,
}),
});
if (res?.ok) {
onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function grantTraffic() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
const gbRaw = s.grantTrafficGbDraft;
const gb = Number(gbRaw);
if (!gbRaw || Number.isNaN(gb) || gb <= 0) {
onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB"));
return;
}
const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
method: "POST",
body: JSON.stringify({ kind, gb }),
});
if (res?.ok) {
onToast(
kind === "premium"
? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
: at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
);
state.update(st => ({ ...st, grantTrafficGbDraft: "" }));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function deleteUser() {
let s;
state.update(st => { s = st; return st; });
@@ -291,5 +403,8 @@ export function createUsersStore({ api, onToast, at }) {
extendUser,
resetTrialUser,
deleteUser,
savePremiumTrafficOverride,
saveRegularTrafficOverride,
grantTraffic,
};
}
@@ -267,6 +267,17 @@
flex: 0 0 auto;
}
.admin-extend-control .admin-btn.admin-btn-primary {
background: var(--accent);
color: #02110a;
border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600;
}
.admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 90%, #fff);
}
.admin-input-row .input,
.admin-input-row .admin-btn {
height: 36px;
@@ -100,7 +100,34 @@
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
</div>
</Card>
{#if Number(subscription?.premium_limit_bytes || 0) > 0}
{#if subscription?.premium_unlimited_override}
<Card class="premium-traffic-card">
<div class="traffic-top">
<span>{premiumTitle(subscription)}</span>
<strong></strong>
</div>
<div class="traffic-meta premium-traffic-meta">
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown">
<summary>
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
<ChevronsUpDown size={13} />
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
</details>
{:else}
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
{/if}
</div>
</Card>
{:else if Number(subscription?.premium_limit_bytes || 0) > 0}
<Card class={`${canOpenPremiumTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
{#if canOpenPremiumTopupModal}
<button class="card-click-target" type="button" onclick={openPremiumTopupModal} aria-label={premiumTitle(subscription)}></button>
+10 -1
View File
@@ -2113,10 +2113,13 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
sale_mode="premium_topup",
title_prefix=f"{tariff.premium_name(lang)} ",
) if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids else []
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
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)
+ premium_bonus_bytes
)
premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff)
return web.json_response(
@@ -2127,7 +2130,7 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
"topup_kind": topup_kind,
"premium_title": tariff.premium_name(lang),
"traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes),
"premium_traffic_percent": _traffic_percent(
"premium_traffic_percent": 0 if premium_unlimited_override else _traffic_percent(
sub.premium_used_bytes,
premium_limit_bytes,
),
@@ -2136,6 +2139,8 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
"premium_baseline_bytes": int(sub.premium_baseline_bytes or 0),
"premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0),
"premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0),
"premium_bonus_bytes": premium_bonus_bytes,
"premium_unlimited_override": premium_unlimited_override,
"premium_is_limited": bool(sub.premium_is_limited),
"premium_squad_labels": premium_access.get("squad_labels") or [],
"premium_node_labels": premium_access.get("node_labels") or [],
@@ -3278,6 +3283,10 @@ def _serialize_subscription(
"premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")),
"premium_topup_balance_bytes": _coerce_int_or_none(active.get("premium_topup_balance_bytes")),
"premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")),
"premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0,
"regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0,
"regular_unlimited_override": bool(active.get("regular_unlimited_override")),
"premium_unlimited_override": bool(active.get("premium_unlimited_override")),
"premium_is_limited": bool(active.get("premium_is_limited")),
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
"premium_node_labels": list(active.get("premium_node_labels") or []),