feat: redesign devices management page

This commit is contained in:
lie-must-die
2026-03-23 00:22:57 +03:00
parent 9a9c44440e
commit 6ac5b09de0
9 changed files with 277 additions and 29 deletions

View File

@@ -6,7 +6,8 @@ btn-back =
btn-common =
.notification-close = ❌ Закрыть
.devices-empty = ⚠️ Нет привязанных устройств
.devices-empty = У вас нет подключённых устройств
.cancel = Отмена
.squad-choice = { $selected ->
[1] 🔘
@@ -18,6 +19,13 @@ btn-common =
*[other] { unit-day }
}
btn-devices =
.delete-all = 🗑 Удалить все устройства
.reissue = 🔄 Перевыпустить подписку
.confirm-delete = ✅ Да, удалить
.confirm-reissue = ✅ Да, сбросить
.cancel-reissue = ❌ Нет
btn-remnashop-info =
.release-latest = 👀 Посмотреть
.how-upgrade = ❓ Как обновить

View File

@@ -49,11 +49,30 @@ msg-main-menu =
}
msg-menu-devices =
<b>📱 Мои устройства</b>
<b>📱 Управление устройствами</b>
Здесь вы можете удалить привязанные устройства.
<i>Чтобы увеличить или уменьшить лимит вам нужно изменить подписку и выбрать нужное кол-во устройств.</i>
📊 Подключено: <b>{ $current_count } / { $max_count }</b>
Нажмите на устройство чтобы удалить его.
Если не хватает устройств — измените подписку.
msg-menu-devices-confirm-reissue =
🔄 <b>Перевыпуск подписки</b>
Эта функция генерирует <b>новую</b> ссылку подписки.
⚠️ После сброса старая ссылка <b>перестанет работать</b>.
Вам потребуется:
1⃣ Удалить старую подписку из приложения
2⃣ Добавить новую ссылку из раздела «Подключение»
Вы уверены, что хотите сбросить ссылку?
msg-menu-devices-confirm-delete =
🗑 Удалить устройство <b>{ $selected_device_label }</b>?
msg-menu-devices-confirm-delete-all =
🗑 Удалить <b>все устройства</b>?
msg-menu-invite =
<b>👥 Пригласить друзей</b>

View File

@@ -150,4 +150,9 @@ ntf-sync =
.already-running = ⚠️ <i>Синхронизация уже выполняется. Пожалуйста, подождите.</i>
ntf-menu-editor =
.button-saved = ✅ <i>Кнопка успешно сохранена.</i>
.button-saved = ✅ <i>Кнопка успешно сохранена.</i>
ntf-devices =
.deleted = ✅ Устройство удалено
.all-deleted = ✅ Все устройства удалены
.reissued = ✅ Подписка успешно перевыпущена

View File

@@ -40,6 +40,8 @@ class Remnawave(Protocol):
async def reset_traffic(self, uuid: UUID) -> Optional[UserResponseDto]: ...
async def revoke_subscription(self, uuid: UUID) -> None: ...
def apply_sync(
self,
target: T,

View File

@@ -150,6 +150,13 @@ class RemnawaveImpl(Remnawave):
logger.debug(f"RemnaUser '{uuid}' not found in panel")
return None
async def revoke_subscription(self, uuid: UUID) -> None:
try:
await self.sdk.users.revoke_user_subscription(uuid)
logger.info(f"Subscription for RemnaUser '{uuid}' revoked successfully")
except NotFoundError:
logger.debug(f"RemnaUser '{uuid}' not found in panel")
def apply_sync(self, target: T, source: Union[SubscriptionDto, RemnaSubscriptionDto]) -> T:
if not is_dataclass(target) or not is_dataclass(source):
raise TypeError("Both target and source must be dataclasses")

View File

@@ -2,8 +2,8 @@ from aiogram.enums import ButtonStyle
from aiogram_dialog import Dialog, StartMode
from aiogram_dialog.widgets.input import MessageInput
from aiogram_dialog.widgets.kbd import (
Button,
CopyText,
Button,
ListGroup,
Row,
Start,
@@ -25,11 +25,16 @@ from src.telegram.utils import require_permission
from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate
from src.telegram.window import Window
from .getters import devices_getter, invite_about_getter, invite_getter, menu_getter
from .getters import device_confirm_delete_getter, devices_getter, invite_about_getter, invite_getter, menu_getter
from .handlers import (
on_device_delete,
on_device_delete_confirm,
on_device_delete_request,
on_device_delete_all_confirm,
on_device_delete_all_request,
on_get_trial,
on_invite,
on_reissue_subscription_request,
on_reissue_subscription_confirm,
on_show_qr,
on_withdraw_points,
show_reason,
@@ -115,24 +120,38 @@ devices = Window(
Button(
text=I18nFormat("btn-common.devices-empty"),
id="devices_empty",
when=F["devices_empty"],
when=~F["has_devices"],
),
),
ListGroup(
Row(
CopyText(
text=Format("{item[platform]} - {item[device_model]}"),
copy_text=Format("{item[platform]} - {item[device_model]}"),
),
Button(
text=Format(""),
id="delete",
on_click=on_device_delete,
text=Format("{item[label]}"),
id="device_item",
on_click=on_device_delete_request,
),
),
id="devices_list",
item_id_getter=lambda item: item["short_hwid"],
items="devices",
when=F["has_devices"],
),
Row(
Button(
text=I18nFormat("btn-devices.delete-all"),
id="delete_all",
on_click=on_device_delete_all_request,
when=F["has_devices"],
style=Style(ButtonStyle.DANGER),
),
),
Row(
Button(
text=I18nFormat("btn-devices.reissue"),
id="reissue",
on_click=on_reissue_subscription_request,
style=Style(ButtonStyle.PRIMARY),
),
),
Row(
SwitchTo(
@@ -146,6 +165,48 @@ devices = Window(
getter=devices_getter,
)
device_confirm_delete = Window(
Banner(BannerName.MENU),
I18nFormat("msg-menu-devices-confirm-delete"),
Row(
Button(
text=I18nFormat("btn-devices.confirm-delete"),
id="confirm_delete",
on_click=on_device_delete_confirm,
style=Style(ButtonStyle.DANGER),
),
SwitchTo(
text=I18nFormat("btn-common.cancel"),
id="cancel",
state=MainMenu.DEVICES,
),
),
IgnoreUpdate(),
state=MainMenu.DEVICE_CONFIRM_DELETE,
getter=device_confirm_delete_getter,
)
device_confirm_delete_all = Window(
Banner(BannerName.MENU),
I18nFormat("msg-menu-devices-confirm-delete-all"),
Row(
Button(
text=I18nFormat("btn-devices.confirm-delete"),
id="confirm_delete_all",
on_click=on_device_delete_all_confirm,
style=Style(ButtonStyle.DANGER),
),
SwitchTo(
text=I18nFormat("btn-common.cancel"),
id="cancel",
state=MainMenu.DEVICES,
),
),
IgnoreUpdate(),
state=MainMenu.DEVICE_CONFIRM_DELETE_ALL,
getter=device_confirm_delete_getter,
)
invite = Window(
Banner(BannerName.REFERRAL),
I18nFormat("msg-menu-invite"),
@@ -220,9 +281,33 @@ invite_about = Window(
)
device_confirm_reissue = Window(
Banner(BannerName.MENU),
I18nFormat("msg-menu-devices-confirm-reissue"),
Row(
Button(
text=I18nFormat("btn-devices.confirm-reissue"),
id="confirm_reissue",
on_click=on_reissue_subscription_confirm,
style=Style(ButtonStyle.DANGER),
),
SwitchTo(
text=I18nFormat("btn-devices.cancel-reissue"),
id="cancel_reissue",
state=MainMenu.DEVICES,
),
),
IgnoreUpdate(),
state=MainMenu.DEVICE_CONFIRM_REISSUE,
getter=device_confirm_delete_getter,
)
router = Dialog(
menu,
devices,
device_confirm_delete,
device_confirm_delete_all,
device_confirm_reissue,
invite,
invite_about,
)

View File

@@ -98,6 +98,21 @@ async def menu_getter(
raise MenuRenderError(str(e)) from e
PLATFORM_ICONS = {
"ios": "🍎",
"android": "🤖",
"windows": "🖥️",
"macos": "💻",
"linux": "🐧",
"router": "📡",
"tv": "📺",
}
def get_platform_icon(platform: str) -> str:
return PLATFORM_ICONS.get(platform.lower(), "📱")
@inject
async def devices_getter(
dialog_manager: DialogManager,
@@ -120,6 +135,7 @@ async def devices_getter(
"platform": device.platform,
"device_model": device.device_model,
"user_agent": device.user_agent,
"label": f"{get_platform_icon(device.platform)} {device.platform} ({device.device_model})",
}
for device in devices
]
@@ -131,9 +147,20 @@ async def devices_getter(
"max_count": i18n_format_device_limit(current_subscription.device_limit),
"devices": formatted_devices,
"devices_empty": len(devices) == 0,
"has_devices": len(devices) > 0,
}
@inject
async def device_confirm_delete_getter(
dialog_manager: DialogManager,
user: UserDto,
**kwargs: Any,
) -> dict[str, Any]:
selected_label = dialog_manager.dialog_data.get("selected_device_label", "")
return {"selected_device_label": selected_label}
@inject
async def invite_getter(
dialog_manager: DialogManager,

View File

@@ -80,40 +80,132 @@ async def on_get_trial(
@inject
async def on_device_delete(
async def on_device_delete_request(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
) -> None:
selected_short_hwid = dialog_manager.item_id # type: ignore[attr-defined]
hwid_map = dialog_manager.dialog_data.get("hwid_map", [])
device = next((d for d in hwid_map if d["short_hwid"] == selected_short_hwid), None)
if not device:
raise ValueError(f"Device not found for hwid '{selected_short_hwid}'")
dialog_manager.dialog_data["selected_short_hwid"] = selected_short_hwid
dialog_manager.dialog_data["selected_device_label"] = device["label"]
await dialog_manager.switch_to(state=MainMenu.DEVICE_CONFIRM_DELETE)
@inject
async def on_device_delete_confirm(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
subscription_dao: FromDishka[SubscriptionDao],
remnawave: FromDishka[Remnawave],
i18n: FromDishka[TranslatorRunner],
) -> None:
selected_short_hwid = dialog_manager.item_id # type: ignore[attr-defined]
user: UserDto = dialog_manager.middleware_data[USER_KEY]
hwid_map = dialog_manager.dialog_data.get("hwid_map")
selected_short_hwid = dialog_manager.dialog_data.get("selected_short_hwid")
hwid_map = dialog_manager.dialog_data.get("hwid_map", [])
if not hwid_map:
raise ValueError(f"Selected '{selected_short_hwid}' HWID, but 'hwid_map' is missing")
if not selected_short_hwid or not hwid_map:
raise ValueError("Missing selected device data")
full_hwid = next((d["hwid"] for d in hwid_map if d["short_hwid"] == selected_short_hwid), None)
if not full_hwid:
raise ValueError(f"Full HWID not found for '{selected_short_hwid}'")
current_subscription = await subscription_dao.get_current(user.telegram_id)
if not (current_subscription and current_subscription.device_limit):
raise ValueError("User has no active subscription or device limit unlimited")
devices = await remnawave.delete_device(
await remnawave.delete_device(
user_uuid=current_subscription.user_remna_id,
hwid=full_hwid,
hwid_uuid=full_hwid,
)
logger.info(f"{user.log} Deleted device '{full_hwid}'")
await callback.answer(
text=i18n.get("ntf-devices.deleted"),
show_alert=True,
)
await dialog_manager.switch_to(state=MainMenu.DEVICES)
if devices:
return
await dialog_manager.switch_to(state=MainMenu.MAIN)
@inject
async def on_device_delete_all_request(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
) -> None:
dialog_manager.dialog_data["selected_device_label"] = ""
await dialog_manager.switch_to(state=MainMenu.DEVICE_CONFIRM_DELETE_ALL)
@inject
async def on_device_delete_all_confirm(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
subscription_dao: FromDishka[SubscriptionDao],
remnawave: FromDishka[Remnawave],
i18n: FromDishka[TranslatorRunner],
) -> None:
user: UserDto = dialog_manager.middleware_data[USER_KEY]
hwid_map = dialog_manager.dialog_data.get("hwid_map", [])
current_subscription = await subscription_dao.get_current(user.telegram_id)
if not (current_subscription and current_subscription.device_limit):
raise ValueError("User has no active subscription or device limit unlimited")
for device in hwid_map:
await remnawave.delete_device(
user_uuid=current_subscription.user_remna_id,
hwid_uuid=device["hwid"],
)
logger.info(f"{user.log} Deleted all devices ({len(hwid_map)})")
await callback.answer(
text=i18n.get("ntf-devices.all-deleted"),
show_alert=True,
)
await dialog_manager.switch_to(state=MainMenu.DEVICES)
@inject
async def on_reissue_subscription_request(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
) -> None:
await dialog_manager.switch_to(state=MainMenu.DEVICE_CONFIRM_REISSUE)
@inject
async def on_reissue_subscription_confirm(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
subscription_dao: FromDishka[SubscriptionDao],
remnawave: FromDishka[Remnawave],
i18n: FromDishka[TranslatorRunner],
) -> None:
user: UserDto = dialog_manager.middleware_data[USER_KEY]
current_subscription = await subscription_dao.get_current(user.telegram_id)
if not current_subscription:
raise ValueError(f"No active subscription for user '{user.telegram_id}'")
await remnawave.revoke_subscription(current_subscription.user_remna_id)
logger.info(f"{user.log} Reissued subscription")
await callback.answer(
text=i18n.get("ntf-devices.reissued"),
show_alert=True,
)
await dialog_manager.switch_to(state=MainMenu.DEVICES)
@inject

View File

@@ -6,6 +6,9 @@ from aiogram.fsm.state import State, StatesGroup
class MainMenu(StatesGroup):
MAIN = State()
DEVICES = State()
DEVICE_CONFIRM_DELETE = State()
DEVICE_CONFIRM_DELETE_ALL = State()
DEVICE_CONFIRM_REISSUE = State()
INVITE = State()
INVITE_ABOUT = State()