feat: tune visual of web app admin panel
This commit is contained in:
@@ -33,8 +33,10 @@ from bot.services.settings_override_service import (
|
||||
current_value,
|
||||
update_overrides,
|
||||
)
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.utils import MessageContent, send_message_via_queue
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from urllib.parse import parse_qsl, urlsplit, urlunsplit
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import TariffsConfig
|
||||
from db.dal import (
|
||||
@@ -523,6 +525,7 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
@@ -548,6 +551,50 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
|
||||
|
||||
# Referral links — both the bot deep-link and the webapp deep-link.
|
||||
referral_code: Optional[str] = None
|
||||
try:
|
||||
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||
await session.commit()
|
||||
except Exception as exc_ref: # pragma: no cover — defensive
|
||||
logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref)
|
||||
await session.rollback()
|
||||
|
||||
referral_service: Optional[ReferralService] = request.app.get("referral_service")
|
||||
bot_username = request.app.get("bot_username") or ""
|
||||
referral_bot_link: Optional[str] = None
|
||||
if referral_service and bot_username and referral_code:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
referral_bot_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, target_id
|
||||
)
|
||||
except Exception as exc_link: # pragma: no cover
|
||||
logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link)
|
||||
referral_webapp_link = _build_admin_webapp_referral_link(
|
||||
getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None),
|
||||
referral_code,
|
||||
)
|
||||
|
||||
# Subscription page URL — the raw panel `subscriptionUrl` that the user
|
||||
# imports into their VPN client. May be missing if the user has never
|
||||
# been provisioned on the panel.
|
||||
subscription_url: Optional[str] = None
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None)
|
||||
if panel_uuid:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
if panel_service is not None:
|
||||
try:
|
||||
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
if panel_data:
|
||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
||||
except Exception as exc_panel: # pragma: no cover
|
||||
logger.warning(
|
||||
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
|
||||
target_id, panel_uuid, exc_panel,
|
||||
)
|
||||
|
||||
serialized_user = _serialize_user(user)
|
||||
serialized_user["avatar_url"] = (
|
||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
||||
@@ -563,10 +610,33 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
"total_paid": float(total_paid),
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
"log_count": int(log_count or 0),
|
||||
"subscription_url": subscription_url,
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
"webapp_link": referral_webapp_link,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_admin_webapp_referral_link(
|
||||
base_url: Optional[str], referral_code: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Mirror of ``subscription_webapp._build_webapp_referral_link``.
|
||||
|
||||
Kept local to avoid a cross-module import cycle (subscription_webapp
|
||||
imports admin_api).
|
||||
"""
|
||||
if not base_url or not referral_code:
|
||||
return None
|
||||
parts = urlsplit(base_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
new_query = "&".join(f"{k}={v}" for k, v in query.items())
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
||||
|
||||
|
||||
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
@@ -1273,13 +1343,13 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_get("/api/admin/stats", admin_stats_route)
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:\\d+}", admin_user_detail_route)
|
||||
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+}/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)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
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+}/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)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
telegramLoginBotId: 1234567890,
|
||||
telegramOAuthClientId: 1234567890,
|
||||
telegramOAuthRequestAccess: ["write"],
|
||||
appVersion: "dev+local",
|
||||
appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop",
|
||||
},
|
||||
data: {
|
||||
ok: true,
|
||||
@@ -759,18 +761,25 @@
|
||||
|
||||
function adminSectionFromPath(pathname) {
|
||||
const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)$/);
|
||||
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/);
|
||||
if (m && ADMIN_SECTIONS.has(m[1])) return m[1];
|
||||
return "stats";
|
||||
}
|
||||
|
||||
function syncSectionPath(section, replace = false, adminSection = null) {
|
||||
function adminUserIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/users\/(-?\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
|
||||
if (window.location.protocol === "file:") return;
|
||||
const normalized = normalizeSection(section);
|
||||
let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
|
||||
if (normalized === "admin") {
|
||||
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
|
||||
targetPath = `/admin/${adm}`;
|
||||
const uid = adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
|
||||
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
|
||||
}
|
||||
if (window.location.pathname === targetPath) return;
|
||||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||
@@ -1117,6 +1126,12 @@
|
||||
{ payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" },
|
||||
],
|
||||
log_count: 18,
|
||||
subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP",
|
||||
referral: {
|
||||
code: "ABCD1234",
|
||||
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
|
||||
webapp_link: "https://app.example.com/?ref=uABCD1234",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/tariffs") {
|
||||
@@ -2180,10 +2195,12 @@
|
||||
syncSectionPath("settings");
|
||||
}
|
||||
|
||||
function handleAdminSectionChange(adminSection) {
|
||||
function handleAdminSectionChange(adminSection, adminUserId = null) {
|
||||
if (screen !== "admin") return;
|
||||
if (window.location.protocol === "file:") return;
|
||||
const targetPath = `/admin/${adminSection}`;
|
||||
const targetPath = adminSection === "users" && adminUserId
|
||||
? `/admin/users/${adminUserId}`
|
||||
: `/admin/${adminSection}`;
|
||||
if (window.location.pathname === targetPath) return;
|
||||
window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`);
|
||||
}
|
||||
@@ -2937,9 +2954,15 @@
|
||||
onClose={closeAdminPanel}
|
||||
onToast={(text) => showToast(text)}
|
||||
initialSection={adminSectionFromPath(window.location.pathname)}
|
||||
initialUserId={adminUserIdFromPath(window.location.pathname)}
|
||||
onSectionChange={handleAdminSectionChange}
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
onTariffsSaved={handleTariffsSaved}
|
||||
brandTitle={brandTitle}
|
||||
logoUrl={CFG.logoUrl}
|
||||
logoEmoji={brandEmoji}
|
||||
appVersion={CFG.appVersion}
|
||||
appRepositoryUrl={CFG.appRepositoryUrl}
|
||||
/>
|
||||
{:else}
|
||||
<div class="phone-screen" class:home-screen={screen === "home"}>
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
CreditCard,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Link2,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
Menu,
|
||||
@@ -35,15 +38,22 @@
|
||||
import { onMount } from "svelte";
|
||||
import { Accordion, Label, Select, Separator, Switch, Tabs } from "bits-ui";
|
||||
|
||||
import BrandMark from "../BrandMark.svelte";
|
||||
import Dialog from "../lib/components/ui/dialog.svelte";
|
||||
|
||||
export let api;
|
||||
export let onClose = () => {};
|
||||
export let onToast = () => {};
|
||||
export let initialSection = "stats";
|
||||
export let initialUserId = null;
|
||||
export let onSectionChange = () => {};
|
||||
export let onSettingsSaved = () => {};
|
||||
export let onTariffsSaved = () => {};
|
||||
export let brandTitle = "/minishop";
|
||||
export let logoUrl = "";
|
||||
export let logoEmoji = "рџ«Ґ";
|
||||
export let appVersion = "dev+local";
|
||||
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
@@ -202,22 +212,41 @@
|
||||
}
|
||||
active = next;
|
||||
sidebarOpen = false;
|
||||
if (openedUser) {
|
||||
openedUser = null;
|
||||
openedUserDetail = null;
|
||||
userDeleteOpen = false;
|
||||
userBanConfirmOpen = false;
|
||||
}
|
||||
onSectionChange(next);
|
||||
loadActive();
|
||||
}
|
||||
|
||||
function _readSectionFromPath() {
|
||||
if (typeof window === "undefined") return "stats";
|
||||
const m = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)$/i);
|
||||
const m = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/i);
|
||||
return _normalizeSection(m ? m[1].toLowerCase() : "stats");
|
||||
}
|
||||
|
||||
function _readUserIdFromPath() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const m = window.location.pathname.match(/^\/admin\/users\/(-?\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
function _onPopState() {
|
||||
const next = _readSectionFromPath();
|
||||
if (active === next) return;
|
||||
active = next;
|
||||
sidebarOpen = false;
|
||||
loadActive();
|
||||
if (active !== next) {
|
||||
active = next;
|
||||
sidebarOpen = false;
|
||||
loadActive();
|
||||
}
|
||||
const uid = _readUserIdFromPath();
|
||||
if (uid) {
|
||||
if (!openedUser || openedUser.user_id !== uid) openUser(uid, { skipPush: true });
|
||||
} else if (openedUser) {
|
||||
closeUser({ skipPush: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActive() {
|
||||
@@ -282,31 +311,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openUser(user) {
|
||||
openedUser = user;
|
||||
async function openUser(userOrId, opts = {}) {
|
||||
const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
|
||||
if (!userId) return;
|
||||
openedUser = typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId };
|
||||
openedUserDetail = null;
|
||||
userMessageDraft = "";
|
||||
userExtendDays = 30;
|
||||
userDetailLoading = true;
|
||||
userDetailTab = "profile";
|
||||
userDetailTab = "subscription";
|
||||
if (!opts.skipPush) _pushUserPath(userId);
|
||||
try {
|
||||
const res = await api(`/admin/users/${user.user_id}`);
|
||||
const res = await api(`/admin/users/${userId}`);
|
||||
if (res?.ok) {
|
||||
openedUserDetail = res;
|
||||
if (res.user) openedUser = { ...res.user, ...openedUser, ...res.user };
|
||||
} else {
|
||||
flash(res?.error || "load_failed");
|
||||
openedUser = null;
|
||||
if (!opts.skipPush) _pushUserPath(null);
|
||||
}
|
||||
} finally {
|
||||
userDetailLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeUser() {
|
||||
function closeUser(opts = {}) {
|
||||
const wasOpen = Boolean(openedUser);
|
||||
openedUser = null;
|
||||
openedUserDetail = null;
|
||||
userDeleteOpen = false;
|
||||
userBanConfirmOpen = false;
|
||||
if (wasOpen && !opts.skipPush) _pushUserPath(null);
|
||||
}
|
||||
|
||||
function _pushUserPath(userId) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.location.protocol === "file:") return;
|
||||
if (active !== "users") return;
|
||||
const target = userId ? `/admin/users/${userId}` : `/admin/users`;
|
||||
if (window.location.pathname === target) return;
|
||||
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
|
||||
}
|
||||
|
||||
function copyToClipboard(text, successMessage = "Ссылка скопирована") {
|
||||
if (!text) return;
|
||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => flash(successMessage),
|
||||
() => flash(text),
|
||||
);
|
||||
} else {
|
||||
flash(text);
|
||||
}
|
||||
}
|
||||
|
||||
function copyUserDeepLink() {
|
||||
if (!openedUser || typeof window === "undefined") return;
|
||||
const url = `${window.location.origin}/admin/users/${openedUser.user_id}`;
|
||||
copyToClipboard(url);
|
||||
}
|
||||
|
||||
function requestBanToggle() {
|
||||
@@ -369,7 +432,7 @@
|
||||
});
|
||||
if (res?.ok) {
|
||||
flash(`Подписка продлена на ${days} дн.`);
|
||||
await openUser(openedUser);
|
||||
await openUser(openedUser, { skipPush: true });
|
||||
} else flash(res?.error || "Ошибка");
|
||||
} finally {
|
||||
userActionBusy = false;
|
||||
@@ -1212,6 +1275,9 @@
|
||||
window.addEventListener("popstate", _onPopState);
|
||||
}
|
||||
loadActive();
|
||||
if (active === "users" && initialUserId) {
|
||||
openUser(initialUserId, { skipPush: true });
|
||||
}
|
||||
return () => {
|
||||
if (_compactMql) {
|
||||
if (_compactMql.removeEventListener) _compactMql.removeEventListener("change", _onCompactChange);
|
||||
@@ -1322,10 +1388,10 @@
|
||||
|
||||
<aside class="admin-sidebar" aria-label="Навигация админки">
|
||||
<div class="admin-sidebar-brand">
|
||||
<span class="admin-brand-mark"><Shield size={18} /></span>
|
||||
<BrandMark class="admin-brand-mark" logoUrl={logoUrl} emoji={logoEmoji} />
|
||||
<div>
|
||||
<strong>Админ-панель</strong>
|
||||
<small>Web App</small>
|
||||
<strong class="admin-brand-title">{brandTitle}</strong>
|
||||
<small>Админ-панель</small>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn-icon admin-btn-ghost" on:click={onClose} aria-label="Выйти">
|
||||
<ArrowLeft size={16} />
|
||||
@@ -1351,8 +1417,16 @@
|
||||
{/each}
|
||||
|
||||
<div class="admin-sidebar-footer">
|
||||
<span>Минприложение</span>
|
||||
<span>v1 · {new Date().getFullYear()}</span>
|
||||
<a
|
||||
class="admin-version-link"
|
||||
href={appRepositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="GitHub"
|
||||
>
|
||||
<span>remnawave-minishop</span>
|
||||
<span>{appVersion || "dev+local"}</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1996,14 +2070,14 @@
|
||||
<Tabs.Content value="general" class="admin-tabs-content">
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>Ключ</span>
|
||||
<small>Стабильный ID для платежей и подписок</small>
|
||||
<span>Ключ тарифа</span>
|
||||
<small>Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется</small>
|
||||
<input class="input" type="text" placeholder="standard" bind:value={tariffDraft.key} />
|
||||
</Label.Root>
|
||||
|
||||
<div class="admin-field-label">
|
||||
<span>Модель тарификации</span>
|
||||
<small>Период — фикс. длительность; Трафик — оплата за GB</small>
|
||||
<small><b>Период</b> — пользователь покупает фиксированный срок (1/3/12 мес. и т.д.). <b>Трафик</b> — пользователь покупает пакеты гигабайт по фиксированной цене за GB</small>
|
||||
<Select.Root type="single" bind:value={tariffDraft.billing_model}>
|
||||
<Select.Trigger class="admin-select-trigger" aria-label="Модель">
|
||||
<span>{tariffDraft.billing_model === "traffic" ? "Трафик" : "Период"}</span>
|
||||
@@ -2034,8 +2108,8 @@
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
<Label.Root class="admin-action-label">
|
||||
<strong>{tariffDraft.enabled ? "Тариф включён" : "Тариф выключен"}</strong>
|
||||
<small>Скрытые тарифы не отображаются на витрине</small>
|
||||
<strong>{tariffDraft.enabled ? "Тариф виден на витрине" : "Тариф скрыт от пользователей"}</strong>
|
||||
<small>Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать</small>
|
||||
</Label.Root>
|
||||
</div>
|
||||
|
||||
@@ -2062,8 +2136,8 @@
|
||||
</div>
|
||||
|
||||
<div class="admin-field-label">
|
||||
<span>Основные Internal Squads</span>
|
||||
<small>{panelSquadsLoading ? "Загружаю список из панели…" : "Выберите сквады из Remnawave"}</small>
|
||||
<span>Базовые Internal Squads</span>
|
||||
<small>{panelSquadsLoading ? "Загружаю список из панели…" : "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"}</small>
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={selectedBaseSquad}
|
||||
@@ -2098,20 +2172,20 @@
|
||||
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>Базовый лимит устройств</span>
|
||||
<small>Пусто — значение из env, 0 — безлимит</small>
|
||||
<span>Лимит устройств (HWID)</span>
|
||||
<small>Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, <code>0</code> — без ограничений</small>
|
||||
<input class="input" type="number" min="0" placeholder="5" bind:value={tariffDraft.hwid_device_limit} />
|
||||
</Label.Root>
|
||||
{#if tariffDraft.billing_model === "period"}
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>Месячный лимит, GB</span>
|
||||
<small>0 — безлимит</small>
|
||||
<input class="input" type="number" min="0" step="0.1" bind:value={tariffDraft.monthly_gb} />
|
||||
<span>Месячный лимит трафика, GB</span>
|
||||
<small>Сколько GB включено в тариф на каждый месяц. <code>0</code> — безлимитный трафик. Сверху можно докупать пакеты на вкладке «Докупки»</small>
|
||||
<input class="input" type="number" min="0" step="0.1" placeholder="100" bind:value={tariffDraft.monthly_gb} />
|
||||
</Label.Root>
|
||||
{:else}
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>Курс конвертации, RUB/GB</span>
|
||||
<small>Нужен для перехода period → traffic</small>
|
||||
<span>Курс конвертации, ₽ за 1 GB</span>
|
||||
<small>По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»</small>
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="20" bind:value={tariffDraft.conversion_rate_rub_per_gb} />
|
||||
</Label.Root>
|
||||
{/if}
|
||||
@@ -2121,12 +2195,15 @@
|
||||
<Tabs.Content value="premium" class="admin-tabs-content">
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<strong>Premium-сквад и отдельный лимит</strong>
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>Premium-доступ и отдельный счётчик трафика</strong>
|
||||
<small>Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно</small>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<div class="admin-field-label">
|
||||
<span>Premium Internal Squads</span>
|
||||
<small>Ноды для учета трафика будут взяты из accessible nodes этих сквадов</small>
|
||||
<small>Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes</small>
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={selectedPremiumSquad}
|
||||
@@ -2159,8 +2236,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>Premium лимит, GB/мес.</span>
|
||||
<small>0 или пусто — нет отдельного premium-лимита</small>
|
||||
<span>Месячный лимит premium-трафика, GB</span>
|
||||
<small>Сколько GB через premium-сквады включено в тариф каждый месяц. <code>0</code> или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)</small>
|
||||
<input class="input" type="number" min="0" step="0.1" placeholder="50" bind:value={tariffDraft.premium_monthly_gb} />
|
||||
</Label.Root>
|
||||
</div>
|
||||
@@ -2168,29 +2245,46 @@
|
||||
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<strong>Докупка premium-трафика</strong>
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>Докупка premium-трафика</strong>
|
||||
<small>Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал</small>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ₽</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ⭐</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">RUB</span>
|
||||
<span class="admin-row-editor-caption">Оплата рублями</span>
|
||||
{#if tariffDraft.premiumTopupRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>Объём, GB</span>
|
||||
<span>Цена, ₽</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.premiumTopupRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" />
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="10" bind:value={row.gb} aria-label="Объём premium-пакета в GB" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="199" bind:value={row.price} aria-label="Цена premium-пакета в рублях" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">Stars</span>
|
||||
<span class="admin-row-editor-caption">Оплата Telegram Stars</span>
|
||||
{#if tariffDraft.premiumTopupStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>Объём, GB</span>
|
||||
<span>Цена, ⭐</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.premiumTopupStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" />
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="10" bind:value={row.gb} aria-label="Объём premium-пакета в GB" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="100" bind:value={row.price} aria-label="Цена premium-пакета в Telegram Stars" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -2203,53 +2297,80 @@
|
||||
{#if tariffDraft.billing_model === "period"}
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<strong>Периоды и цены</strong>
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>Периоды подписки и цены</strong>
|
||||
<small>Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит</small>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}>
|
||||
<Plus size={13} /> Период
|
||||
</button>
|
||||
</header>
|
||||
{#if !tariffDraft.periodRows.length}
|
||||
<p class="admin-muted">Добавьте хотя бы один период.</p>
|
||||
{/if}
|
||||
<div class="admin-row-editor">
|
||||
{#each tariffDraft.periodRows as row, index}
|
||||
<div class="admin-row-editor-line admin-row-editor-4">
|
||||
<input class="input" type="number" min="1" placeholder="Мес." bind:value={row.months} aria-label="Период (месяцы)" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="RUB" bind:value={row.rub} aria-label="Цена RUB" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.stars} aria-label="Цена Stars" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("periodRows", index)} aria-label="Удалить">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<p class="admin-muted">Добавьте хотя бы один период — без него тариф не появится на витрине.</p>
|
||||
{:else}
|
||||
<div class="admin-row-editor">
|
||||
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
|
||||
<span>Срок, мес.</span>
|
||||
<span>Цена, ₽</span>
|
||||
<span>Цена, ⭐ Stars</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#each tariffDraft.periodRows as row, index}
|
||||
<div class="admin-row-editor-line admin-row-editor-4">
|
||||
<input class="input" type="number" min="1" placeholder="1" bind:value={row.months} aria-label="Срок (месяцы)" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="299" bind:value={row.rub} aria-label="Цена в рублях" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="150" bind:value={row.stars} aria-label="Цена в Telegram Stars" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("periodRows", index)} aria-label="Удалить">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<strong>Пакеты трафика</strong>
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>Пакеты трафика</strong>
|
||||
<small>Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»</small>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficRubRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ₽</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("trafficStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ⭐</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">RUB</span>
|
||||
<span class="admin-row-editor-caption">Оплата рублями</span>
|
||||
{#if tariffDraft.trafficRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>Объём, GB</span>
|
||||
<span>Цена, ₽</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.trafficRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" />
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="50" bind:value={row.gb} aria-label="Объём пакета в GB" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="299" bind:value={row.price} aria-label="Цена пакета в рублях" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("trafficRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">Stars</span>
|
||||
<span class="admin-row-editor-caption">Оплата Telegram Stars</span>
|
||||
{#if tariffDraft.trafficStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>Объём, GB</span>
|
||||
<span>Цена, ⭐</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.trafficStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" />
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="50" bind:value={row.gb} aria-label="Объём пакета в GB" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="150" bind:value={row.price} aria-label="Цена пакета в Telegram Stars" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("trafficStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -2263,29 +2384,46 @@
|
||||
{#if tariffDraft.billing_model === "period"}
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<strong>Докупка трафика для тарифа</strong>
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>Докупка трафика поверх месячного лимита</strong>
|
||||
<small>Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки</small>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupRubRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ₽</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("topupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Пакет ⭐</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">RUB</span>
|
||||
<span class="admin-row-editor-caption">Оплата рублями</span>
|
||||
{#if tariffDraft.topupRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>Объём, GB</span>
|
||||
<span>Цена, ₽</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.topupRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" />
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="20" bind:value={row.gb} aria-label="Объём пакета в GB" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="149" bind:value={row.price} aria-label="Цена пакета в рублях" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("topupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">Stars</span>
|
||||
<span class="admin-row-editor-caption">Оплата Telegram Stars</span>
|
||||
{#if tariffDraft.topupStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>Объём, GB</span>
|
||||
<span>Цена, ⭐</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.topupStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Объём GB" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" />
|
||||
<input class="input" type="number" min="0.1" step="0.1" placeholder="20" bind:value={row.gb} aria-label="Объём пакета в GB" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="75" bind:value={row.price} aria-label="Цена пакета в Telegram Stars" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("topupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -2293,36 +2431,53 @@
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<p class="admin-muted">Для трафиковой модели докупки не нужны — настройте пакеты трафика на вкладке «Цены».</p>
|
||||
<p class="admin-muted">Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания.</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="hwid" class="admin-tabs-content">
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<strong>Пакеты HWID-устройств</strong>
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>Пакеты дополнительных устройств (HWID)</strong>
|
||||
<small>Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»</small>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidRubRows", { count: 1, price: "" })}><Plus size={12} /> RUB</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidStarsRows", { count: 1, price: "" })}><Plus size={12} /> Stars</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidRubRows", { count: 1, price: "" })}><Plus size={12} /> Пакет ₽</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("hwidStarsRows", { count: 1, price: "" })}><Plus size={12} /> Пакет ⭐</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">RUB</span>
|
||||
<span class="admin-row-editor-caption">Оплата рублями</span>
|
||||
{#if tariffDraft.hwidRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>+ устройств</span>
|
||||
<span>Цена, ₽</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.hwidRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="1" step="1" placeholder="Шт." bind:value={row.count} aria-label="Количество устройств" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" />
|
||||
<input class="input" type="number" min="1" step="1" placeholder="1" bind:value={row.count} aria-label="Сколько устройств добавляет пакет" />
|
||||
<input class="input" type="number" min="0" step="0.01" placeholder="99" bind:value={row.price} aria-label="Цена пакета в рублях" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("hwidRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">Stars</span>
|
||||
<span class="admin-row-editor-caption">Оплата Telegram Stars</span>
|
||||
{#if tariffDraft.hwidStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>+ устройств</span>
|
||||
<span>Цена, ⭐</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.hwidStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input class="input" type="number" min="1" step="1" placeholder="Шт." bind:value={row.count} aria-label="Количество устройств" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" />
|
||||
<input class="input" type="number" min="1" step="1" placeholder="1" bind:value={row.count} aria-label="Сколько устройств добавляет пакет" />
|
||||
<input class="input" type="number" min="0" step="1" placeholder="50" bind:value={row.price} aria-label="Цена пакета в Telegram Stars" />
|
||||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("hwidStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -2368,54 +2523,114 @@
|
||||
{#if userDetailLoading || !openedUserDetail}
|
||||
<p class="admin-muted">Загрузка…</p>
|
||||
{:else}
|
||||
<div class="admin-user-summary">
|
||||
<span class="admin-avatar admin-avatar-lg">
|
||||
{#if resolvedAvatarUrl(openedUser)}
|
||||
<img src={resolvedAvatarUrl(openedUser)} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
<span>{userInitials(openedUser)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="admin-user-summary-meta">
|
||||
<strong>{userDisplayName(openedUser)}</strong>
|
||||
<small>{userSecondaryName(openedUser)}</small>
|
||||
<div class="admin-user-summary-tags">
|
||||
{#if openedUser.is_banned}
|
||||
<span class="admin-badge admin-badge-danger">Бан</span>
|
||||
{:else}
|
||||
<span class="admin-badge admin-badge-success">Активен</span>
|
||||
{/if}
|
||||
{#if openedUserDetail.active_subscription}
|
||||
<span class="admin-badge admin-badge-success">Подписка</span>
|
||||
{:else}
|
||||
<span class="admin-badge admin-badge-muted">Без подписки</span>
|
||||
{/if}
|
||||
<span class="admin-badge admin-badge-muted">Заплачено: {fmtMoney(openedUserDetail.total_paid)}</span>
|
||||
<div class="admin-user-dialog-body">
|
||||
<aside class="admin-user-aside">
|
||||
<div class="admin-user-summary">
|
||||
<span class="admin-avatar admin-avatar-lg">
|
||||
{#if resolvedAvatarUrl(openedUser)}
|
||||
<img src={resolvedAvatarUrl(openedUser)} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
<span>{userInitials(openedUser)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="admin-user-summary-meta">
|
||||
<strong>{userDisplayName(openedUser)}</strong>
|
||||
<small>{userSecondaryName(openedUser)}</small>
|
||||
<div class="admin-user-summary-tags">
|
||||
{#if openedUser.is_banned}
|
||||
<span class="admin-badge admin-badge-danger">Бан</span>
|
||||
{:else}
|
||||
<span class="admin-badge admin-badge-success">Активен</span>
|
||||
{/if}
|
||||
{#if openedUserDetail.active_subscription}
|
||||
<span class="admin-badge admin-badge-success">Подписка</span>
|
||||
{:else}
|
||||
<span class="admin-badge admin-badge-muted">Без подписки</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={userDetailTab} class="admin-tabs-root">
|
||||
<Tabs.List class="admin-tabs-list">
|
||||
<Tabs.Trigger value="profile" class="admin-tabs-trigger">Профиль</Tabs.Trigger>
|
||||
<Tabs.Trigger value="subscription" class="admin-tabs-trigger">Подписка</Tabs.Trigger>
|
||||
<Tabs.Trigger value="activity" class="admin-tabs-trigger">Активность</Tabs.Trigger>
|
||||
<Tabs.Trigger value="actions" class="admin-tabs-trigger">Действия</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<div class="admin-user-stats">
|
||||
<div class="admin-user-stat">
|
||||
<span>Заплачено</span>
|
||||
<strong>{fmtMoney(openedUserDetail.total_paid)}</strong>
|
||||
</div>
|
||||
<div class="admin-user-stat">
|
||||
<span>Логов</span>
|
||||
<strong>{openedUserDetail.log_count}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs.Content value="profile" class="admin-tabs-content">
|
||||
<div class="admin-subsection-title">Профиль</div>
|
||||
<ul class="admin-meta-list">
|
||||
<li><span>ID</span><strong>{openedUser.user_id}</strong></li>
|
||||
<li><span>Telegram ID</span><strong>{openedUser.telegram_id || "—"}</strong></li>
|
||||
<li><span>Username</span><strong>{openedUser.username ? "@" + openedUser.username : "—"}</strong></li>
|
||||
<li><span>Email</span><strong>{openedUser.email || "—"}</strong></li>
|
||||
<li><span>Email</span><strong class="admin-meta-truncate">{openedUser.email || "—"}</strong></li>
|
||||
<li><span>Регистрация</span><strong>{fmtDate(openedUser.registration_date)}</strong></li>
|
||||
<li><span>Реф. код</span><strong>{openedUserDetail.user?.referral_code || "—"}</strong></li>
|
||||
<li><span>Логов</span><strong>{openedUserDetail.log_count}</strong></li>
|
||||
<li><span>Реф. код</span><strong>{openedUserDetail.referral?.code || openedUserDetail.user?.referral_code || "—"}</strong></li>
|
||||
</ul>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="subscription" class="admin-tabs-content">
|
||||
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
||||
<div class="admin-subsection-title">Ссылки</div>
|
||||
<div class="admin-link-list">
|
||||
{#if openedUserDetail.subscription_url}
|
||||
<div class="admin-link-row">
|
||||
<div class="admin-link-row-meta">
|
||||
<span class="admin-link-row-label">Подписка</span>
|
||||
<a class="admin-link-row-url" href={openedUserDetail.subscription_url} target="_blank" rel="noopener">
|
||||
{openedUserDetail.subscription_url}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn-icon" title="Скопировать" on:click={() => copyToClipboard(openedUserDetail.subscription_url, "Ссылка на подписку скопирована")}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if openedUserDetail.referral?.bot_link}
|
||||
<div class="admin-link-row">
|
||||
<div class="admin-link-row-meta">
|
||||
<span class="admin-link-row-label">Реф. ссылка (бот)</span>
|
||||
<a class="admin-link-row-url" href={openedUserDetail.referral.bot_link} target="_blank" rel="noopener">
|
||||
{openedUserDetail.referral.bot_link}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn-icon" title="Скопировать" on:click={() => copyToClipboard(openedUserDetail.referral.bot_link, "Реф. ссылка скопирована")}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if openedUserDetail.referral?.webapp_link}
|
||||
<div class="admin-link-row">
|
||||
<div class="admin-link-row-meta">
|
||||
<span class="admin-link-row-label">Реф. ссылка (веб)</span>
|
||||
<a class="admin-link-row-url" href={openedUserDetail.referral.webapp_link} target="_blank" rel="noopener">
|
||||
{openedUserDetail.referral.webapp_link}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn-icon" title="Скопировать" on:click={() => copyToClipboard(openedUserDetail.referral.webapp_link, "Реф. ссылка скопирована")}>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="admin-btn admin-user-link-btn" on:click={copyUserDeepLink}>
|
||||
<Link2 size={14} /> Скопировать ссылку на карточку
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="admin-user-main">
|
||||
<Tabs.Root bind:value={userDetailTab} class="admin-tabs-root">
|
||||
<Tabs.List class="admin-tabs-list">
|
||||
<Tabs.Trigger value="subscription" class="admin-tabs-trigger">Подписка</Tabs.Trigger>
|
||||
<Tabs.Trigger value="activity" class="admin-tabs-trigger">Активность</Tabs.Trigger>
|
||||
<Tabs.Trigger value="actions" class="admin-tabs-trigger">Действия</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="subscription" class="admin-tabs-content">
|
||||
{#if openedUserDetail.active_subscription}
|
||||
<ul class="admin-meta-list">
|
||||
<li><span>Активна до</span><strong>{fmtDate(openedUserDetail.active_subscription.end_date)}</strong></li>
|
||||
@@ -2516,7 +2731,9 @@
|
||||
</div>
|
||||
</section>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Tabs.Root>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Dialog>
|
||||
|
||||
@@ -33,6 +33,19 @@
|
||||
--danger: #ff6b6b;
|
||||
--blue: #2d9cff;
|
||||
--radius: 8px;
|
||||
|
||||
/* Admin design tokens — kept on :root so portal-rendered admin
|
||||
surfaces (dialogs, bits-ui Select.Portal content) inherit them. */
|
||||
--admin-bg: var(--bg);
|
||||
--admin-surface: var(--panel);
|
||||
--admin-surface-2: var(--panel-2);
|
||||
--admin-elev: var(--panel-3);
|
||||
--admin-border: var(--border);
|
||||
--admin-border-strong: var(--border-strong);
|
||||
--admin-text: var(--text);
|
||||
--admin-muted: var(--muted);
|
||||
--admin-dim: var(--dim);
|
||||
--admin-ring: color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
--screen-gutter: 18px;
|
||||
--safe-inline: max(env(safe-area-inset-left), env(safe-area-inset-right));
|
||||
--nav-inline-gutter: max(var(--screen-gutter), var(--safe-inline));
|
||||
@@ -2647,16 +2660,6 @@ a {
|
||||
.admin-screen-wrap {
|
||||
--admin-sidebar-w: 248px;
|
||||
--admin-header-h: 60px;
|
||||
--admin-bg: var(--bg);
|
||||
--admin-surface: var(--panel);
|
||||
--admin-surface-2: var(--panel-2);
|
||||
--admin-elev: var(--panel-3);
|
||||
--admin-border: var(--border);
|
||||
--admin-border-strong: var(--border-strong);
|
||||
--admin-text: var(--text);
|
||||
--admin-muted: var(--muted);
|
||||
--admin-dim: var(--dim);
|
||||
--admin-ring: color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -2704,9 +2707,6 @@ a {
|
||||
.admin-sidebar-brand .admin-brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--admin-surface-2));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
@@ -2791,6 +2791,30 @@ a {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-version-link {
|
||||
order: 2;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--admin-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.admin-version-link span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-version-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
@@ -3858,6 +3882,148 @@ a {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* User-detail dialog: constrain on desktop and lay out as a two-column
|
||||
sidebar (profile facts) + main content (tabs). On mobile it stacks. */
|
||||
.admin-user-dialog {
|
||||
width: min(100%, 1040px);
|
||||
max-height: min(100%, 760px);
|
||||
}
|
||||
|
||||
.admin-user-dialog-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-user-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-stat span {
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-user-stat strong {
|
||||
color: var(--admin-text);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-user-link-btn {
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-link-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-link-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-link-row-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-link-row-label {
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-link-row-url {
|
||||
color: var(--admin-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-link-row-url:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.admin-btn.admin-btn-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-meta-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
.admin-user-dialog-body {
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-user-aside {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-tariff-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -3975,6 +4141,44 @@ a {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Section title block: stacks `<strong>` heading + `<small>` description.
|
||||
Higher specificity than `.admin-editor-section-head > div` (which would
|
||||
otherwise force flex-row on the wrapper). */
|
||||
.admin-editor-section-head > .admin-editor-section-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-editor-section-head > .admin-editor-section-title small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Column headers above an `.admin-row-editor` list — visually distinct,
|
||||
inherits the same grid template as input rows so columns line up. */
|
||||
.admin-row-editor-line.admin-row-editor-header {
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.admin-row-editor-line.admin-row-editor-header span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-row-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -4438,6 +4642,23 @@ a {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-field-label > small code,
|
||||
.admin-editor-section-title small code {
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--admin-elev) 70%, transparent);
|
||||
color: var(--admin-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-field-label > small b,
|
||||
.admin-editor-section-title small b {
|
||||
color: var(--admin-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Action rows in dialogs */
|
||||
.admin-action-row {
|
||||
display: flex;
|
||||
@@ -4513,7 +4734,6 @@ a {
|
||||
border-radius: 12px;
|
||||
background: var(--admin-surface-2);
|
||||
border: 1px solid var(--admin-border);
|
||||
margin-bottom: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import io
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
@@ -64,6 +66,7 @@ WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop"
|
||||
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
|
||||
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
@@ -77,6 +80,7 @@ WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
|
||||
WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state"
|
||||
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/telegram/nonce",
|
||||
"/api/auth/token",
|
||||
@@ -210,6 +214,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/settings", index_route)
|
||||
app.router.add_get("/admin", index_route)
|
||||
app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
@@ -645,6 +650,63 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
return cache["data"]
|
||||
|
||||
|
||||
def _run_git_command(*args: str) -> str:
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _resolve_app_version() -> str:
|
||||
global _APP_VERSION_CACHE
|
||||
if _APP_VERSION_CACHE:
|
||||
return _APP_VERSION_CACHE
|
||||
|
||||
env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip()
|
||||
if env_version:
|
||||
_APP_VERSION_CACHE = env_version
|
||||
return env_version
|
||||
|
||||
build_version_path = Path(__file__).resolve().parents[3] / ".build-version"
|
||||
try:
|
||||
build_version = build_version_path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
build_version = ""
|
||||
if build_version:
|
||||
_APP_VERSION_CACHE = build_version
|
||||
return build_version
|
||||
|
||||
tag = _run_git_command("describe", "--tags", "--abbrev=0")
|
||||
sha = _run_git_command("rev-parse", "--short", "HEAD")
|
||||
dirty = bool(_run_git_command("status", "--porcelain"))
|
||||
|
||||
if tag and sha:
|
||||
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
|
||||
if commits_since_tag and commits_since_tag != "0":
|
||||
version = f"{tag}+{commits_since_tag}.g{sha}"
|
||||
else:
|
||||
version = tag
|
||||
elif sha:
|
||||
version = f"dev+g{sha}"
|
||||
else:
|
||||
version = "dev+unknown"
|
||||
|
||||
if dirty:
|
||||
version = f"{version}-dirty"
|
||||
|
||||
_APP_VERSION_CACHE = version
|
||||
return version
|
||||
|
||||
|
||||
async def _enforce_webapp_rate_limit(
|
||||
request: web.Request,
|
||||
*,
|
||||
@@ -727,6 +789,8 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
"currency": cached["currency"],
|
||||
"language": cached["language"],
|
||||
"emailAuthEnabled": cached["email_auth_enabled"],
|
||||
"appVersion": _resolve_app_version(),
|
||||
"appRepositoryUrl": APP_REPOSITORY_URL,
|
||||
}
|
||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
|
||||
Reference in New Issue
Block a user