feat: tune visual of web app admin panel

This commit is contained in:
3252a8
2026-05-10 20:19:43 +03:00
parent ff59eda3d7
commit e01f56bc34
11 changed files with 918 additions and 188 deletions
+77 -7
View File
@@ -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)
+28 -5
View File
@@ -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"}>
+341 -124
View File
@@ -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>
+234 -14
View File
@@ -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;
}
+64
View File
@@ -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")