feat: show and filter premium squad status in admin panel users list
This commit is contained in:
+204
-10
@@ -19,7 +19,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from aiogram import Bot
|
||||
from aiohttp import web
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import func as sa_func, or_, select
|
||||
from sqlalchemy import and_, case, cast, Float, func as sa_func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -162,17 +162,60 @@ def _serialize_user(user: User) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
def _premium_limit_bytes_from_subscription(sub: Subscription) -> int:
|
||||
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 = (
|
||||
return (
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]:
|
||||
"""Premium traffic column when subscription has a finite premium quota (bytes > 0).
|
||||
|
||||
Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel
|
||||
routing, not 'tariff includes premium traffic' — do not use it here.
|
||||
"""
|
||||
|
||||
if sub is None:
|
||||
return {"state": "none"}
|
||||
if bool(getattr(sub, "premium_unlimited_override", False)):
|
||||
return {
|
||||
"state": "unlimited",
|
||||
"unlimited": True,
|
||||
"used_bytes": int(sub.premium_used_bytes or 0),
|
||||
"limit_bytes": None,
|
||||
"percent": None,
|
||||
}
|
||||
limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||
if limit_bytes <= 0:
|
||||
return {"state": "none"}
|
||||
used_bytes = int(sub.premium_used_bytes or 0)
|
||||
ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0
|
||||
pct = int(max(0, min(100, round(ratio * 100))))
|
||||
if ratio >= 1.0:
|
||||
state = "critical"
|
||||
elif ratio >= 0.85:
|
||||
state = "warn"
|
||||
else:
|
||||
state = "good"
|
||||
return {
|
||||
"state": state,
|
||||
"unlimited": False,
|
||||
"used_bytes": used_bytes,
|
||||
"limit_bytes": limit_bytes,
|
||||
"percent": pct,
|
||||
}
|
||||
|
||||
|
||||
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 = _premium_limit_bytes_from_subscription(sub)
|
||||
return {
|
||||
"subscription_id": int(sub.subscription_id),
|
||||
"panel_user_uuid": sub.panel_user_uuid,
|
||||
@@ -507,6 +550,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
query = (request.query.get("q") or "").strip()
|
||||
filter_value = (request.query.get("filter") or "all").lower()
|
||||
panel_status = (request.query.get("panel_status") or "all").lower()
|
||||
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
|
||||
sort_value = (request.query.get("sort") or "registered_desc").lower()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
@@ -515,6 +559,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
query=query,
|
||||
filter_value=filter_value,
|
||||
panel_status=panel_status,
|
||||
premium_traffic=premium_traffic,
|
||||
sort_value=sort_value,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -522,6 +567,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
|
||||
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])
|
||||
active_subs = await _bulk_active_subscriptions_for_users(session, [u.user_id for u in users])
|
||||
|
||||
serialized = []
|
||||
for user in users:
|
||||
@@ -535,6 +581,7 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
|
||||
if user.user_id in cached_avatar_ids
|
||||
else None
|
||||
)
|
||||
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
|
||||
serialized.append(payload)
|
||||
|
||||
return _ok(
|
||||
@@ -642,21 +689,115 @@ async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _ranked_active_subscriptions_sq(now: datetime):
|
||||
"""Latest active subscription per user (same ordering as subscription_dal)."""
|
||||
|
||||
rn = sa_func.row_number().over(
|
||||
partition_by=Subscription.user_id,
|
||||
order_by=(
|
||||
Subscription.end_date.desc(),
|
||||
Subscription.subscription_id.desc(),
|
||||
),
|
||||
)
|
||||
inner = (
|
||||
select(
|
||||
Subscription.user_id,
|
||||
Subscription.subscription_id,
|
||||
Subscription.premium_used_bytes,
|
||||
Subscription.premium_baseline_bytes,
|
||||
Subscription.premium_topup_balance_bytes,
|
||||
Subscription.premium_topup_used_bytes,
|
||||
Subscription.premium_bonus_bytes,
|
||||
Subscription.premium_unlimited_override,
|
||||
rn.label("rn"),
|
||||
)
|
||||
.where(
|
||||
Subscription.is_active.is_(True),
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub")
|
||||
|
||||
|
||||
async def _bulk_active_subscriptions_for_users(
|
||||
session: AsyncSession, user_ids: List[int]
|
||||
) -> Dict[int, Subscription]:
|
||||
"""Active subscription row per user (for admin list premium traffic column)."""
|
||||
|
||||
if not user_ids:
|
||||
return {}
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.is_active.is_(True),
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
.order_by(
|
||||
Subscription.user_id.asc(),
|
||||
Subscription.end_date.desc(),
|
||||
Subscription.subscription_id.desc(),
|
||||
)
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
out: Dict[int, Subscription] = {}
|
||||
for sub in rows:
|
||||
uid = int(sub.user_id)
|
||||
if uid not in out:
|
||||
out[uid] = sub
|
||||
return out
|
||||
|
||||
|
||||
async def _filter_and_sort_users(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
query: str = "",
|
||||
filter_value: str,
|
||||
panel_status: str = "all",
|
||||
premium_traffic: str = "all",
|
||||
sort_value: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[List[User], int]:
|
||||
"""Return paginated users with optional search, filter and sort applied."""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
sort_key = (sort_value or "registered_desc").lower()
|
||||
pt_filter = (premium_traffic or "all").lower()
|
||||
needs_premium_sq = pt_filter != "all" or sort_key in {
|
||||
"premium_ratio_asc",
|
||||
"premium_ratio_desc",
|
||||
}
|
||||
|
||||
stmt = select(User)
|
||||
count_stmt = select(sa_func.count(User.user_id))
|
||||
|
||||
sq = None
|
||||
ratio_expr = None
|
||||
plim_expr = None
|
||||
pu_expr = None
|
||||
|
||||
if needs_premium_sq:
|
||||
sq = _ranked_active_subscriptions_sq(now)
|
||||
stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
||||
count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id)
|
||||
pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0)
|
||||
plim_expr = (
|
||||
sa_func.coalesce(sq.c.premium_baseline_bytes, 0)
|
||||
+ sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0)
|
||||
+ sa_func.coalesce(sq.c.premium_topup_used_bytes, 0)
|
||||
+ pb
|
||||
)
|
||||
pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0)
|
||||
ratio_expr = case(
|
||||
(sq.c.user_id.is_(None), None),
|
||||
(sq.c.premium_unlimited_override.is_(True), None),
|
||||
(plim_expr <= 0, None),
|
||||
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
|
||||
)
|
||||
|
||||
search_cond = _user_search_condition(query)
|
||||
if search_cond is not None:
|
||||
stmt = stmt.where(search_cond)
|
||||
@@ -689,6 +830,53 @@ async def _filter_and_sort_users(
|
||||
stmt = stmt.where(panel_cond)
|
||||
count_stmt = count_stmt.where(panel_cond)
|
||||
|
||||
if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None:
|
||||
if pt_filter == "none":
|
||||
premium_cond = or_(
|
||||
sq.c.user_id.is_(None),
|
||||
and_(
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr <= 0,
|
||||
),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "unlimited":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(True),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "good":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
(100 * pu_expr) < (85 * plim_expr),
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "warn":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
(100 * pu_expr) >= (85 * plim_expr),
|
||||
pu_expr < plim_expr,
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
elif pt_filter == "critical":
|
||||
premium_cond = and_(
|
||||
sq.c.user_id.isnot(None),
|
||||
sq.c.premium_unlimited_override.is_(False),
|
||||
plim_expr > 0,
|
||||
pu_expr >= plim_expr,
|
||||
)
|
||||
stmt = stmt.where(premium_cond)
|
||||
count_stmt = count_stmt.where(premium_cond)
|
||||
|
||||
sort_map = {
|
||||
"registered_desc": User.registration_date.desc().nullslast(),
|
||||
"registered_asc": User.registration_date.asc().nullslast(),
|
||||
@@ -703,11 +891,17 @@ async def _filter_and_sort_users(
|
||||
"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)
|
||||
|
||||
if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc":
|
||||
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
|
||||
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
|
||||
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(order)
|
||||
order = sort_map.get(sort_key, 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))
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script>
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState, AdminPagination, AdminSelect } from "$components/patterns/admin/index.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminPagination,
|
||||
AdminSelect,
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { trafficOfLabel } from "../../lib/admin/format.js";
|
||||
|
||||
export let at = (key) => key;
|
||||
export let fmtDateShort = (value) => value;
|
||||
@@ -20,6 +29,7 @@
|
||||
usersQuery,
|
||||
usersFilter,
|
||||
usersPanelStatus,
|
||||
usersPremiumTraffic,
|
||||
usersSort,
|
||||
usersLoading,
|
||||
} = $usersStore);
|
||||
@@ -45,6 +55,8 @@
|
||||
{ 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 ↓") },
|
||||
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
|
||||
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") },
|
||||
];
|
||||
|
||||
const USERS_PANEL_STATUS_OPTIONS = [
|
||||
@@ -54,6 +66,37 @@
|
||||
{ value: "limited", label: at("status_limited", {}, "limited") },
|
||||
];
|
||||
|
||||
const USERS_PREMIUM_TRAFFIC_OPTIONS = [
|
||||
{ value: "all", label: at("premium_traffic_filter_all", {}, "Все (премиум)") },
|
||||
{ value: "none", label: at("premium_traffic_filter_none", {}, "Без лимита в тарифе") },
|
||||
{ value: "unlimited", label: at("premium_traffic_filter_unlimited", {}, "Безлимит (оверрайд)") },
|
||||
{ value: "good", label: at("premium_traffic_filter_good", {}, "Премиум: норма") },
|
||||
{ value: "warn", label: at("premium_traffic_filter_warn", {}, "Премиум: мало") },
|
||||
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
|
||||
];
|
||||
|
||||
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||
function premiumTrafficBadgeVariant(pt) {
|
||||
if (!pt || pt.state === "none") return "muted";
|
||||
if (pt.state === "unlimited" || pt.state === "good") return "success";
|
||||
if (pt.state === "warn") return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||
function premiumTrafficBadgeText(pt) {
|
||||
if (!pt || pt.state === "none") return "";
|
||||
if (pt.state === "unlimited") return trafficOfLabel(pt.used_bytes, 0);
|
||||
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
|
||||
}
|
||||
|
||||
$: userTableHeaders = [
|
||||
at("user", {}, "Пользователь"),
|
||||
at("premium_traffic_filter_label", {}, "Премиум трафик"),
|
||||
at("status", {}, "Статус"),
|
||||
at("users_col_registration", {}, "Регистрация"),
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
usersStore.loadUsers();
|
||||
});
|
||||
@@ -95,6 +138,17 @@
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field">
|
||||
<span class="admin-toolbar-field-label">{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span>
|
||||
<AdminSelect
|
||||
value={usersPremiumTraffic}
|
||||
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
|
||||
onValueChange={(value) => { usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 }); usersStore.loadUsers(); }}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field">
|
||||
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
|
||||
<AdminSelect
|
||||
@@ -113,53 +167,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<div class="admin-table-wrap admin-users-table-wrap">
|
||||
{#if usersLoading}
|
||||
<ul class="admin-user-list admin-user-list-skeleton" aria-hidden="true">
|
||||
{#each Array(USERS_PAGE_SIZE) as _, i (i)}
|
||||
<li>
|
||||
<div class="admin-user-row admin-user-row-skeleton">
|
||||
<span class="admin-skeleton admin-skeleton-avatar"></span>
|
||||
<span class="admin-user-main">
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
|
||||
</span>
|
||||
<span class="admin-user-side">
|
||||
<span class="admin-skeleton admin-skeleton-badge"></span>
|
||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<AdminTableSkeleton
|
||||
headers={userTableHeaders}
|
||||
rows={USERS_PAGE_SIZE}
|
||||
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
|
||||
/>
|
||||
{:else if !users.length}
|
||||
<AdminEmptyState tone="card"><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span></AdminEmptyState>
|
||||
{:else}
|
||||
<ul class="admin-user-list">
|
||||
{#each users as user}
|
||||
{@const avatar = resolvedAvatarUrl(user)}
|
||||
{@const badge = panelStatusBadge(user)}
|
||||
<li>
|
||||
<button type="button" class="admin-user-row" on:click={() => usersStore.openUser(user)}>
|
||||
<span class="admin-avatar admin-avatar-sm">
|
||||
{#if avatar}
|
||||
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
<AdminTable class="admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("user", {}, "Пользователь")}</th>
|
||||
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
|
||||
<th>{at("status", {}, "Статус")}</th>
|
||||
<th>{at("users_col_registration", {}, "Регистрация")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user}
|
||||
{@const avatar = resolvedAvatarUrl(user)}
|
||||
{@const badge = panelStatusBadge(user)}
|
||||
<tr
|
||||
class="is-clickable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-user-id={user.user_id}
|
||||
on:click={() => usersStore.openUser(user)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
usersStore.openUser(user);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td class="admin-users-cell-user" data-label={at("user", {}, "Пользователь")}>
|
||||
<div class="admin-users-cell-user-inner">
|
||||
<span class="admin-avatar admin-avatar-sm">
|
||||
{#if avatar}
|
||||
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
<span>{userInitials(user)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="admin-users-cell-user-text">
|
||||
<span class="admin-users-cell-name">{userDisplayName(user)}</span>
|
||||
<span class="admin-users-cell-secondary">{userSecondaryName(user)}</span>
|
||||
<span class="admin-users-cell-id">#{user.user_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="admin-users-cell-premium" data-label={at("premium_traffic_filter_label", {}, "Премиум трафик")}>
|
||||
{#if user.premium_traffic && user.premium_traffic.state !== "none"}
|
||||
<AdminBadge variant={premiumTrafficBadgeVariant(user.premium_traffic)} class="admin-user-premium-badge">
|
||||
{premiumTrafficBadgeText(user.premium_traffic)}
|
||||
</AdminBadge>
|
||||
{:else}
|
||||
<span>{userInitials(user)}</span>
|
||||
<span class="admin-user-premium-placeholder">{at("premium_traffic_na", {}, "—")}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="admin-user-main">
|
||||
<strong>{userDisplayName(user)}</strong>
|
||||
<small>{userSecondaryName(user)}</small>
|
||||
</span>
|
||||
<span class="admin-user-side">
|
||||
</td>
|
||||
<td data-label={at("status", {}, "Статус")}>
|
||||
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
||||
<span class="admin-user-tertiary">{fmtDateShort(user.registration_date)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</td>
|
||||
<td class="admin-users-cell-date admin-cell-mono" data-label={at("users_col_registration", {}, "Регистрация")}>
|
||||
{fmtDateShort(user.registration_date)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -172,3 +250,72 @@
|
||||
onPrev={() => { usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) }); usersStore.loadUsers(); }}
|
||||
onNext={() => { usersStore.updateState({ usersPage: usersPage + 1 }); usersStore.loadUsers(); }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.admin-users-cell-user-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-users-cell-user-text {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-users-cell-name {
|
||||
font-weight: 650;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-cell-secondary {
|
||||
font-size: 11px;
|
||||
color: var(--admin-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-cell-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-users-cell-premium {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-cell-premium :global(.admin-user-premium-badge) {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-user-premium-placeholder {
|
||||
color: var(--admin-dim);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-users-cell-date {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table tbody tr.is-clickable:focus-visible) {
|
||||
outline: 2px solid var(--admin-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
usersQuery: "",
|
||||
usersFilter: "all",
|
||||
usersPanelStatus: "all",
|
||||
usersPremiumTraffic: "all",
|
||||
usersSort: "registered_desc",
|
||||
usersLoading: false,
|
||||
|
||||
@@ -59,6 +60,9 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
|
||||
if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter);
|
||||
if (s.usersPanelStatus && s.usersPanelStatus !== "all") params.set("panel_status", s.usersPanelStatus);
|
||||
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
|
||||
params.set("premium_traffic", s.usersPremiumTraffic);
|
||||
}
|
||||
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
|
||||
const data = await api(`/admin/users?${params.toString()}`);
|
||||
if (data?.ok) {
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-24T10:20:00Z",
|
||||
is_banned: false,
|
||||
premium_traffic: { state: "good", unlimited: false, used_bytes: 4 * 1073741824, limit_bytes: 25 * 1073741824, percent: 16 },
|
||||
},
|
||||
{
|
||||
user_id: 100200301,
|
||||
@@ -34,6 +35,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-26T08:15:00Z",
|
||||
is_banned: false,
|
||||
premium_traffic: { state: "warn", unlimited: false, used_bytes: 22 * 1073741824, limit_bytes: 25 * 1073741824, percent: 88 },
|
||||
},
|
||||
{
|
||||
user_id: 100200302,
|
||||
@@ -45,6 +47,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-29T16:45:00Z",
|
||||
is_banned: true,
|
||||
premium_traffic: { state: "none" },
|
||||
},
|
||||
];
|
||||
if (path === "/admin/stats") {
|
||||
|
||||
@@ -835,7 +835,7 @@
|
||||
|
||||
.admin-toolbar-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto);
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr)) minmax(96px, auto);
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -881,7 +881,7 @@
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-toolbar-controls {
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(130px, 1fr));
|
||||
}
|
||||
|
||||
.admin-toolbar-summary {
|
||||
@@ -1000,7 +1000,7 @@
|
||||
|
||||
.admin-user-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 36px minmax(0, 1fr) minmax(72px, max-content) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
@@ -1023,6 +1023,28 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.admin-user-premium-col {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-premium-badge {
|
||||
max-width: 148px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-user-premium-placeholder {
|
||||
color: var(--admin-dim);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-user-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user