From 7b19f14dc3628dfdea93fbcb995fc13b5276c8da Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 06:07:13 +0300 Subject: [PATCH] feat: enhance admin user detail with campaign, panel data, node usage - Add campaign card and referrals list to info tab - Add panel config/links, live traffic, connection info to subscription tab - Add node usage bars with 7/14/30 day period selector - Add getPanelInfo/getNodeUsage API methods and types - Add 18 new i18n keys across 4 languages --- src/api/adminUsers.ts | 43 +++++ src/locales/en.json | 18 ++ src/locales/fa.json | 18 ++ src/locales/ru.json | 17 ++ src/locales/zh.json | 18 ++ src/pages/AdminUserDetail.tsx | 346 +++++++++++++++++++++++++++++++++- 6 files changed, 458 insertions(+), 2 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index c4a3d3d..06a3859 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -101,6 +101,8 @@ export interface UserDetailResponse { used_promocodes: number; has_had_paid_subscription: boolean; lifetime_used_traffic_bytes: number; + campaign_name: string | null; + campaign_id: number | null; restriction_topup: boolean; restriction_subscription: boolean; restriction_reason: string | null; @@ -111,6 +113,33 @@ export interface UserDetailResponse { remnawave_uuid: string | null; } +export interface UserPanelInfo { + found: boolean; + trojan_password: string | null; + vless_uuid: string | null; + ss_password: string | null; + subscription_url: string | null; + happ_link: string | null; + used_traffic_bytes: number; + lifetime_used_traffic_bytes: number; + traffic_limit_bytes: number; + first_connected_at: string | null; + online_at: string | null; + last_connected_node_uuid: string | null; + last_connected_node_name: string | null; +} + +export interface UserNodeUsageItem { + node_uuid: string; + node_name: string; + total_bytes: number; +} + +export interface UserNodeUsageResponse { + items: UserNodeUsageItem[]; + period_days: number; +} + export interface UsersStatsResponse { total_users: number; active_users: number; @@ -508,4 +537,18 @@ export const adminUsersApi = { const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`); return response.data; }, + + // Get panel info + getPanelInfo: async (userId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`); + return response.data; + }, + + // Get node usage + getNodeUsage: async (userId: number, days = 7): Promise => { + const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`, { + params: { days }, + }); + return response.data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index c6f2c0f..3f70580 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1852,6 +1852,24 @@ "lastActivity": "Last activity", "totalSpent": "Total spent", "purchases": "Purchases", + "campaign": "Advertising campaign", + "referralsList": "Referrals list", + "referralEarnings": "Referral earnings", + "noReferrals": "No referrals", + "panelConfig": "Panel configuration", + "trojanPassword": "Trojan password", + "vlessUuid": "VLESS UUID", + "ssPassword": "SS password", + "subscriptionUrl": "Subscription URL", + "happLink": "HAPP link", + "firstConnected": "First connected", + "lastOnline": "Last online", + "lastNode": "Last node", + "liveTraffic": "Current traffic", + "lifetime": "Lifetime", + "nodeUsage": "Node usage", + "copied": "Copied", + "panelNotFound": "User not found in panel", "referral": { "title": "Referral program", "referrals": "Referrals", diff --git a/src/locales/fa.json b/src/locales/fa.json index 971f0bb..5b7e28b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1555,6 +1555,24 @@ "lastActivity": "آخرین فعالیت", "totalSpent": "کل هزینه", "purchases": "خریدها", + "campaign": "کمپین تبلیغاتی", + "referralsList": "لیست ارجاعات", + "referralEarnings": "درآمد ارجاع", + "noReferrals": "بدون ارجاع", + "panelConfig": "پیکربندی پنل", + "trojanPassword": "رمز Trojan", + "vlessUuid": "VLESS UUID", + "ssPassword": "رمز SS", + "subscriptionUrl": "لینک اشتراک", + "happLink": "لینک HAPP", + "firstConnected": "اولین اتصال", + "lastOnline": "آخرین آنلاین", + "lastNode": "آخرین نود", + "liveTraffic": "ترافیک فعلی", + "lifetime": "کل", + "nodeUsage": "مصرف نودها", + "copied": "کپی شد", + "panelNotFound": "کاربر در پنل یافت نشد", "referral": { "title": "برنامه ارجاع", "referrals": "ارجاعات", diff --git a/src/locales/ru.json b/src/locales/ru.json index e5f7cd8..7dd81b3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2379,6 +2379,23 @@ "lastActivity": "Последняя активность", "totalSpent": "Всего потрачено", "purchases": "Покупок", + "campaign": "Рекламная кампания", + "referralsList": "Список рефералов", + "noReferrals": "Нет рефералов", + "panelConfig": "Конфигурация панели", + "trojanPassword": "Trojan пароль", + "vlessUuid": "VLESS UUID", + "ssPassword": "SS пароль", + "subscriptionUrl": "Ссылка подписки", + "happLink": "HAPP ссылка", + "firstConnected": "Первое подключение", + "lastOnline": "Последний онлайн", + "lastNode": "Последняя нода", + "liveTraffic": "Текущий трафик", + "lifetime": "За всё время", + "nodeUsage": "Расход по нодам", + "copied": "Скопировано", + "panelNotFound": "Пользователь не найден в панели", "referral": { "title": "Реферальная программа", "referrals": "Рефералов", diff --git a/src/locales/zh.json b/src/locales/zh.json index c064e7b..568726e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1554,6 +1554,24 @@ "lastActivity": "最后活跃", "totalSpent": "总消费", "purchases": "购买次数", + "campaign": "广告活动", + "referralsList": "推荐列表", + "referralEarnings": "推荐收入", + "noReferrals": "暂无推荐", + "panelConfig": "面板配置", + "trojanPassword": "Trojan 密码", + "vlessUuid": "VLESS UUID", + "ssPassword": "SS 密码", + "subscriptionUrl": "订阅链接", + "happLink": "HAPP 链接", + "firstConnected": "首次连接", + "lastOnline": "最后在线", + "lastNode": "最后节点", + "liveTraffic": "当前流量", + "lifetime": "累计", + "nodeUsage": "节点用量", + "copied": "已复制", + "panelNotFound": "面板中未找到用户", "referral": { "title": "推荐计划", "referrals": "推荐人数", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 1f56d5f..8392c81 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -8,6 +8,9 @@ import { adminUsersApi, type UserDetailResponse, type UserAvailableTariff, + type UserListItem, + type UserPanelInfo, + type UserNodeUsageResponse, type PanelSyncStatusResponse, type UpdateSubscriptionRequest, } from '../api/adminUsers'; @@ -98,6 +101,16 @@ export default function AdminUserDetail() { const [tariffs, setTariffs] = useState([]); const [actionLoading, setActionLoading] = useState(false); + // Referrals + const [referrals, setReferrals] = useState([]); + const [referralsLoading, setReferralsLoading] = useState(false); + + // Panel info & node usage + const [panelInfo, setPanelInfo] = useState(null); + const [panelInfoLoading, setPanelInfoLoading] = useState(false); + const [nodeUsage, setNodeUsage] = useState(null); + const [nodeUsageDays, setNodeUsageDays] = useState(7); + // Inline confirm state const [confirmingAction, setConfirmingAction] = useState(null); const confirmTimerRef = useRef | null>(null); @@ -184,6 +197,45 @@ export default function AdminUserDetail() { } }, []); + const loadReferrals = useCallback(async () => { + if (!userId) return; + try { + setReferralsLoading(true); + const data = await adminUsersApi.getReferrals(userId, 0, 50); + setReferrals(data.users); + } catch { + // ignore + } finally { + setReferralsLoading(false); + } + }, [userId]); + + const loadPanelInfo = useCallback(async () => { + if (!userId) return; + try { + setPanelInfoLoading(true); + const data = await adminUsersApi.getPanelInfo(userId); + setPanelInfo(data); + } catch { + // ignore + } finally { + setPanelInfoLoading(false); + } + }, [userId]); + + const loadNodeUsage = useCallback( + async (days = 7) => { + if (!userId) return; + try { + const data = await adminUsersApi.getNodeUsage(userId, days); + setNodeUsage(data); + } catch { + // ignore + } + }, + [userId], + ); + const handleTicketReply = async () => { if (!selectedTicketId || !replyText.trim()) return; setReplySending(true); @@ -234,10 +286,20 @@ export default function AdminUserDetail() { }, [userId, loadUser, navigate]); useEffect(() => { + if (activeTab === 'info') loadReferrals(); if (activeTab === 'sync') loadSyncStatus(); - if (activeTab === 'subscription') loadTariffs(); + if (activeTab === 'subscription') { + loadTariffs(); + loadPanelInfo(); + } if (activeTab === 'tickets') loadTickets(); - }, [activeTab, loadSyncStatus, loadTariffs, loadTickets]); + }, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadPanelInfo]); + + useEffect(() => { + if (activeTab === 'subscription') { + loadNodeUsage(nodeUsageDays); + } + }, [activeTab, loadNodeUsage, nodeUsageDays]); const handleUpdateBalance = async (isAdd: boolean) => { if (balanceAmount === '' || !userId) return; @@ -440,6 +502,23 @@ export default function AdminUserDetail() { }); }; + const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + notify.success(t('admin.users.detail.copied')); + } catch { + // ignore + } + }; + if (loading) { return (
@@ -577,6 +656,14 @@ export default function AdminUserDetail() {
+ {/* Campaign */} + {user.campaign_name && ( +
+
{t('admin.users.detail.campaign')}
+
{user.campaign_name}
+
+ )} + {/* Referral */}
@@ -610,6 +697,49 @@ export default function AdminUserDetail() {
+ {/* Referrals list */} + {user.referral.referrals_count > 0 && ( +
+
+ {t('admin.users.detail.referralsList')} +
+ {referralsLoading ? ( +
+
+
+ ) : referrals.length === 0 ? ( +
+ {t('admin.users.detail.noReferrals')} +
+ ) : ( +
+ {referrals.map((ref) => ( + + ))} +
+ )} +
+ )} + {/* Restrictions */} {(user.restriction_topup || user.restriction_subscription) && (
@@ -854,6 +984,218 @@ export default function AdminUserDetail() {
)} + + {/* Panel Info */} + {panelInfoLoading ? ( +
+
+
+ ) : panelInfo && !panelInfo.found ? ( +
+ {t('admin.users.detail.panelNotFound')} +
+ ) : panelInfo && panelInfo.found ? ( + <> + {/* Links */} + {(panelInfo.subscription_url || panelInfo.happ_link) && ( +
+
+ {t('admin.users.detail.subscriptionUrl')} / {t('admin.users.detail.happLink')} +
+
+ {panelInfo.subscription_url && ( + + )} + {panelInfo.happ_link && ( + + )} +
+
+ )} + + {/* Config */} + {(panelInfo.trojan_password || panelInfo.vless_uuid || panelInfo.ss_password) && ( +
+
+ {t('admin.users.detail.panelConfig')} +
+
+ {panelInfo.trojan_password && ( + + )} + {panelInfo.vless_uuid && ( + + )} + {panelInfo.ss_password && ( + + )} +
+
+ )} + + {/* Connection info */} +
+
+
+
+ {t('admin.users.detail.firstConnected')} +
+
+ {formatDate(panelInfo.first_connected_at)} +
+
+
+
+ {t('admin.users.detail.lastOnline')} +
+
{formatDate(panelInfo.online_at)}
+
+ {panelInfo.last_connected_node_name && ( +
+
+ {t('admin.users.detail.lastNode')} +
+
+ {panelInfo.last_connected_node_name} +
+
+ )} +
+
+ + {/* Live traffic */} +
+
+ {t('admin.users.detail.liveTraffic')} +
+
+
+ + {formatBytes(panelInfo.used_traffic_bytes)} + + + {panelInfo.traffic_limit_bytes > 0 + ? formatBytes(panelInfo.traffic_limit_bytes) + : '∞'} + +
+
+
0 + ? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%` + : '0%', + }} + /> +
+
+
+ {t('admin.users.detail.lifetime')}:{' '} + {formatBytes(panelInfo.lifetime_used_traffic_bytes)} +
+
+ + {/* Node usage */} +
+
+ + {t('admin.users.detail.nodeUsage')} + +
+ {[7, 14, 30].map((d) => ( + + ))} +
+
+ {nodeUsage && nodeUsage.items.length > 0 ? ( +
+ {nodeUsage.items.map((item) => { + const maxBytes = nodeUsage.items[0].total_bytes; + const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0; + return ( +
+
+ {item.node_name} + {formatBytes(item.total_bytes)} +
+
+
+
+
+ ); + })} +
+ ) : ( +
-
+ )} +
+ + ) : null}
)}