From ed6b214b290c325914202bb2c04eb937f95e9ef0 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 11 May 2026 21:41:31 +0300 Subject: [PATCH] refactor: slice web app wip --- bot/app/web/frontend/src/App.svelte | 1178 ++------ .../web/frontend/src/admin/AdminPanel.svelte | 2377 ++--------------- .../src/admin/sections/AdsSection.svelte | 121 + .../admin/sections/BroadcastSection.svelte | 62 + .../src/admin/sections/LogsSection.svelte | 102 + .../src/admin/sections/PaymentsSection.svelte | 30 +- .../src/admin/sections/PromosSection.svelte | 140 + .../src/admin/sections/SettingsSection.svelte | 208 +- .../src/admin/sections/StatsSection.svelte | 73 +- .../admin/sections/TariffEditorModal.svelte | 498 ++++ .../src/admin/sections/TariffsSection.svelte | 118 +- .../src/admin/sections/UserDetailModal.svelte | 349 +++ .../src/admin/sections/UsersSection.svelte | 82 +- .../frontend/src/lib/admin/stores/adsStore.js | 101 + .../src/lib/admin/stores/broadcastStore.js | 60 + .../src/lib/admin/stores/logsStore.js | 59 + .../src/lib/admin/stores/paymentsStore.js | 47 + .../src/lib/admin/stores/promosStore.js | 112 + .../src/lib/admin/stores/settingsStore.js | 92 + .../src/lib/admin/stores/statsStore.js | 53 + .../src/lib/admin/stores/tariffsStore.js | 255 ++ .../src/lib/admin/stores/usersStore.js | 295 ++ .../src/lib/components/ui/button.svelte | 4 +- .../src/lib/components/ui/dialog.svelte | 2 +- .../src/lib/webapp/stores/accountStore.js | 215 ++ .../src/lib/webapp/stores/authStore.js | 274 ++ .../src/lib/webapp/stores/billingStore.js | 360 +++ .../src/lib/webapp/stores/devicesStore.js | 100 + .../web/frontend/src/lib/webapp/tariffs.js | 11 +- bot/app/web/frontend/src/styles.css | 3 + .../frontend/src/styles/admin-controls.css | 277 ++ .../web/frontend/src/styles/admin-dialogs.css | 8 + bot/app/web/frontend/src/styles/admin.css | 286 +- bot/app/web/frontend/src/styles/base.css | 95 + bot/app/web/frontend/src/styles/controls.css | 162 +- bot/app/web/frontend/src/styles/dialogs.css | 138 +- bot/app/web/frontend/src/styles/webapp.css | 226 +- .../web/frontend/src/webapp/BottomNav.svelte | 10 +- .../frontend/src/webapp/PaymentDialogs.svelte | 76 +- .../frontend/src/webapp/TariffDialogs.svelte | 141 +- .../src/webapp/auth/AuthScreen.svelte | 6 +- .../src/webapp/screens/DevicesScreen.svelte | 21 +- .../src/webapp/screens/HomeScreen.svelte | 45 +- .../src/webapp/screens/SettingsScreen.svelte | 20 +- locales/en.json | 207 +- locales/ru.json | 207 +- scratch_divs.py | 159 -- scratch_find.py | 8 - 48 files changed, 5367 insertions(+), 4106 deletions(-) create mode 100644 bot/app/web/frontend/src/admin/sections/AdsSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/LogsSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/PromosSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte create mode 100644 bot/app/web/frontend/src/lib/admin/stores/adsStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/logsStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/promosStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/settingsStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/statsStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js create mode 100644 bot/app/web/frontend/src/lib/admin/stores/usersStore.js create mode 100644 bot/app/web/frontend/src/lib/webapp/stores/accountStore.js create mode 100644 bot/app/web/frontend/src/lib/webapp/stores/authStore.js create mode 100644 bot/app/web/frontend/src/lib/webapp/stores/billingStore.js create mode 100644 bot/app/web/frontend/src/lib/webapp/stores/devicesStore.js create mode 100644 bot/app/web/frontend/src/styles/admin-controls.css create mode 100644 bot/app/web/frontend/src/styles/admin-dialogs.css create mode 100644 bot/app/web/frontend/src/styles/base.css delete mode 100644 scratch_divs.py delete mode 100644 scratch_find.py diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 8dc4e3e..1d38e27 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -1,6 +1,10 @@ -{#snippet renderField(field)} - {@const revealed = isSecretRevealed(field.key)} -
-
- - {field.label} - {#if field.secret} - Secret - {/if} - {#if isOverridden(field)} - Override - {/if} - - {field.key} - {#if field.description} - {field.description} - {/if} -
-
- {#if field.type === "bool"} -
- markDirty(field.key, checked)} - class="admin-switch-root" - > - - - {Boolean(valueFor(field)) ? "Включено" : "Выключено"} -
- {:else if field.type === "color"} - markDirty(field.key, e.currentTarget.value)} - /> - markDirty(field.key, e.currentTarget.value)} - /> - {:else if field.type === "int" || field.type === "float"} - markDirty(field.key, e.currentTarget.value)} - /> - {:else if field.secret} - markDirty(field.key, e.currentTarget.value)} - /> - - {:else} - markDirty(field.key, e.currentTarget.value)} - /> - {/if} - {#if isOverridden(field) || settingsDirty[field.key]} - - {/if} -
-
-{/snippet} + $: if (active === "users" && initialUserId && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)) { + usersStore.openUser(initialUserId, { skipPush: true }); + } +
{#if sidebarOpen} - + {/if}
- (tariffEditorOpen = false)} - class="admin-dialog admin-tariff-dialog" -> - - - Основное - Цены - Докупки - Premium - Устройства - + - -
- - Ключ тарифа - Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется - - - -
- Модель тарификации - Период — пользователь покупает фиксированный срок (1/3/12 мес. и т.д.). Трафик — пользователь покупает пакеты гигабайт по фиксированной цене за GB - - - {tariffDraft.billing_model === "traffic" ? "Трафик" : "Период"} - - - - - - Период - - - - Трафик - - - - - -
-
- -
- (tariffDraft.enabled = v)} - class="admin-switch-root" - > - - - - {tariffDraft.enabled ? "Тариф виден на витрине" : "Тариф скрыт от пользователей"} - Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать - -
- -
- - Название · RU - - - - Название · EN - - -
- -
- - Описание · RU - - - - Описание · EN - - -
- -
- Базовые Internal Squads - {panelSquadsLoading ? "Загружаю список из панели…" : "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"} - { - addSquadToDraft("squadUuids", value); - selectedBaseSquad = ""; - }} - > - - Добавить сквад - - - - - {#each panelSquads as squad} - - {squad.name} - - - {/each} - - - -
- {#each normalizeUuidList(tariffDraft.squadUuids) as uuid} - - {/each} -
-
- -
- - Лимит устройств (HWID) - Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений - - - {#if tariffDraft.billing_model === "period"} - - Месячный лимит трафика, GB - Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафик. Сверху можно докупать пакеты на вкладке «Докупки» - - - {:else} - - Курс конвертации, ₽ за 1 GB - По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик» - - - {/if} -
-
- - -
-
-
- Premium-доступ и отдельный счётчик трафика - Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно -
-
-
- - Название premium-раздела, RU - Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов. - - - - Название premium-раздела, EN - Опционально для английского интерфейса. - - -
-
-
- Premium Internal Squads - Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes - { - addSquadToDraft("premiumSquadUuids", value); - selectedPremiumSquad = ""; - }} - > - - Добавить premium-сквад - - - - - {#each panelSquads as squad} - - {squad.name} - - - {/each} - - - -
- {#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid} - - {/each} -
-
- - Месячный лимит premium-трафика, GB - Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения) - - -
-
- -
-
-
- Докупка premium-трафика - Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал -
-
- - -
-
-
-
- Оплата рублями - {#if tariffDraft.premiumTopupRubRows.length} -
- Объём, GB - Цена, ₽ - -
- {/if} - {#each tariffDraft.premiumTopupRubRows as row, index} -
- - - -
- {/each} -
-
- Оплата Telegram Stars - {#if tariffDraft.premiumTopupStarsRows.length} -
- Объём, GB - Цена, ⭐ - -
- {/if} - {#each tariffDraft.premiumTopupStarsRows as row, index} -
- - - -
- {/each} -
-
-
-
- - - {#if tariffDraft.billing_model === "period"} -
-
-
- Периоды подписки и цены - Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит -
- -
- {#if !tariffDraft.periodRows.length} -

Добавьте хотя бы один период — без него тариф не появится на витрине.

- {:else} -
-
- Срок, мес. - Цена, ₽ - Цена, ⭐ Stars - -
- {#each tariffDraft.periodRows as row, index} -
- - - - -
- {/each} -
- {/if} -
- {:else} -
-
-
- Пакеты трафика - Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты» -
-
- - -
-
-
-
- Оплата рублями - {#if tariffDraft.trafficRubRows.length} -
- Объём, GB - Цена, ₽ - -
- {/if} - {#each tariffDraft.trafficRubRows as row, index} -
- - - -
- {/each} -
-
- Оплата Telegram Stars - {#if tariffDraft.trafficStarsRows.length} -
- Объём, GB - Цена, ⭐ - -
- {/if} - {#each tariffDraft.trafficStarsRows as row, index} -
- - - -
- {/each} -
-
-
- {/if} -
- - - {#if tariffDraft.billing_model === "period"} -
-
-
- Докупка трафика поверх месячного лимита - Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки -
-
- - -
-
-
-
- Оплата рублями - {#if tariffDraft.topupRubRows.length} -
- Объём, GB - Цена, ₽ - -
- {/if} - {#each tariffDraft.topupRubRows as row, index} -
- - - -
- {/each} -
-
- Оплата Telegram Stars - {#if tariffDraft.topupStarsRows.length} -
- Объём, GB - Цена, ⭐ - -
- {/if} - {#each tariffDraft.topupStarsRows as row, index} -
- - - -
- {/each} -
-
-
- {:else} -

Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания.

- {/if} -
- - -
-
-
- Пакеты дополнительных устройств (HWID) - Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты» -
-
- - -
-
-
-
- Оплата рублями - {#if tariffDraft.hwidRubRows.length} -
- + устройств - Цена, ₽ - -
- {/if} - {#each tariffDraft.hwidRubRows as row, index} -
- - - -
- {/each} -
-
- Оплата Telegram Stars - {#if tariffDraft.hwidStarsRows.length} -
- + устройств - Цена, ⭐ - -
- {/if} - {#each tariffDraft.hwidStarsRows as row, index} -
- - - -
- {/each} -
-
-
-
-
- -
- - -
-
- - (tariffDeleteOpen = false)} - class="admin-dialog" -> -
- - -
-
- - - {#if openedUser} - {#if userDetailLoading || !openedUserDetail} -

Загрузка…

- {:else} -
- - -
- - - Подписка - Активность - Действия - - - - {#if openedUserDetail.active_subscription} -
    -
  • Активна до{fmtDate(openedUserDetail.active_subscription.end_date)}
  • -
  • Тариф{openedUserDetail.active_subscription.tariff_key || "—"}
  • -
  • Авто-продление{pretty(openedUserDetail.active_subscription.auto_renew_enabled)}
  • -
  • Провайдер{openedUserDetail.active_subscription.provider || "—"}
  • -
-
-
-
- Основной трафик - {trafficOfLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)} -
-
- -
-
- Осталось: {trafficLeftLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)} - {trafficPercentValue(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)}% -
-
- {#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0} -
-
- Premium-сквады - {trafficOfLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)} -
-
- -
-
- Осталось: {trafficLeftLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)} - {trafficPercentValue(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)}% -
-
- {/if} -
- {:else} -

Активной подписки нет

- {/if} - - {#if (openedUserDetail.subscriptions || []).length} - -
История подписок · {openedUserDetail.subscriptions.length}
-
- {#each openedUserDetail.subscriptions.slice(0, 8) as sub} -
-
- {sub.tariff_key || "Без тарифа"} - до {fmtDate(sub.end_date)} -
- {#if sub.is_active} - Активна - {:else} - {sub.status_from_panel || "История"} - {/if} -
- {/each} -
- {/if} -
- - -
Последние платежи · {(openedUserDetail.recent_payments || []).length}
- {#if (openedUserDetail.recent_payments || []).length} -
- {#each openedUserDetail.recent_payments.slice(0, 8) as payment} -
-
- {fmtMoney(payment.amount, payment.currency)} - {payment.provider} · {fmtDateShort(payment.created_at)} -
- {payment.status} -
- {/each} -
- {:else} -

Платежей нет

- {/if} -
- - -
- - - Продлить подписку -
- - -
-
-
- - - Сообщение в Telegram - Поддерживается HTML-разметка Telegram - - -
- - -
- -
-
- Опасные действия - Эти действия требуют подтверждения и (для удаления) необратимы -
-
- {#if openedUser.is_banned} - - {:else} - - {/if} - -
-
-
-
-
-
- {/if} - {/if} -
- - (userMessageConfirmOpen = false)} - class="admin-dialog" -> -
{userMessageDraft}
-
- - -
-
- - (userBanConfirmOpen = false)} - class="admin-dialog" -> -
- - -
-
- - (userDeleteOpen = false)} - class="admin-dialog" -> -
- - -
-
- - (promoCreateOpen = false)} class="admin-dialog"> -
- - Код - - -
- - Бонус (дней) - - - - Макс. активаций - - - - Срок действия - 0 — бессрочно - - -
- -
-
- - (adCreateOpen = false)} class="admin-dialog"> -
- - Источник - - - - start-параметр - Передаётся в /start, должен быть уникален - - - - Стоимость, RUB - - - -
-
+ diff --git a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte new file mode 100644 index 0000000..1fba3a5 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte @@ -0,0 +1,121 @@ + + +
+ {#if adsLoading} + + + + + + + + + + + + + + + {#each Array(6) as _, i (i)} + + + + + + + + + + + {/each} + + + {:else if !ads.length} +
{at("ads_empty", {}, "Кампаний нет")}
+ {:else} + + + + + + + + + + + + + + + {#each ads as ad} + + + + + + + + + + + {/each} + +
{at("id", {}, "ID")}{at("ads_col_source", {}, "Источник")}{at("ads_col_param", {}, "Параметр")}{at("ads_col_cost", {}, "Стоимость")}{at("ads_col_registrations", {}, "Регистрации")}{at("ads_col_conversions", {}, "Конверсии")}{at("ads_col_status", {}, "Статус")}{at("actions", {}, "Действия")}
#{ad.id}{ad.source}{ad.start_param}{fmtMoney(ad.cost)}{ad.stats?.registrations ?? 0}{ad.stats?.conversions ?? 0} + {#if ad.is_active} + {at("status_active", {}, "Активна")} + {:else} + {at("status_disabled", {}, "Выключена")} + {/if} + + + +
+ {/if} +
+ + adsStore.setCreateOpen(false)} class="admin-dialog"> +
+ + {at("ad_label_source", {}, "Источник")} + adsStore.updateDraft({ source: e.target.value })} /> + + + {at("ad_label_param", {}, "start-параметр")} + {at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")} + adsStore.updateDraft({ start_param: e.target.value })} /> + + + {at("ad_label_cost", {}, "Стоимость, RUB")} + adsStore.updateDraft({ cost: Number(e.target.value) })} /> + + +
+
diff --git a/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte b/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte new file mode 100644 index 0000000..8b76503 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte @@ -0,0 +1,62 @@ + + +
+
+

{at("broadcast_title", {}, "Рассылка")}

+ {at("broadcast_subtitle", {}, "Доставка через очередь сообщений")} +
+
+
+ + {at("broadcast_label_audience", {}, "Аудитория")} + broadcastStore.updateField({ broadcastTarget: value })}> + + {optionLabel(BROADCAST_TARGET_OPTIONS, broadcastTarget)} + + + + + {#each BROADCAST_TARGET_OPTIONS as opt} + + {opt.label} + + + {/each} + + + + + + {at("broadcast_label_text", {}, "Текст сообщения")} + {at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")} + + +
+ + {#if broadcastResult} + {at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at("broadcast_stat_failed", {}, "Неудач")}: {broadcastResult.failed} + {/if} +
+
+
+
diff --git a/bot/app/web/frontend/src/admin/sections/LogsSection.svelte b/bot/app/web/frontend/src/admin/sections/LogsSection.svelte new file mode 100644 index 0000000..f7c5993 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/LogsSection.svelte @@ -0,0 +1,102 @@ + + +
+ +
+ {at("total", {}, "Всего")} + {logsTotal} +
+
+ +
+ {#if logsLoading} + + + + + + + + {#each Array(10) as _, i (i)} + + + + + + + + {/each} + + + {:else if !logs.length} +
{at("logs_empty", {}, "Записей нет")}
+ {:else} + + + + + + + + + + + + {#each logs as entry} + + + + + + + + {/each} + +
{at("date", {}, "Дата")}{at("event", {}, "Событие")}{at("user_short", {}, "User")}{at("target_short", {}, "Target")}{at("content", {}, "Контент")}
{fmtDate(entry.timestamp)}{entry.event_type}{entry.user_id || "—"}{entry.target_user_id || "—"}{entry.content || ""}
+ {/if} +
+ +
+ {at("page_short", {}, "Стр.")} {logsPage + 1} +
+ + +
+
diff --git a/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte b/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte index 38d29af..eadd2e9 100644 --- a/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte @@ -1,16 +1,26 @@
@@ -18,7 +28,7 @@ - + @@ -41,7 +51,7 @@ - + @@ -72,10 +82,10 @@
{at("page_short", {}, "Стр.")} {paymentsPage + 1} · {at("total", {}, "Всего")} {paymentsTotal}
- -
diff --git a/bot/app/web/frontend/src/admin/sections/PromosSection.svelte b/bot/app/web/frontend/src/admin/sections/PromosSection.svelte new file mode 100644 index 0000000..31bbc34 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/PromosSection.svelte @@ -0,0 +1,140 @@ + + +
+ {#if promosLoading} +
ID{at("id", {}, "ID")} {at("user", {}, "Пользователь")} {at("amount", {}, "Сумма")} {at("provider", {}, "Провайдер")}
+ + + + + + + + + + + + {#each Array(6) as _, i (i)} + + + + + + + + + {/each} + + + {:else if !promos.length} +
{at("promos_empty", {}, "Промокодов нет")}
+ {:else} + + + + + + + + + + + + + {#each promos as p} + + + + + + + + + {/each} + +
{at("promo_col_code", {}, "Код")}{at("promo_col_bonus", {}, "Бонус")}{at("promo_col_activations", {}, "Активаций")}{at("promo_col_valid_until", {}, "Действует до")}{at("promo_col_status", {}, "Статус")}{at("actions", {}, "Действия")}
{p.code}+{p.bonus_days} {at("days_short", {}, "дн.")}{p.current_activations}/{p.max_activations}{p.valid_until ? fmtDateShort(p.valid_until) : "∞"} + {#if p.is_active} + {at("status_active", {}, "Активен")} + {:else} + {at("status_disabled", {}, "Выключен")} + {/if} + + + +
+ {/if} + {#if promosHasMore} +
+ +
+ {/if} +
+ + promosStore.setCreateOpen(false)} + class="admin-dialog" +> +
+
+

{at("promo_create_title", {}, "Создать промокод")}

+
+
+ + {at("promo_label_code", {}, "Код")} + promosStore.updateDraft({ code: e.target.value })} placeholder="FREE-7-DAYS" /> + +
+ + {at("promo_label_bonus_days", {}, "Бонус (дней)")} + promosStore.updateDraft({ bonus_days: Number(e.target.value) })} /> + + + {at("promo_label_max_activations", {}, "Макс. активаций")} + promosStore.updateDraft({ max_activations: Number(e.target.value) })} /> + +
+ + {at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")} + promosStore.updateDraft({ valid_days: Number(e.target.value) })} /> + +
+ +
+
diff --git a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte index fcd304c..8befd51 100644 --- a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte @@ -1,30 +1,198 @@ +{#snippet renderField(field)} + {@const revealed = isSecretRevealed(field.key)} +
+
+ + {field.label} + {at("settings_badge_secret", {}, "Secret")} + {#if isOverridden(field)} + {at("settings_badge_override", {}, "Override")} + {/if} + + {field.key} + {#if field.description} + {field.description} + {/if} +
+
+ {#if field.type === "bool"} +
+ settingsStore.markDirty(field.key, checked)} + class="admin-switch-root" + > + + + {Boolean(valueFor(field)) ? at("enabled", {}, "Включено") : at("disabled", {}, "Выключено")} +
+ {:else if field.type === "color"} + settingsStore.markDirty(field.key, e.currentTarget.value)} + /> + settingsStore.markDirty(field.key, e.currentTarget.value)} + /> + {:else if field.type === "int" || field.type === "float"} + settingsStore.markDirty(field.key, e.currentTarget.value)} + /> + {:else if field.secret} + settingsStore.markDirty(field.key, e.currentTarget.value)} + /> + + {:else} + settingsStore.markDirty(field.key, e.currentTarget.value)} + /> + {/if} + {#if isOverridden(field) || settingsDirty[field.key]} + + {/if} +
+
+{/snippet} + {#if settingsLoading || !settingsSections.length} -
{settingsLoading ? "Загрузка…" : "Нет данных"}
+
{settingsLoading ? at("loading", {}, "Загрузка…") : at("no_data", {}, "Нет данных")}
{:else}

- Изменения в админке имеют приоритет над .env. Кнопка «Восстановить» возвращает значение из переменных окружения. + {at("settings_hint", {}, "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.")}

- +
+ + {#if Object.keys(settingsDirty).length > 0} + + {/if} +
{#each settingsSections as section} @@ -35,7 +203,7 @@ {sectionTitle(section.id)} - {section.fields.length} параметров{#if overriddenInSection} · {overriddenInSection} override{/if}{#if dirtyInSection} · {dirtyInSection} изм.{/if} + {at("settings_params_count", { count: section.fields.length }, `${section.fields.length} параметров`)}{#if overriddenInSection} · {at("settings_overridden_count", { count: overriddenInSection }, `${overriddenInSection} override`)}{/if}{#if dirtyInSection} · {at("settings_dirty_count", { count: dirtyInSection }, `${dirtyInSection} изм.`)}{/if} @@ -65,7 +233,7 @@ {group.label} - {group.fields.length} полей{#if subOverridden} · {subOverridden} override{/if}{#if subDirty} · {subDirty} изм.{/if} + {at("settings_fields_count", { count: group.fields.length }, `${group.fields.length} полей`)}{#if subOverridden} · {at("settings_overridden_count", { count: subOverridden }, `${subOverridden} override`)}{/if}{#if subDirty} · {at("settings_dirty_count", { count: subDirty }, `${subDirty} изм.`)}{/if} diff --git a/bot/app/web/frontend/src/admin/sections/StatsSection.svelte b/bot/app/web/frontend/src/admin/sections/StatsSection.svelte index c009ec5..3ffd62c 100644 --- a/bot/app/web/frontend/src/admin/sections/StatsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/StatsSection.svelte @@ -1,88 +1,99 @@ {#if statsError} -
Не удалось загрузить статистику: {statsError}
+
{at("stats_error", { error: statsError }, "Не удалось загрузить статистику: " + statsError)}
{:else if statsLoading || !stats} -
Загрузка…
+
{at("loading", {}, "Загрузка…")}
{:else}
- Пользователи + {at("stats_label_users", {}, "Пользователи")} {stats.users?.total_users ?? 0} - В бане: {stats.users?.banned_users ?? 0} + {at("stats_trend_banned", { count: stats.users?.banned_users ?? 0 }, "В бане: " + (stats.users?.banned_users ?? 0))}
- Платные подписки + {at("stats_label_paid_subs", {}, "Платные подписки")} {stats.users?.paid_subscriptions ?? 0} - Триалы: {stats.users?.trial_users ?? 0} + {at("stats_trend_trials", { count: stats.users?.trial_users ?? 0 }, "Триалы: " + (stats.users?.trial_users ?? 0))}
- Доход за день + {at("stats_label_today_rev", {}, "Доход за день")} {fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)} - {stats.financial?.today_payments_count ?? 0} платежей + {at("stats_trend_payments", { count: stats.financial?.today_payments_count ?? 0 }, (stats.financial?.today_payments_count ?? 0) + " платежей")}
- За неделю + {at("stats_label_week", {}, "За неделю")} {fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)} - Месяц: {fmtMoney(stats.financial?.month_revenue, stats.currency_symbol)} + {at("stats_trend_month", { value: fmtMoney(stats.financial?.month_revenue, stats.currency_symbol) }, "Месяц: " + fmtMoney(stats.financial?.month_revenue, stats.currency_symbol))}
- Всё время + {at("stats_label_all_time", {}, "Всё время")} {fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)} - Sync: {stats.panel_sync?.status ?? "—"} + {at("stats_sync_label", {}, "Sync")}: {stats.panel_sync?.status ?? "—"}
{#if stats.queue}
- Очередь + {at("stats_label_queue", {}, "Очередь")} {stats.queue.user_queue_size ?? 0} - Группы: {stats.queue.group_queue_size ?? 0} + {at("stats_trend_groups", { count: stats.queue.group_queue_size ?? 0 }, "Группы: " + (stats.queue.group_queue_size ?? 0))}
{/if}
-

Последние платежи

- {(stats.recent_payments || []).length} записей +

{at("stats_recent_payments", {}, "Последние платежи")}

+ {at("stats_records_count", { count: (stats.recent_payments || []).length }, (stats.recent_payments || []).length + " записей")}
{#if (stats.recent_payments || []).length} - - - - - - + + + + + + {#each stats.recent_payments as p} - - - - + + + - + {/each}
IDПользовательСуммаПровайдерСтатусДата{at("id", {}, "ID")}{at("user", {}, "Пользователь")}{at("amount", {}, "Сумма")}{at("provider", {}, "Провайдер")}{at("status", {}, "Статус")}{at("date", {}, "Дата")}
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider} + {p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider} {p.status} {fmtDate(p.created_at)}{fmtDate(p.created_at)}
{:else} -
Нет данных
+
{at("no_data", {}, "Нет данных")}
{/if}
{/if} diff --git a/bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte b/bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte new file mode 100644 index 0000000..e2ac879 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte @@ -0,0 +1,498 @@ + + + tariffsStore.updateState({ tariffEditorOpen: false })} + class="admin-dialog admin-tariff-dialog" +> + + + {at("tariff_tab_general", {}, "Основное")} + {at("tariff_tab_pricing", {}, "Цены")} + {at("tariff_tab_topup", {}, "Докупки")} + {at("tariff_tab_premium", {}, "Premium")} + {at("tariff_tab_hwid", {}, "Устройства")} + + + +
+ + {at("tariff_label_key", {}, "Ключ тарифа")} + {at("tariff_hint_key", {}, "Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется")} + + + +
+ {at("tariff_label_model", {}, "Модель тарификации")} + {at("tariff_model_period_label", {}, "Период")} — {at("tariff_model_period_desc", {}, "пользователь покупает фиксированный срок (1/3/12 мес. и т.д.)")}. {at("tariff_model_traffic_label", {}, "Трафик")} — {at("tariff_model_traffic_desc", {}, "пользователь покупает пакеты гигабайт по фиксированной цене за GB")} + + + {tariffDraft.billing_model === "traffic" ? at("tariff_model_traffic_label", {}, "Трафик") : at("tariff_model_period_label", {}, "Период")} + + + + + + {at("tariff_model_period_label", {}, "Период")} + + + + {at("tariff_model_traffic_label", {}, "Трафик")} + + + + + +
+
+ +
+ (tariffDraft.enabled = v)} + class="admin-switch-root" + > + + + + {tariffDraft.enabled ? at("tariff_visible", {}, "Тариф виден на витрине") : at("tariff_hidden", {}, "Тариф скрыт от пользователей")} + {at("tariff_enabled_hint", {}, "Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать")} + +
+ +
+ + {at("tariff_label_name_ru", {}, "Название · RU")} + + + + {at("tariff_label_name_en", {}, "Название · EN")} + + +
+ +
+ + {at("tariff_label_desc_ru", {}, "Описание · RU")} + + + + {at("tariff_label_desc_en", {}, "Описание · EN")} + + +
+ +
+ {at("tariff_label_squads", {}, "Базовые Internal Squads")} + {panelSquadsLoading ? at("loading_squads", {}, "Загружаю список из панели…") : at("tariff_hint_squads", {}, "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько")} + { + tariffsStore.addSquadToDraft("squadUuids", value); + selectedBaseSquad = ""; + }} + > + + {at("btn_add_squad", {}, "Добавить сквад")} + + + + + {#each panelSquads as squad} + + {squad.name} + + + {/each} + + + +
+ {#each normalizeUuidList(tariffDraft.squadUuids) as uuid} + + {/each} +
+
+ +
+ + {at("tariff_label_hwid", {}, "Лимит устройств (HWID)")} + {at("tariff_hint_hwid", {}, "Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений")} + + + {#if tariffDraft.billing_model === "period"} + + {at("tariff_label_traffic_limit", {}, "Месячный лимит трафика, GB")} + {at("tariff_hint_traffic_limit", {}, "Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»")} + + + {:else} + + {at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")} + {at("tariff_hint_conversion", {}, "По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»")} + + + {/if} +
+
+ + +
+
+
+ {at("tariff_premium_head", {}, "Premium-доступ и отдельный счётчик трафика")} + {at("tariff_premium_subhead", {}, "Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно")} +
+
+
+ + {at("tariff_label_premium_name_ru", {}, "Название premium-раздела, RU")} + {at("tariff_hint_premium_name_ru", {}, "Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов.")} + + + + {at("tariff_label_premium_name_en", {}, "Название premium-раздела, EN")} + {at("tariff_hint_premium_name_en", {}, "Опционально для английского интерфейса.")} + + +
+
+
+ {at("tariff_label_premium_squads", {}, "Premium Internal Squads")} + {at("tariff_hint_premium_squads", {}, "Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes")} + { + tariffsStore.addSquadToDraft("premiumSquadUuids", value); + selectedPremiumSquad = ""; + }} + > + + {at("btn_add_premium_squad", {}, "Добавить premium-сквад")} + + + + + {#each panelSquads as squad} + + {squad.name} + + + {/each} + + + +
+ {#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid} + + {/each} +
+
+ + {at("tariff_label_premium_traffic_limit", {}, "Месячный лимит premium-трафика, GB")} + {at("tariff_hint_premium_traffic_limit", {}, "Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)")} + + +
+
+ +
+
+
+ {at("tariff_premium_topup_title", {}, "Докупка premium-трафика")} + {at("tariff_premium_topup_subtitle", {}, "Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал")} +
+
+ + +
+
+
+
+ {at("payment_rub", {}, "Оплата рублями")} + {#if tariffDraft.premiumTopupRubRows.length} +
+ {at("tariff_col_volume_gb", {}, "Объём, GB")} + {at("tariff_col_price_rub", {}, "Цена, ₽")} + +
+ {/if} + {#each tariffDraft.premiumTopupRubRows as row, index} +
+ + + +
+ {/each} +
+
+ {at("payment_stars", {}, "Оплата Telegram Stars")} + {#if tariffDraft.premiumTopupStarsRows.length} +
+ {at("tariff_col_volume_gb", {}, "Объём, GB")} + {at("tariff_col_price_stars", {}, "Цена, ⭐")} + +
+ {/if} + {#each tariffDraft.premiumTopupStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+
+ + + {#if tariffDraft.billing_model === "period"} +
+
+
+ {at("tariff_pricing_period_title", {}, "Периоды подписки и цены")} + {at("tariff_pricing_period_subtitle", {}, "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит")} +
+ +
+ {#if !tariffDraft.periodRows.length} +

{at("tariff_pricing_empty", {}, "Добавьте хотя бы один период — без него тариф не появится на витрине.")}

+ {:else} +
+
+ {at("tariff_col_period_months", {}, "Срок, мес.")} + {at("tariff_col_price_rub", {}, "Цена, ₽")} + {at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")} + +
+ {#each tariffDraft.periodRows as row, index} +
+ + + + +
+ {/each} +
+ {/if} +
+ {:else} +
+
+
+ {at("tariff_pricing_traffic_title", {}, "Пакеты трафика")} + {at("tariff_pricing_traffic_subtitle", {}, "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»")} +
+
+ + +
+
+
+
+ {at("payment_rub", {}, "Оплата рублями")} + {#if tariffDraft.trafficRubRows.length} +
+ {at("tariff_col_volume_gb", {}, "Объём, GB")} + {at("tariff_col_price_rub", {}, "Цена, ₽")} + +
+ {/if} + {#each tariffDraft.trafficRubRows as row, index} +
+ + + +
+ {/each} +
+
+ {at("payment_stars", {}, "Оплата Telegram Stars")} + {#if tariffDraft.trafficStarsRows.length} +
+ {at("tariff_col_volume_gb", {}, "Объём, GB")} + {at("tariff_col_price_stars", {}, "Цена, ⭐")} + +
+ {/if} + {#each tariffDraft.trafficStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+ {/if} +
+ + + {#if tariffDraft.billing_model === "period"} +
+
+
+ {at("tariff_topup_title", {}, "Докупка трафика поверх месячного лимита")} + {at("tariff_topup_subtitle", {}, "Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки")} +
+
+ + +
+
+
+
+ {at("payment_rub", {}, "Оплата рублями")} + {#if tariffDraft.topupRubRows.length} +
+ {at("tariff_col_volume_gb", {}, "Объём, GB")} + {at("tariff_col_price_rub", {}, "Цена, ₽")} + +
+ {/if} + {#each tariffDraft.topupRubRows as row, index} +
+ + + +
+ {/each} +
+
+ {at("payment_stars", {}, "Оплата Telegram Stars")} + {#if tariffDraft.topupStarsRows.length} +
+ {at("tariff_col_volume_gb", {}, "Объём, GB")} + {at("tariff_col_price_stars", {}, "Цена, ⭐")} + +
+ {/if} + {#each tariffDraft.topupStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+ {:else} +

{at("tariff_topup_traffic_hint", {}, "Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания.")}

+ {/if} +
+ + +
+
+
+ {at("tariff_hwid_packages_title", {}, "Пакеты дополнительных устройств (HWID)")} + {at("tariff_hwid_packages_subtitle", {}, "Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»")} +
+
+ + +
+
+
+
+ {at("payment_rub", {}, "Оплата рублями")} + {#if tariffDraft.hwidRubRows.length} +
+ {at("tariff_col_hwid_count", {}, "+ устройств")} + {at("tariff_col_price_rub", {}, "Цена, ₽")} + +
+ {/if} + {#each tariffDraft.hwidRubRows as row, index} +
+ + + +
+ {/each} +
+
+ {at("payment_stars", {}, "Оплата Telegram Stars")} + {#if tariffDraft.hwidStarsRows.length} +
+ {at("tariff_col_hwid_count", {}, "+ устройств")} + {at("tariff_col_price_stars", {}, "Цена, ⭐")} + +
+ {/if} + {#each tariffDraft.hwidStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+
+
+ +
+ + +
+
+ + tariffsStore.updateState({ tariffDeleteOpen: false })} + class="admin-dialog" +> +
+ + +
+
diff --git a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte index 13adde6..d1cfcbe 100644 --- a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte @@ -1,57 +1,89 @@ {#if tariffsLoading} -
Загрузка…
+
{at("loading", {}, "Загрузка…")}
{:else}
- Всего тарифов + {at("tariffs_stat_total", {}, "Всего тарифов")} {tariffsCatalog.tariffs.length} - Включено: {enabledTariffs.length} + {at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length}
- По умолчанию + {at("tariffs_stat_default", {}, "По умолчанию")} {tariffsCatalog.default_tariff || "—"} - Используется для новых подписок + {at("tariffs_stat_default_hint", {}, "Используется для новых подписок")}
- Отключено + {at("tariffs_stat_disabled", {}, "Отключено")} {disabledTariffs} - Скрыто с витрины + {at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}
-

Каталог тарифов

+

{at("tariffs_title", {}, "Каталог тарифов")}

{tariffsPath || "config/tariffs.json"}
- +
+ + +
{#if !tariffsCatalog.tariffs.length}
- Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога. + {at("tariffs_catalog_empty", {}, "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.")}
{:else}
@@ -62,46 +94,46 @@
{tariffName(tariff)} {#if tariff.key === tariffsCatalog.default_tariff} - Default + {at("status_default", {}, "Default")} {/if}
{tariff.key}
{#if tariff.enabled === false} - Выключен + {at("status_disabled", {}, "Выключен")} {:else} - Активен + {at("status_active", {}, "Активен")} {/if}
-

{tariff.descriptions?.ru || tariff.descriptions?.en || "Без описания"}

+

{tariff.descriptions?.ru || tariff.descriptions?.en || at("no_description", {}, "Без описания")}

- {tariff.billing_model === "traffic" ? "Трафик" : "Периоды"} + {tariff.billing_model === "traffic" ? at("tariff_model_traffic", {}, "Трафик") : at("tariff_model_periods", {}, "Периоды")} {tariffPriceSummary(tariff)} - Squads: {(tariff.squad_uuids || []).length} - Premium: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"} - Устройства: {tariff.hwid_device_limit ?? "env"} + {at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length} + {at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"} + {at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ?? "env"}
- - diff --git a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte new file mode 100644 index 0000000..e3f71c3 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte @@ -0,0 +1,349 @@ + + + + {#if openedUser} + {#if userDetailLoading || !openedUserDetail} +

{at("loading", {}, "Загрузка…")}

+ {:else} +
+ + +
+ + + {at("user_tab_subscription", {}, "Подписка")} + {at("user_tab_activity", {}, "Активность")} + {at("user_tab_actions", {}, "Действия")} + + + + {#if openedUserDetail.active_subscription} +
    +
  • {at("user_label_active_until", {}, "Активна до")}{fmtDate(openedUserDetail.active_subscription.end_date)}
  • +
  • {at("user_label_tariff", {}, "Тариф")}{openedUserDetail.active_subscription.tariff_key || "—"}
  • +
  • {at("user_label_auto_renew", {}, "Авто-продление")}{pretty(openedUserDetail.active_subscription.auto_renew_enabled)}
  • +
  • {at("user_label_provider", {}, "Провайдер")}{openedUserDetail.active_subscription.provider || "—"}
  • +
+
+
+
+ {at("user_label_main_traffic", {}, "Основной трафик")} + {trafficOfLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)} +
+
+ +
+
+ {at("user_traffic_left", { left: trafficLeftLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes) }, "Осталось: " + trafficLeftLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes))} + {trafficPercentValue(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)}% +
+
+ {#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0} +
+
+ {at("user_label_premium_squads", {}, "Premium-сквады")} + {trafficOfLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)} +
+
+ +
+
+ {at("user_traffic_left", { left: trafficLeftLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes) }, "Осталось: " + trafficLeftLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes))} + {trafficPercentValue(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)}% +
+
+ {/if} +
+ {:else} +

{at("user_no_active_subscription", {}, "Активной подписки нет")}

+ {/if} + + {#if (openedUserDetail.subscriptions || []).length} + +
{at("user_history_title", { count: openedUserDetail.subscriptions.length }, `История подписок · ${openedUserDetail.subscriptions.length}`)}
+
+ {#each openedUserDetail.subscriptions.slice(0, 8) as sub} +
+
+ {sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа")} + {at("user_history_until", { date: fmtDate(sub.end_date) }, `до ${fmtDate(sub.end_date)}`)} +
+ {#if sub.is_active} + {at("user_history_active", {}, "Активна")} + {:else} + {sub.status_from_panel || at("user_history_status_panel", {}, "История")} + {/if} +
+ {/each} +
+ {/if} +
+ + +
{at("user_recent_payments_title", { count: (openedUserDetail.recent_payments || []).length }, `Последние платежи · ${(openedUserDetail.recent_payments || []).length}`)}
+ {#if (openedUserDetail.recent_payments || []).length} +
+ {#each openedUserDetail.recent_payments.slice(0, 8) as payment} +
+
+ {fmtMoney(payment.amount, payment.currency)} + {payment.provider} · {fmtDateShort(payment.created_at)} +
+ {payment.status} +
+ {/each} +
+ {:else} +

{at("user_no_payments", {}, "Платежей нет")}

+ {/if} +
+ + +
+ + + {at("user_label_extend", {}, "Продлить подписку")} +
+ + +
+
+
+ + + {at("user_label_telegram_msg", {}, "Сообщение в Telegram")} + {at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")} + + +
+ + +
+ +
+
+ {at("user_danger_zone_title", {}, "Опасные действия")} + {at("user_danger_zone_subtitle", {}, "Эти действия требуют подтверждения и (для удаления) необратимы")} +
+
+ {#if openedUser.is_banned} + + {:else} + + {/if} + +
+
+
+
+
+
+ {/if} + {/if} +
+ + usersStore.updateState({ userMessageConfirmOpen: false })} + class="admin-dialog" +> +
{userMessageDraft}
+
+ + +
+
+ + usersStore.updateState({ userBanConfirmOpen: false })} + class="admin-dialog" +> +
+ + +
+
+ + usersStore.updateState({ userDeleteOpen: false })} + class="admin-dialog" +> +
+ + +
+
diff --git a/bot/app/web/frontend/src/admin/sections/UsersSection.svelte b/bot/app/web/frontend/src/admin/sections/UsersSection.svelte index f1e4701..9670610 100644 --- a/bot/app/web/frontend/src/admin/sections/UsersSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/UsersSection.svelte @@ -1,30 +1,63 @@
@@ -33,10 +66,11 @@ type="search" class="input" placeholder={at("users_search_placeholder", {}, "ID, @username или email")} - bind:value={usersQuery} - on:keydown={(e) => e.key === "Enter" && ((usersPage = 0), loadUsers())} + value={usersQuery} + on:input={(e) => usersStore.updateState({ usersQuery: e.target.value })} + on:keydown={(e) => e.key === "Enter" && (usersStore.updateState({ usersPage: 0 }), usersStore.loadUsers())} /> - +
@@ -45,7 +79,7 @@ { usersFilter = value; usersPage = 0; loadUsers(); }} + onValueChange={(value) => { usersStore.updateState({ usersFilter: value, usersPage: 0 }); usersStore.loadUsers(); }} > {optionLabel(USERS_FILTER_OPTIONS, usersFilter)} @@ -69,7 +103,7 @@ { usersPanelStatus = value; usersPage = 0; loadUsers(); }} + onValueChange={(value) => { usersStore.updateState({ usersPanelStatus: value, usersPage: 0 }); usersStore.loadUsers(); }} > {optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus)} @@ -93,7 +127,7 @@ { usersSort = value; usersPage = 0; loadUsers(); }} + onValueChange={(value) => { usersStore.updateState({ usersSort: value, usersPage: 0 }); usersStore.loadUsers(); }} > {optionLabel(USERS_SORT_OPTIONS, usersSort)} @@ -146,7 +180,7 @@ {@const avatar = resolvedAvatarUrl(user)} {@const badge = panelStatusBadge(user)}
  • - -
  • diff --git a/bot/app/web/frontend/src/lib/admin/stores/adsStore.js b/bot/app/web/frontend/src/lib/admin/stores/adsStore.js new file mode 100644 index 0000000..fff0b22 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/adsStore.js @@ -0,0 +1,101 @@ +import { writable } from "svelte/store"; + +export function createAdsStore({ api, onToast, at }) { + const state = writable({ + ads: [], + adsTotals: null, + adsLoading: false, + adCreateOpen: false, + adDraft: { source: "", start_param: "", cost: 0 }, + }); + + async function loadAds() { + state.update((s) => ({ ...s, adsLoading: true })); + try { + const data = await api("/admin/ads"); + if (data?.ok) { + state.update((s) => ({ + ...s, + ads: data.campaigns || [], + adsTotals: data.totals || {}, + })); + } + } finally { + state.update((s) => ({ ...s, adsLoading: false })); + } + } + + async function createAd() { + let draft = null; + state.update((s) => { + draft = s.adDraft; + return s; + }); + if (!draft.source.trim() || !draft.start_param.trim()) return; + + const res = await api("/admin/ads", { + method: "POST", + body: JSON.stringify(draft), + }); + + if (res?.ok) { + onToast(at("ad_created", {}, "Кампания создана")); + state.update((s) => ({ + ...s, + adCreateOpen: false, + adDraft: { source: "", start_param: "", cost: 0 }, + })); + await loadAds(); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } + + async function toggleAd(ad) { + const res = await api(`/admin/ads/${ad.id}/toggle`, { + method: "POST", + body: JSON.stringify({ is_active: !ad.is_active }), + }); + if (res?.ok) { + state.update((s) => ({ + ...s, + ads: s.ads.map((c) => (c.id === ad.id ? { ...c, is_active: !ad.is_active } : c)), + })); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } + + async function deleteAd(ad) { + const res = await api(`/admin/ads/${ad.id}`, { method: "DELETE" }); + if (res?.ok) { + state.update((s) => ({ + ...s, + ads: s.ads.filter((c) => c.id !== ad.id), + })); + onToast(at("ad_deleted", {}, "Кампания удалена")); + } else { + onToast(res?.error || at("error", {}, "Ошибка")); + } + } + + function setCreateOpen(open) { + state.update((s) => ({ ...s, adCreateOpen: open })); + } + + function updateDraft(fields) { + state.update((s) => ({ ...s, adDraft: { ...s.adDraft, ...fields } })); + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + loadAds, + createAd, + toggleAd, + deleteAd, + setCreateOpen, + updateDraft, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js b/bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js new file mode 100644 index 0000000..33fea5a --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js @@ -0,0 +1,60 @@ +import { writable } from "svelte/store"; + +export function createBroadcastStore({ api, onToast, at }) { + const state = writable({ + broadcastTarget: "all", + broadcastText: "", + broadcastBusy: false, + broadcastResult: null, + }); + + const BROADCAST_TARGET_OPTIONS = [ + { value: "all", label: at("broadcast_target_all", {}, "Все активные") }, + { value: "active", label: at("broadcast_target_active", {}, "С подпиской") }, + { value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") }, + ]; + + async function runBroadcast() { + let text = ""; + let target = ""; + state.update((s) => { + text = s.broadcastText; + target = s.broadcastTarget; + s.broadcastBusy = true; + s.broadcastResult = null; + return s; + }); + + try { + const res = await api("/admin/broadcast", { + method: "POST", + body: JSON.stringify({ target, text }), + }); + if (res?.ok) { + state.update((s) => ({ + ...s, + broadcastText: "", + broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 }, + })); + onToast(at("broadcast_started", {}, "Рассылка запущена")); + } else { + onToast(res?.error || at("broadcast_failed", {}, "Ошибка рассылки")); + } + } finally { + state.update((s) => ({ ...s, broadcastBusy: false })); + } + } + + function updateField(fields) { + state.update((s) => ({ ...s, ...fields })); + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + runBroadcast, + updateField, + BROADCAST_TARGET_OPTIONS, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/logsStore.js b/bot/app/web/frontend/src/lib/admin/stores/logsStore.js new file mode 100644 index 0000000..7783bd5 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/logsStore.js @@ -0,0 +1,59 @@ +import { writable } from "svelte/store"; + +export function createLogsStore({ api, at }) { + const state = writable({ + logs: [], + logsTotal: 0, + logsPage: 0, + logsUserFilter: "", + logsLoading: false, + }); + + const LOGS_PAGE_SIZE = 50; + + async function loadLogs() { + state.update((s) => ({ ...s, logsLoading: true })); + let currentPage = 0; + let filter = ""; + state.update((s) => { + currentPage = s.logsPage; + filter = s.logsUserFilter; + return s; + }); + + try { + let q = `/admin/logs?page=${currentPage}&page_size=${LOGS_PAGE_SIZE}`; + if (filter.trim()) { + q += `&user_id=${encodeURIComponent(filter.trim())}`; + } + const data = await api(q); + if (data?.ok) { + state.update((s) => ({ + ...s, + logs: data.logs || [], + logsTotal: data.total || 0, + })); + } + } finally { + state.update((s) => ({ ...s, logsLoading: false })); + } + } + + function setPage(page) { + state.update((s) => ({ ...s, logsPage: page })); + loadLogs(); + } + + function setFilter(filter) { + state.update((s) => ({ ...s, logsUserFilter: filter })); + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + loadLogs, + setPage, + setFilter, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js b/bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js new file mode 100644 index 0000000..498d4dd --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js @@ -0,0 +1,47 @@ +import { writable } from "svelte/store"; + +export function createPaymentsStore({ api, at }) { + const state = writable({ + payments: [], + paymentsTotal: 0, + paymentsPage: 0, + paymentsLoading: false, + }); + + const PAYMENTS_PAGE_SIZE = 25; + + async function loadPayments() { + state.update((s) => ({ ...s, paymentsLoading: true })); + let currentPage = 0; + state.update((s) => { + currentPage = s.paymentsPage; + return s; + }); + + try { + const data = await api(`/admin/payments?page=${currentPage}&page_size=${PAYMENTS_PAGE_SIZE}`); + if (data?.ok) { + state.update((s) => ({ + ...s, + payments: data.payments || [], + paymentsTotal: data.total || 0, + })); + } + } finally { + state.update((s) => ({ ...s, paymentsLoading: false })); + } + } + + function setPage(page) { + state.update((s) => ({ ...s, paymentsPage: page })); + loadPayments(); + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + loadPayments, + setPage, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/promosStore.js b/bot/app/web/frontend/src/lib/admin/stores/promosStore.js new file mode 100644 index 0000000..9831299 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/promosStore.js @@ -0,0 +1,112 @@ +import { writable } from "svelte/store"; + +export function createPromosStore({ api, onToast }) { + const state = writable({ + promos: [], + promosTotal: 0, + promosPage: 0, + promosLoading: false, + promoCreateOpen: false, + promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 }, + }); + + const PROMOS_PAGE_SIZE = 25; + + async function loadPromos() { + state.update((s) => ({ ...s, promosLoading: true })); + let currentPage = 0; + state.update((s) => { + currentPage = s.promosPage; + return s; + }); + try { + const data = await api(`/admin/promos?page=${currentPage}&page_size=${PROMOS_PAGE_SIZE}`); + if (data?.ok) { + state.update((s) => ({ ...s, promos: data.promos || [], promosTotal: data.total || 0 })); + } + } finally { + state.update((s) => ({ ...s, promosLoading: false })); + } + } + + async function createPromo() { + let draft = null; + state.update((s) => { + draft = s.promoDraft; + return s; + }); + if (!draft.code.trim()) return; + + const res = await api("/admin/promos", { + method: "POST", + body: JSON.stringify(draft), + }); + + if (res?.ok) { + onToast("Промокод создан"); + state.update((s) => ({ + ...s, + promoCreateOpen: false, + promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 } + })); + await loadPromos(); + } else { + onToast(res?.error || "Ошибка"); + } + } + + async function togglePromo(promo) { + const res = await api(`/admin/promos/${promo.id}`, { + method: "PATCH", + body: JSON.stringify({ is_active: !promo.is_active }), + }); + if (res?.ok) { + state.update((s) => ({ + ...s, + promos: s.promos.map((p) => (p.id === promo.id ? res.promo : p)) + })); + } else { + onToast(res?.error || "Ошибка"); + } + } + + async function deletePromo(promo) { + const res = await api(`/admin/promos/${promo.id}`, { method: "DELETE" }); + if (res?.ok) { + state.update((s) => ({ + ...s, + promos: s.promos.filter((p) => p.id !== promo.id) + })); + onToast("Промокод удалён"); + } else { + onToast(res?.error || "Ошибка"); + } + } + + function setPage(page) { + state.update((s) => ({ ...s, promosPage: page })); + loadPromos(); + } + + function setCreateOpen(open) { + state.update((s) => ({ ...s, promoCreateOpen: open })); + } + + function updateDraft(fields) { + state.update((s) => ({ ...s, promoDraft: { ...s.promoDraft, ...fields } })); + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + loadPromos, + createPromo, + togglePromo, + deletePromo, + setPage, + setCreateOpen, + updateDraft, + PROMOS_PAGE_SIZE, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js new file mode 100644 index 0000000..edd2993 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js @@ -0,0 +1,92 @@ +import { writable } from "svelte/store"; + +export function createSettingsStore({ api, onToast, at }) { + const state = writable({ + settingsSections: [], + settingsLoading: false, + settingsDirty: {}, + settingsSaving: false, + }); + + async function loadSettings(isCompact = false) { + state.update((s) => ({ ...s, settingsLoading: true, settingsDirty: {} })); + try { + const data = await api("/admin/settings"); + if (data?.ok) { + state.update((s) => ({ + ...s, + settingsSections: data.sections || [], + })); + } + } finally { + state.update((s) => ({ ...s, settingsLoading: false })); + } + } + + function markDirty(key, value, deleted = false) { + state.update((s) => ({ + ...s, + settingsDirty: { ...s.settingsDirty, [key]: { value, deleted } }, + })); + } + + function clearDirty(key) { + state.update((s) => { + const next = { ...s.settingsDirty }; + delete next[key]; + return { ...s, settingsDirty: next }; + }); + } + + async function saveSettings(onSettingsSaved) { + let dirty = {}; + state.update(s => { dirty = s.settingsDirty; return s; }); + if (!Object.keys(dirty).length) return; + + state.update(s => ({ ...s, settingsSaving: true })); + try { + const updates = {}; + const deletes = []; + for (const [key, change] of Object.entries(dirty)) { + if (change.deleted) deletes.push(key); + else updates[key] = change.value; + } + const res = await api("/admin/settings", { + method: "PATCH", + body: JSON.stringify({ updates, deletes }), + }); + if (res?.ok) { + onToast(at("settings_saved", {}, "Настройки сохранены")); + state.update(s => ({ ...s, settingsDirty: {} })); + if (onSettingsSaved) await onSettingsSaved({ updates, deletes }); + await loadSettings(); + } else if (res?.errors) { + const summary = Object.entries(res.errors).map(([k, v]) => `${k}: ${v}`).join("; "); + onToast(`Ошибки: ${summary}`); + } else { + onToast(res?.error || "Ошибка"); + } + } finally { + state.update(s => ({ ...s, settingsSaving: false })); + } + } + + function resetField(field) { + if (field.overridden) { + markDirty(field.key, "", true); + } else { + clearDirty(field.key); + } + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + loadSettings, + markDirty, + clearDirty, + resetField, + saveSettings, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/statsStore.js b/bot/app/web/frontend/src/lib/admin/stores/statsStore.js new file mode 100644 index 0000000..e6e35c6 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/statsStore.js @@ -0,0 +1,53 @@ +import { writable } from "svelte/store"; + +export function createStatsStore({ api, onToast, at }) { + const state = writable({ + stats: null, + statsLoading: false, + statsError: "", + syncBusy: false, + }); + + async function loadStats() { + state.update((s) => ({ ...s, statsLoading: true, statsError: "" })); + try { + const data = await api("/admin/stats"); + if (!data?.ok) { + state.update((s) => ({ ...s, statsError: data?.error || "load_failed" })); + } else { + state.update((s) => ({ ...s, stats: data })); + } + } catch (e) { + state.update((s) => ({ ...s, statsError: e?.message || String(e) })); + } finally { + state.update((s) => ({ ...s, statsLoading: false })); + } + } + + async function triggerSync() { + let busy = false; + state.update(s => { busy = s.syncBusy; return s; }); + if (busy) return; + + state.update((s) => ({ ...s, syncBusy: true })); + try { + const res = await api("/admin/sync", { method: "POST" }); + if (res?.ok) { + onToast(at("sync_started", {}, "Синхронизация запущена")); + await loadStats(); + } else { + onToast(res?.error || at("sync_error", {}, "Ошибка синхронизации")); + } + } finally { + state.update((s) => ({ ...s, syncBusy: false })); + } + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + loadStats, + triggerSync, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js b/bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js new file mode 100644 index 0000000..b572a28 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js @@ -0,0 +1,255 @@ +import { writable } from "svelte/store"; +import { + emptyTariffDraft, + cloneCatalog, + draftFromTariff, + tariffFromDraft as tariffFromDraftFn, + normalizeUuidList, +} from "../tariffDraft.js"; + +export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at }) { + const state = writable({ + tariffsCatalog: { + default_tariff: "", + topup_packages_default: { rub: [], stars: [] }, + tariffs: [], + }, + tariffsPath: "", + tariffsLoading: false, + tariffsSaving: false, + tariffEditorOpen: false, + tariffEditingKey: "", + tariffDeleteOpen: false, + tariffDeleteTarget: null, + tariffDraft: emptyTariffDraft(), + panelSquads: [], + panelSquadsLoading: false, + selectedBaseSquad: "", + selectedPremiumSquad: "", + tariffEditorTab: "general", + }); + + const tariffFromDraft = (draft) => tariffFromDraftFn(draft); + + async function loadTariffs() { + state.update((s) => ({ ...s, tariffsLoading: true })); + try { + loadPanelSquads(); + const data = await api("/admin/tariffs"); + if (data?.ok) { + state.update((s) => ({ + ...s, + tariffsCatalog: cloneCatalog(data.catalog), + tariffsPath: data.path || "", + })); + } else { + flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы")); + } + } finally { + state.update((s) => ({ ...s, tariffsLoading: false })); + } + } + + async function loadPanelSquads() { + let loading = false; + state.update(s => { loading = s.panelSquadsLoading; return s; }); + if (loading) return; + + state.update((s) => ({ ...s, panelSquadsLoading: true })); + try { + const data = await api("/admin/panel/internal-squads"); + if (data?.ok) state.update(s => ({ ...s, panelSquads: data.squads || [] })); + } catch (e) { + state.update(s => ({ ...s, panelSquads: [] })); + } finally { + state.update((s) => ({ ...s, panelSquadsLoading: false })); + } + } + + function squadLabel(uuid) { + let squads = []; + state.update(s => { squads = s.panelSquads; return s; }); + const squad = squads.find((item) => item.uuid === uuid); + return squad ? `${squad.name} · ${uuid.slice(0, 8)}…` : uuid; + } + + function addSquadToDraft(field, uuid) { + if (!uuid) return; + state.update(s => { + const current = normalizeUuidList(s.tariffDraft[field]); + if (current.includes(uuid)) return s; + return { ...s, tariffDraft: { ...s.tariffDraft, [field]: [...current, uuid] } }; + }); + } + + function removeSquadFromDraft(field, uuid) { + state.update(s => { + return { + ...s, + tariffDraft: { + ...s.tariffDraft, + [field]: normalizeUuidList(s.tariffDraft[field]).filter((item) => item !== uuid), + } + }; + }); + } + + async function persistTariffs(nextCatalog, successText) { + state.update((s) => ({ ...s, tariffsSaving: true })); + let currentPath = ""; + state.update(s => { currentPath = s.tariffsPath; return s; }); + + try { + const res = await api("/admin/tariffs", { + method: "PUT", + body: JSON.stringify({ catalog: nextCatalog }), + }); + if (res?.ok) { + state.update(s => ({ + ...s, + tariffsCatalog: cloneCatalog(res.catalog), + tariffsPath: res.path || currentPath, + tariffEditorOpen: false, + tariffDeleteOpen: false, + tariffDeleteTarget: null, + })); + if (onTariffsSaved) await onTariffsSaved(res.catalog); + flash(successText || at("tariffs_saved", {}, "Тарифы сохранены")); + } else { + flash(res?.message || res?.error || at("tariffs_save_failed", {}, "Ошибка сохранения тарифов")); + } + } finally { + state.update((s) => ({ ...s, tariffsSaving: false })); + } + } + + function openCreateTariff() { + state.update(s => ({ + ...s, + tariffEditingKey: "", + tariffDraft: emptyTariffDraft(), + tariffEditorTab: "general", + selectedBaseSquad: "", + selectedPremiumSquad: "", + tariffEditorOpen: true, + })); + } + + function openEditTariff(tariff) { + state.update(s => ({ + ...s, + tariffEditingKey: tariff.key, + tariffDraft: draftFromTariff(tariff), + tariffEditorTab: "general", + selectedBaseSquad: "", + selectedPremiumSquad: "", + tariffEditorOpen: true, + })); + } + + async function saveTariffDraft() { + let s; + state.update(st => { s = st; return st; }); + const tariff = tariffFromDraft(s.tariffDraft); + if (!tariff.key) { + flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа")); + return; + } + const existing = (s.tariffsCatalog.tariffs || []).find((item) => item.key === tariff.key && item.key !== s.tariffEditingKey); + if (existing) { + flash(at("tariff_error_key_exists", {}, "Тариф с таким ключом уже есть")); + return; + } + const current = s.tariffsCatalog.tariffs || []; + const tariffs = s.tariffEditingKey + ? current.map((item) => (item.key === s.tariffEditingKey ? tariff : item)) + : [...current, tariff]; + const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key); + if (!enabledKeys.length) { + flash(at("tariff_error_min_enabled", {}, "Должен быть хотя бы один включённый тариф")); + return; + } + const currentDefault = s.tariffsCatalog.default_tariff === s.tariffEditingKey ? tariff.key : s.tariffsCatalog.default_tariff; + const defaultTariff = enabledKeys.includes(currentDefault) + ? currentDefault + : enabledKeys[0]; + await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs }, at("tariff_saved", {}, "Тариф сохранён")); + } + + async function toggleTariffEnabled(tariff) { + let s; + state.update(st => { s = st; return st; }); + const tariffs = (s.tariffsCatalog.tariffs || []).map((item) => + item.key === tariff.key ? { ...item, enabled: item.enabled === false } : item, + ); + const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key); + if (!enabledKeys.length) { + flash(at("tariff_error_min_enabled", {}, "Должен остаться хотя бы один включённый тариф")); + return; + } + const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff) ? s.tariffsCatalog.default_tariff : enabledKeys[0]; + await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs }, at("tariff_status_updated", {}, "Статус тарифа обновлён")); + } + + async function setDefaultTariff(key) { + let s; + state.update(st => { s = st; return st; }); + if (!key || key === s.tariffsCatalog.default_tariff) return; + await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: key }, at("tariff_default_updated", {}, "Тариф по умолчанию обновлён")); + } + + async function deleteTariff() { + let s; + state.update(st => { s = st; return st; }); + if (!s.tariffDeleteTarget) return; + const tariffs = (s.tariffsCatalog.tariffs || []).filter((item) => item.key !== s.tariffDeleteTarget.key); + const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key); + if (!enabledKeys.length) { + flash(at("tariff_error_delete_last_enabled", {}, "Нельзя удалить последний включённый тариф")); + return; + } + const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff) ? s.tariffsCatalog.default_tariff : enabledKeys[0]; + await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs }, at("tariff_deleted", {}, "Тариф удалён")); + } + + function addDraftRow(field, row) { + state.update(s => ({ + ...s, + tariffDraft: { ...s.tariffDraft, [field]: [...(s.tariffDraft[field] || []), row] } + })); + } + + function removeDraftRow(field, index) { + state.update(s => ({ + ...s, + tariffDraft: { + ...s.tariffDraft, + [field]: (s.tariffDraft[field] || []).filter((_, idx) => idx !== index), + } + })); + } + + function updateState(updates) { + state.update(s => ({ ...s, ...updates })); + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + updateState, + loadTariffs, + loadPanelSquads, + squadLabel, + addSquadToDraft, + removeSquadFromDraft, + openCreateTariff, + openEditTariff, + saveTariffDraft, + toggleTariffEnabled, + setDefaultTariff, + deleteTariff, + addDraftRow, + removeDraftRow, + }; +} diff --git a/bot/app/web/frontend/src/lib/admin/stores/usersStore.js b/bot/app/web/frontend/src/lib/admin/stores/usersStore.js new file mode 100644 index 0000000..8c78030 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/usersStore.js @@ -0,0 +1,295 @@ +import { writable } from "svelte/store"; + +export function createUsersStore({ api, onToast, at }) { + const USERS_PAGE_SIZE = 25; + + const state = writable({ + users: [], + usersTotal: 0, + usersPage: 0, + usersQuery: "", + usersFilter: "all", + usersPanelStatus: "all", + usersSort: "registered_desc", + usersLoading: false, + + openedUser: null, + openedUserDetail: null, + userDetailLoading: false, + userMessageDraft: "", + userExtendDays: 30, + userActionBusy: false, + userDeleteOpen: false, + userBanConfirmOpen: false, + userMessageConfirmOpen: false, + userDetailTab: "profile", + }); + + let _activeRef = "stats"; // fallback if active isn't tracked + + function setActive(active) { + _activeRef = active; + } + + function _pushUserPath(userId) { + if (typeof window === "undefined") return; + if (window.location.protocol === "file:") return; + if (_activeRef !== "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}`); + } + + async function loadUsers() { + state.update((s) => ({ ...s, usersLoading: true })); + let s; + state.update((st) => { s = st; return st; }); + + try { + const params = new URLSearchParams({ + page: String(s.usersPage), + page_size: String(USERS_PAGE_SIZE), + }); + if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim()); + if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter); + if (s.usersPanelStatus && s.usersPanelStatus !== "all") params.set("panel_status", s.usersPanelStatus); + if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort); + const data = await api(`/admin/users?${params.toString()}`); + if (data?.ok) { + state.update(st => ({ + ...st, + users: data.users || [], + usersTotal: data.total || (data.users || []).length, + })); + } + } finally { + state.update((st) => ({ ...st, usersLoading: false })); + } + } + + async function openUser(userOrId, opts = {}) { + const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId); + if (!userId) return; + + state.update(s => ({ + ...s, + openedUser: typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId }, + openedUserDetail: null, + userMessageDraft: "", + userMessageConfirmOpen: false, + userExtendDays: 30, + userDetailLoading: true, + userDetailTab: "subscription", + })); + + if (!opts.skipPush) _pushUserPath(userId); + try { + const res = await api(`/admin/users/${userId}`); + if (res?.ok) { + state.update(s => ({ + ...s, + openedUserDetail: res, + openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser + })); + } else { + onToast(res?.error || "load_failed"); + state.update(s => ({ ...s, openedUser: null })); + if (!opts.skipPush) _pushUserPath(null); + } + } finally { + state.update(s => ({ ...s, userDetailLoading: false })); + } + } + + function closeUser(opts = {}) { + let wasOpen = false; + state.update(s => { + wasOpen = Boolean(s.openedUser); + return { + ...s, + openedUser: null, + openedUserDetail: null, + userDeleteOpen: false, + userBanConfirmOpen: false, + userMessageConfirmOpen: false, + }; + }); + if (wasOpen && !opts.skipPush) _pushUserPath(null); + } + + function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) { + if (!text) return; + if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(text).then( + () => onToast(successMessage), + () => onToast(text) + ); + } else { + onToast(text); + } + } + + function requestBanToggle() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + if (s.openedUser.is_banned) { + applyBanToggle(false); + } else { + state.update(st => ({ ...st, userBanConfirmOpen: true })); + } + } + + async function applyBanToggle(banned) { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}/ban`, { + method: "POST", + body: JSON.stringify({ banned }), + }); + if (res?.ok) { + state.update(st => { + const updatedUser = { ...st.openedUser, is_banned: banned }; + return { + ...st, + openedUser: updatedUser, + users: st.users.map((u) => (u.user_id === updatedUser.user_id ? updatedUser : u)), + userBanConfirmOpen: false, + }; + }); + onToast(banned ? at("user_banned", {}, "Заблокирован") : at("user_unbanned", {}, "Разблокирован")); + } else onToast(res?.error || at("error", {}, "Ошибка")); + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + async function sendUserMessage() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser || !s.userMessageDraft.trim()) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}/message`, { + method: "POST", + body: JSON.stringify({ text: s.userMessageDraft }), + }); + if (res?.ok) { + onToast(at("message_sent", {}, "Отправлено")); + state.update(st => ({ + ...st, + userMessageDraft: "", + userMessageConfirmOpen: false, + })); + } else onToast(res?.error || at("message_send_failed", {}, "Ошибка отправки")); + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + function requestSendUserMessage() { + state.update(s => { + if (!s.openedUser || !s.userMessageDraft.trim()) return s; + return { ...s, userMessageConfirmOpen: true }; + }); + } + + async function previewUserMessage() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser || !s.userMessageDraft.trim()) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}/message/preview`, { + method: "POST", + body: JSON.stringify({ text: s.userMessageDraft }), + }); + if (res?.ok) onToast(at("message_preview_sent", {}, "Превью отправлено в Telegram")); + else onToast(res?.error || at("message_preview_failed", {}, "Ошибка отправки превью")); + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + async function extendUser() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + const days = Number(s.userExtendDays); + if (!days || days <= 0) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, { + method: "POST", + body: JSON.stringify({ days }), + }); + if (res?.ok) { + onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`)); + await openUser(s.openedUser, { skipPush: true }); + } else onToast(res?.error || at("error", {}, "Ошибка")); + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + async function resetTrialUser() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" }); + if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен")); + else onToast(res?.error || at("error", {}, "Ошибка")); + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + async function deleteUser() { + let s; + state.update(st => { s = st; return st; }); + if (!s.openedUser) return; + state.update(st => ({ ...st, userActionBusy: true })); + try { + const res = await api(`/admin/users/${s.openedUser.user_id}`, { method: "DELETE" }); + if (res?.ok) { + onToast(at("user_deleted", {}, "Удален")); + state.update(st => ({ + ...st, + users: st.users.filter((u) => u.user_id !== st.openedUser.user_id) + })); + closeUser(); + } else onToast(res?.error || at("error", {}, "Ошибка")); + } finally { + state.update(st => ({ ...st, userActionBusy: false })); + } + } + + function updateState(updates) { + state.update(s => ({ ...s, ...updates })); + } + + return { + subscribe: state.subscribe, + set: state.set, + update: state.update, + updateState, + setActive, + loadUsers, + openUser, + closeUser, + copyToClipboard, + requestBanToggle, + applyBanToggle, + sendUserMessage, + requestSendUserMessage, + previewUserMessage, + extendUser, + resetTrialUser, + deleteUser, + }; +} diff --git a/bot/app/web/frontend/src/lib/components/ui/button.svelte b/bot/app/web/frontend/src/lib/components/ui/button.svelte index aef6d9a..86ac524 100644 --- a/bot/app/web/frontend/src/lib/components/ui/button.svelte +++ b/bot/app/web/frontend/src/lib/components/ui/button.svelte @@ -28,11 +28,11 @@ {#if href} - + {:else} - {/if} diff --git a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte index c05d31a..2621776 100644 --- a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte +++ b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte @@ -14,7 +14,7 @@ {#if open} +
    + + +
    {#each [1, 2] as _}
    @@ -226,7 +329,7 @@ class:active={planKey(selectedTopupPlan) === planKey(plan)} class="option-row plan-row" type="button" - on:click={() => (selectedTopupPlan = plan)} + onclick={() => (selectedTopupPlan = plan)} > {plan.title} @@ -258,7 +361,7 @@ class:active={selectedMethod === method.id} class="method-card" type="button" - on:click={() => (selectedMethod = method.id)} + onclick={() => (selectedMethod = method.id)} > {#if meta.icon} @@ -320,7 +423,7 @@ class:active={planKey(selectedDeviceTopupPlan) === planKey(plan)} class="option-row plan-row" type="button" - on:click={() => (selectedDeviceTopupPlan = plan)} + onclick={() => (selectedDeviceTopupPlan = plan)} > {t("wa_hwid_devices_package", { count: Number(plan.device_count || plan.months || 0) })} @@ -342,7 +445,7 @@ class:active={selectedMethod === method.id} class="method-card" type="button" - on:click={() => (selectedMethod = method.id)} + onclick={() => (selectedMethod = method.id)} > {#if meta.icon} diff --git a/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte b/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte index 1079321..98befb5 100644 --- a/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte +++ b/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte @@ -73,7 +73,7 @@ + {/if}
    {t("wa_home_traffic_used")} @@ -85,7 +104,7 @@ {#if Number(subscription?.premium_limit_bytes || 0) > 0} {#if canOpenPremiumTopupModal} - + {/if}
    {premiumTitle(subscription)} @@ -157,7 +176,7 @@ {/if} {#if canShowTopupButton} - diff --git a/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte b/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte index 897cab3..c7c572e 100644 --- a/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte +++ b/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte @@ -54,11 +54,11 @@ {#if isAdmin}
    - @@ -95,7 +95,7 @@
    {:else} - {/if}
    @@ -155,27 +155,27 @@
    {#if supportUrl} - {/if} {#if userAgreementUrl} - {/if} {#if privacyPolicyUrl} - {/if} -