mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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
This commit is contained in:
@@ -101,6 +101,8 @@ export interface UserDetailResponse {
|
|||||||
used_promocodes: number;
|
used_promocodes: number;
|
||||||
has_had_paid_subscription: boolean;
|
has_had_paid_subscription: boolean;
|
||||||
lifetime_used_traffic_bytes: number;
|
lifetime_used_traffic_bytes: number;
|
||||||
|
campaign_name: string | null;
|
||||||
|
campaign_id: number | null;
|
||||||
restriction_topup: boolean;
|
restriction_topup: boolean;
|
||||||
restriction_subscription: boolean;
|
restriction_subscription: boolean;
|
||||||
restriction_reason: string | null;
|
restriction_reason: string | null;
|
||||||
@@ -111,6 +113,33 @@ export interface UserDetailResponse {
|
|||||||
remnawave_uuid: string | null;
|
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 {
|
export interface UsersStatsResponse {
|
||||||
total_users: number;
|
total_users: number;
|
||||||
active_users: number;
|
active_users: number;
|
||||||
@@ -508,4 +537,18 @@ export const adminUsersApi = {
|
|||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`);
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get panel info
|
||||||
|
getPanelInfo: async (userId: number): Promise<UserPanelInfo> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get node usage
|
||||||
|
getNodeUsage: async (userId: number, days = 7): Promise<UserNodeUsageResponse> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`, {
|
||||||
|
params: { days },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1852,6 +1852,24 @@
|
|||||||
"lastActivity": "Last activity",
|
"lastActivity": "Last activity",
|
||||||
"totalSpent": "Total spent",
|
"totalSpent": "Total spent",
|
||||||
"purchases": "Purchases",
|
"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": {
|
"referral": {
|
||||||
"title": "Referral program",
|
"title": "Referral program",
|
||||||
"referrals": "Referrals",
|
"referrals": "Referrals",
|
||||||
|
|||||||
@@ -1555,6 +1555,24 @@
|
|||||||
"lastActivity": "آخرین فعالیت",
|
"lastActivity": "آخرین فعالیت",
|
||||||
"totalSpent": "کل هزینه",
|
"totalSpent": "کل هزینه",
|
||||||
"purchases": "خریدها",
|
"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": {
|
"referral": {
|
||||||
"title": "برنامه ارجاع",
|
"title": "برنامه ارجاع",
|
||||||
"referrals": "ارجاعات",
|
"referrals": "ارجاعات",
|
||||||
|
|||||||
@@ -2379,6 +2379,23 @@
|
|||||||
"lastActivity": "Последняя активность",
|
"lastActivity": "Последняя активность",
|
||||||
"totalSpent": "Всего потрачено",
|
"totalSpent": "Всего потрачено",
|
||||||
"purchases": "Покупок",
|
"purchases": "Покупок",
|
||||||
|
"campaign": "Рекламная кампания",
|
||||||
|
"referralsList": "Список рефералов",
|
||||||
|
"noReferrals": "Нет рефералов",
|
||||||
|
"panelConfig": "Конфигурация панели",
|
||||||
|
"trojanPassword": "Trojan пароль",
|
||||||
|
"vlessUuid": "VLESS UUID",
|
||||||
|
"ssPassword": "SS пароль",
|
||||||
|
"subscriptionUrl": "Ссылка подписки",
|
||||||
|
"happLink": "HAPP ссылка",
|
||||||
|
"firstConnected": "Первое подключение",
|
||||||
|
"lastOnline": "Последний онлайн",
|
||||||
|
"lastNode": "Последняя нода",
|
||||||
|
"liveTraffic": "Текущий трафик",
|
||||||
|
"lifetime": "За всё время",
|
||||||
|
"nodeUsage": "Расход по нодам",
|
||||||
|
"copied": "Скопировано",
|
||||||
|
"panelNotFound": "Пользователь не найден в панели",
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Реферальная программа",
|
"title": "Реферальная программа",
|
||||||
"referrals": "Рефералов",
|
"referrals": "Рефералов",
|
||||||
|
|||||||
@@ -1554,6 +1554,24 @@
|
|||||||
"lastActivity": "最后活跃",
|
"lastActivity": "最后活跃",
|
||||||
"totalSpent": "总消费",
|
"totalSpent": "总消费",
|
||||||
"purchases": "购买次数",
|
"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": {
|
"referral": {
|
||||||
"title": "推荐计划",
|
"title": "推荐计划",
|
||||||
"referrals": "推荐人数",
|
"referrals": "推荐人数",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
adminUsersApi,
|
adminUsersApi,
|
||||||
type UserDetailResponse,
|
type UserDetailResponse,
|
||||||
type UserAvailableTariff,
|
type UserAvailableTariff,
|
||||||
|
type UserListItem,
|
||||||
|
type UserPanelInfo,
|
||||||
|
type UserNodeUsageResponse,
|
||||||
type PanelSyncStatusResponse,
|
type PanelSyncStatusResponse,
|
||||||
type UpdateSubscriptionRequest,
|
type UpdateSubscriptionRequest,
|
||||||
} from '../api/adminUsers';
|
} from '../api/adminUsers';
|
||||||
@@ -98,6 +101,16 @@ export default function AdminUserDetail() {
|
|||||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Referrals
|
||||||
|
const [referrals, setReferrals] = useState<UserListItem[]>([]);
|
||||||
|
const [referralsLoading, setReferralsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Panel info & node usage
|
||||||
|
const [panelInfo, setPanelInfo] = useState<UserPanelInfo | null>(null);
|
||||||
|
const [panelInfoLoading, setPanelInfoLoading] = useState(false);
|
||||||
|
const [nodeUsage, setNodeUsage] = useState<UserNodeUsageResponse | null>(null);
|
||||||
|
const [nodeUsageDays, setNodeUsageDays] = useState(7);
|
||||||
|
|
||||||
// Inline confirm state
|
// Inline confirm state
|
||||||
const [confirmingAction, setConfirmingAction] = useState<string | null>(null);
|
const [confirmingAction, setConfirmingAction] = useState<string | null>(null);
|
||||||
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | 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 () => {
|
const handleTicketReply = async () => {
|
||||||
if (!selectedTicketId || !replyText.trim()) return;
|
if (!selectedTicketId || !replyText.trim()) return;
|
||||||
setReplySending(true);
|
setReplySending(true);
|
||||||
@@ -234,10 +286,20 @@ export default function AdminUserDetail() {
|
|||||||
}, [userId, loadUser, navigate]);
|
}, [userId, loadUser, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeTab === 'info') loadReferrals();
|
||||||
if (activeTab === 'sync') loadSyncStatus();
|
if (activeTab === 'sync') loadSyncStatus();
|
||||||
if (activeTab === 'subscription') loadTariffs();
|
if (activeTab === 'subscription') {
|
||||||
|
loadTariffs();
|
||||||
|
loadPanelInfo();
|
||||||
|
}
|
||||||
if (activeTab === 'tickets') loadTickets();
|
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) => {
|
const handleUpdateBalance = async (isAdd: boolean) => {
|
||||||
if (balanceAmount === '' || !userId) return;
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] items-center justify-center">
|
<div className="flex min-h-[50vh] items-center justify-center">
|
||||||
@@ -577,6 +656,14 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Campaign */}
|
||||||
|
{user.campaign_name && (
|
||||||
|
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-3">
|
||||||
|
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.campaign')}</div>
|
||||||
|
<div className="text-sm font-medium text-accent-400">{user.campaign_name}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Referral */}
|
{/* Referral */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||||
@@ -610,6 +697,49 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Referrals list */}
|
||||||
|
{user.referral.referrals_count > 0 && (
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
|
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.referralsList')}
|
||||||
|
</div>
|
||||||
|
{referralsLoading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : referrals.length === 0 ? (
|
||||||
|
<div className="py-2 text-center text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.noReferrals')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||||
|
{referrals.map((ref) => (
|
||||||
|
<button
|
||||||
|
key={ref.id}
|
||||||
|
onClick={() => navigate(`/admin/users/${ref.id}`)}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-dark-600 text-xs font-bold text-dark-300">
|
||||||
|
{ref.first_name?.[0] || ref.username?.[0] || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-dark-100">{ref.full_name}</div>
|
||||||
|
<div className="text-xs text-dark-500">
|
||||||
|
{formatDate(ref.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-400">
|
||||||
|
{formatWithCurrency(ref.total_spent_kopeks / 100)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Restrictions */}
|
{/* Restrictions */}
|
||||||
{(user.restriction_topup || user.restriction_subscription) && (
|
{(user.restriction_topup || user.restriction_subscription) && (
|
||||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
|
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
|
||||||
@@ -854,6 +984,218 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Panel Info */}
|
||||||
|
{panelInfoLoading ? (
|
||||||
|
<div className="flex justify-center rounded-xl bg-dark-800/50 py-8">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : panelInfo && !panelInfo.found ? (
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center text-sm text-dark-400">
|
||||||
|
{t('admin.users.detail.panelNotFound')}
|
||||||
|
</div>
|
||||||
|
) : panelInfo && panelInfo.found ? (
|
||||||
|
<>
|
||||||
|
{/* Links */}
|
||||||
|
{(panelInfo.subscription_url || panelInfo.happ_link) && (
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.subscriptionUrl')} / {t('admin.users.detail.happLink')}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{panelInfo.subscription_url && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(panelInfo.subscription_url!)}
|
||||||
|
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.subscriptionUrl')}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-xs text-dark-200">
|
||||||
|
{panelInfo.subscription_url}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{panelInfo.happ_link && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(panelInfo.happ_link!)}
|
||||||
|
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.happLink')}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-xs text-dark-200">
|
||||||
|
{panelInfo.happ_link}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config */}
|
||||||
|
{(panelInfo.trojan_password || panelInfo.vless_uuid || panelInfo.ss_password) && (
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.panelConfig')}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{panelInfo.trojan_password && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(panelInfo.trojan_password!)}
|
||||||
|
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.trojanPassword')}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-xs text-dark-200">
|
||||||
|
{panelInfo.trojan_password}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{panelInfo.vless_uuid && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(panelInfo.vless_uuid!)}
|
||||||
|
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.vlessUuid')}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-xs text-dark-200">
|
||||||
|
{panelInfo.vless_uuid}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{panelInfo.ss_password && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(panelInfo.ss_password!)}
|
||||||
|
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.ssPassword')}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-xs text-dark-200">
|
||||||
|
{panelInfo.ss_password}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connection info */}
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.firstConnected')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-100">
|
||||||
|
{formatDate(panelInfo.first_connected_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.lastOnline')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-100">{formatDate(panelInfo.online_at)}</div>
|
||||||
|
</div>
|
||||||
|
{panelInfo.last_connected_node_name && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.lastNode')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-100">
|
||||||
|
{panelInfo.last_connected_node_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live traffic */}
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.liveTraffic')}
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="mb-1 flex justify-between text-xs">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{formatBytes(panelInfo.used_traffic_bytes)}
|
||||||
|
</span>
|
||||||
|
<span className="text-dark-500">
|
||||||
|
{panelInfo.traffic_limit_bytes > 0
|
||||||
|
? formatBytes(panelInfo.traffic_limit_bytes)
|
||||||
|
: '∞'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent-500 transition-all"
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
panelInfo.traffic_limit_bytes > 0
|
||||||
|
? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%`
|
||||||
|
: '0%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.lifetime')}:{' '}
|
||||||
|
{formatBytes(panelInfo.lifetime_used_traffic_bytes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node usage */}
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.nodeUsage')}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[7, 14, 30].map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setNodeUsageDays(d)}
|
||||||
|
className={`rounded-lg px-2 py-1 text-xs transition-colors ${
|
||||||
|
nodeUsageDays === d
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'text-dark-500 hover:text-dark-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nodeUsage && nodeUsage.items.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{nodeUsage.items.map((item) => {
|
||||||
|
const maxBytes = nodeUsage.items[0].total_bytes;
|
||||||
|
const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={item.node_uuid}>
|
||||||
|
<div className="mb-1 flex justify-between text-xs">
|
||||||
|
<span className="text-dark-300">{item.node_name}</span>
|
||||||
|
<span className="text-dark-400">{formatBytes(item.total_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent-500/60"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-2 text-center text-xs text-dark-500">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user