mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: show VPN connection info and subscription request history on admin user detail
- Load panelInfo eagerly on page load (not just on subscription tab) - Add VPN connection card to info tab: last/first connection, online status indicator, last node - Rename "Last activity" to "Bot activity" to distinguish from VPN connection - Show cabinet_last_login field that was never rendered - Add collapsible subscription request history section in subscription tab with paginated table - Add subscription request history API types and method - Add i18n keys for all 4 locales (ru, en, zh, fa)
This commit is contained in:
@@ -158,6 +158,19 @@ export interface UserPanelInfo {
|
|||||||
last_connected_node_name: string | null;
|
last_connected_node_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionRequestRecord {
|
||||||
|
id: number;
|
||||||
|
userUuid: string;
|
||||||
|
requestAt: string;
|
||||||
|
requestIp: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionRequestHistory {
|
||||||
|
total: number;
|
||||||
|
records: SubscriptionRequestRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserNodeUsageItem {
|
export interface UserNodeUsageItem {
|
||||||
node_uuid: string;
|
node_uuid: string;
|
||||||
node_name: string;
|
node_name: string;
|
||||||
@@ -684,6 +697,22 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get subscription request history from RemnaWave panel
|
||||||
|
getSubscriptionRequestHistory: async (
|
||||||
|
userId: number,
|
||||||
|
subscriptionId?: number,
|
||||||
|
offset = 0,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<SubscriptionRequestHistory> => {
|
||||||
|
const params: Record<string, unknown> = { offset, limit };
|
||||||
|
if (subscriptionId != null) params.subscription_id = subscriptionId;
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cabinet/admin/users/${userId}/subscription-request-history`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get user devices
|
// Get user devices
|
||||||
getUserDevices: async (
|
getUserDevices: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -3075,6 +3075,19 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"registration": "Registration",
|
"registration": "Registration",
|
||||||
"lastActivity": "Last activity",
|
"lastActivity": "Last activity",
|
||||||
|
"botActivity": "Bot activity",
|
||||||
|
"cabinetLastLogin": "Last cabinet login",
|
||||||
|
"vpnConnection": "VPN connection",
|
||||||
|
"lastConnection": "Last connection",
|
||||||
|
"firstConnection": "First connection",
|
||||||
|
"online": "Online",
|
||||||
|
"requestHistory": "Subscription request history",
|
||||||
|
"requestHistoryTotal": "Total requests",
|
||||||
|
"requestAt": "Date",
|
||||||
|
"requestIp": "IP address",
|
||||||
|
"requestUserAgent": "Device",
|
||||||
|
"loadMore": "Show more",
|
||||||
|
"noRequests": "No requests",
|
||||||
"totalSpent": "Total spent",
|
"totalSpent": "Total spent",
|
||||||
"purchases": "Purchases",
|
"purchases": "Purchases",
|
||||||
"campaign": "Advertising campaign",
|
"campaign": "Advertising campaign",
|
||||||
|
|||||||
@@ -2579,6 +2579,19 @@
|
|||||||
"language": "زبان",
|
"language": "زبان",
|
||||||
"registration": "ثبتنام",
|
"registration": "ثبتنام",
|
||||||
"lastActivity": "آخرین فعالیت",
|
"lastActivity": "آخرین فعالیت",
|
||||||
|
"botActivity": "فعالیت در ربات",
|
||||||
|
"cabinetLastLogin": "آخرین ورود به کابینت",
|
||||||
|
"vpnConnection": "اتصال VPN",
|
||||||
|
"lastConnection": "آخرین اتصال",
|
||||||
|
"firstConnection": "اولین اتصال",
|
||||||
|
"online": "آنلاین",
|
||||||
|
"requestHistory": "تاریخچه درخواستهای اشتراک",
|
||||||
|
"requestHistoryTotal": "کل درخواستها",
|
||||||
|
"requestAt": "تاریخ",
|
||||||
|
"requestIp": "آدرس IP",
|
||||||
|
"requestUserAgent": "دستگاه",
|
||||||
|
"loadMore": "نمایش بیشتر",
|
||||||
|
"noRequests": "بدون درخواست",
|
||||||
"totalSpent": "کل هزینه",
|
"totalSpent": "کل هزینه",
|
||||||
"purchases": "خریدها",
|
"purchases": "خریدها",
|
||||||
"campaign": "کمپین تبلیغاتی",
|
"campaign": "کمپین تبلیغاتی",
|
||||||
|
|||||||
@@ -3482,6 +3482,19 @@
|
|||||||
"language": "Язык",
|
"language": "Язык",
|
||||||
"registration": "Регистрация",
|
"registration": "Регистрация",
|
||||||
"lastActivity": "Последняя активность",
|
"lastActivity": "Последняя активность",
|
||||||
|
"botActivity": "Активность в боте",
|
||||||
|
"cabinetLastLogin": "Последний вход в кабинет",
|
||||||
|
"vpnConnection": "VPN подключение",
|
||||||
|
"lastConnection": "Последнее подключение",
|
||||||
|
"firstConnection": "Первое подключение",
|
||||||
|
"online": "Онлайн",
|
||||||
|
"requestHistory": "История запросов подписки",
|
||||||
|
"requestHistoryTotal": "Всего запросов",
|
||||||
|
"requestAt": "Дата",
|
||||||
|
"requestIp": "IP адрес",
|
||||||
|
"requestUserAgent": "Устройство",
|
||||||
|
"loadMore": "Показать ещё",
|
||||||
|
"noRequests": "Нет запросов",
|
||||||
"totalSpent": "Всего потрачено",
|
"totalSpent": "Всего потрачено",
|
||||||
"purchases": "Покупок",
|
"purchases": "Покупок",
|
||||||
"campaign": "Рекламная кампания",
|
"campaign": "Рекламная кампания",
|
||||||
|
|||||||
@@ -2578,6 +2578,19 @@
|
|||||||
"language": "语言",
|
"language": "语言",
|
||||||
"registration": "注册时间",
|
"registration": "注册时间",
|
||||||
"lastActivity": "最后活跃",
|
"lastActivity": "最后活跃",
|
||||||
|
"botActivity": "机器人活动",
|
||||||
|
"cabinetLastLogin": "最后登录面板",
|
||||||
|
"vpnConnection": "VPN 连接",
|
||||||
|
"lastConnection": "最后连接",
|
||||||
|
"firstConnection": "首次连接",
|
||||||
|
"online": "在线",
|
||||||
|
"requestHistory": "订阅请求历史",
|
||||||
|
"requestHistoryTotal": "总请求数",
|
||||||
|
"requestAt": "日期",
|
||||||
|
"requestIp": "IP 地址",
|
||||||
|
"requestUserAgent": "设备",
|
||||||
|
"loadMore": "显示更多",
|
||||||
|
"noRequests": "没有请求",
|
||||||
"totalSpent": "总消费",
|
"totalSpent": "总消费",
|
||||||
"purchases": "购买次数",
|
"purchases": "购买次数",
|
||||||
"campaign": "广告活动",
|
"campaign": "广告活动",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type PanelSyncStatusResponse,
|
type PanelSyncStatusResponse,
|
||||||
type UpdateSubscriptionRequest,
|
type UpdateSubscriptionRequest,
|
||||||
type AdminUserGiftsResponse,
|
type AdminUserGiftsResponse,
|
||||||
|
type SubscriptionRequestRecord,
|
||||||
} from '../api/adminUsers';
|
} from '../api/adminUsers';
|
||||||
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin';
|
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin';
|
||||||
import { promocodesApi, type PromoGroup } from '../api/promocodes';
|
import { promocodesApi, type PromoGroup } from '../api/promocodes';
|
||||||
@@ -383,6 +384,13 @@ export default function AdminUserDetail() {
|
|||||||
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(null);
|
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(null);
|
||||||
const [giftsLoading, setGiftsLoading] = useState(false);
|
const [giftsLoading, setGiftsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Subscription request history
|
||||||
|
const [requestHistory, setRequestHistory] = useState<SubscriptionRequestRecord[]>([]);
|
||||||
|
const [requestHistoryLoading, setRequestHistoryLoading] = useState(false);
|
||||||
|
const [requestHistoryOffset, setRequestHistoryOffset] = useState(0);
|
||||||
|
const [requestHistoryTotal, setRequestHistoryTotal] = useState(0);
|
||||||
|
const [requestHistoryExpanded, setRequestHistoryExpanded] = useState(false);
|
||||||
|
|
||||||
const userId = id ? parseInt(id, 10) : null;
|
const userId = id ? parseInt(id, 10) : null;
|
||||||
|
|
||||||
const loadUser = useCallback(async () => {
|
const loadUser = useCallback(async () => {
|
||||||
@@ -483,6 +491,29 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
}, [userId, activeSubscriptionId]);
|
}, [userId, activeSubscriptionId]);
|
||||||
|
|
||||||
|
const loadRequestHistory = useCallback(
|
||||||
|
async (offset = 0, append = false) => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
setRequestHistoryLoading(true);
|
||||||
|
const data = await adminUsersApi.getSubscriptionRequestHistory(
|
||||||
|
userId,
|
||||||
|
activeSubscriptionId ?? undefined,
|
||||||
|
offset,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
setRequestHistory((prev) => (append ? [...prev, ...data.records] : data.records));
|
||||||
|
setRequestHistoryTotal(data.total);
|
||||||
|
setRequestHistoryOffset(offset + data.records.length);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setRequestHistoryLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[userId, activeSubscriptionId],
|
||||||
|
);
|
||||||
|
|
||||||
const loadNodeUsage = useCallback(async () => {
|
const loadNodeUsage = useCallback(async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
@@ -575,7 +606,8 @@ export default function AdminUserDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadUser();
|
loadUser();
|
||||||
}, [userId, loadUser, navigate]);
|
loadPanelInfo();
|
||||||
|
}, [userId, loadUser, loadPanelInfo, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'info') {
|
if (activeTab === 'info') {
|
||||||
@@ -1301,10 +1333,16 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
<div className="mb-1 text-xs text-dark-500">
|
<div className="mb-1 text-xs text-dark-500">
|
||||||
{t('admin.users.detail.lastActivity')}
|
{t('admin.users.detail.botActivity')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
|
<div className="mb-1 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.cabinetLastLogin')}
|
||||||
|
</div>
|
||||||
|
<div className="text-dark-100">{formatDate(user.cabinet_last_login)}</div>
|
||||||
|
</div>
|
||||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
<div className="mb-1 text-xs text-dark-500">
|
<div className="mb-1 text-xs text-dark-500">
|
||||||
{t('admin.users.detail.totalSpent')}
|
{t('admin.users.detail.totalSpent')}
|
||||||
@@ -1321,6 +1359,87 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* VPN Connection Info */}
|
||||||
|
{panelInfo && panelInfo.found && (
|
||||||
|
<div className="rounded-xl border border-dark-700/30 bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.vpnConnection')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-dark-700/30 p-3">
|
||||||
|
<div className="mb-1 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.lastConnection')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{panelInfo.online_at &&
|
||||||
|
(() => {
|
||||||
|
const onlineDate = new Date(panelInfo.online_at);
|
||||||
|
const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 shrink-0 rounded-full ${isRecent ? 'bg-success-400 shadow-[0_0_6px_rgba(74,222,128,0.5)]' : 'bg-dark-500'}`}
|
||||||
|
title={isRecent ? t('admin.users.detail.online') : ''}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${isRecent ? 'font-medium text-success-400' : 'text-dark-100'}`}
|
||||||
|
>
|
||||||
|
{isRecent
|
||||||
|
? t('admin.users.detail.online')
|
||||||
|
: formatDate(panelInfo.online_at)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{!panelInfo.online_at && <span className="text-sm text-dark-100">-</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-dark-700/30 p-3">
|
||||||
|
<div className="mb-1 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.firstConnection')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-100">
|
||||||
|
{panelInfo.first_connected_at
|
||||||
|
? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{panelInfo.last_connected_node_name && (
|
||||||
|
<div className="col-span-2 rounded-lg bg-dark-700/30 p-3">
|
||||||
|
<div className="mb-1 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.lastNode')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-dark-100">
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 shrink-0 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{panelInfo.last_connected_node_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{panelInfoLoading && !panelInfo && (
|
||||||
|
<div className="flex justify-center rounded-xl bg-dark-800/50 py-4">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Campaign */}
|
{/* Campaign */}
|
||||||
{user.campaign_name && (
|
{user.campaign_name && (
|
||||||
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-3">
|
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-3">
|
||||||
@@ -2348,6 +2467,146 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subscription Request History */}
|
||||||
|
<div className="rounded-xl bg-dark-800/50">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = !requestHistoryExpanded;
|
||||||
|
setRequestHistoryExpanded(next);
|
||||||
|
if (next && requestHistory.length === 0) {
|
||||||
|
loadRequestHistory(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center justify-between p-4 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.requestHistory')}
|
||||||
|
</span>
|
||||||
|
{requestHistoryTotal > 0 && (
|
||||||
|
<span className="rounded-full bg-accent-500/15 px-2 py-0.5 text-xs font-medium text-accent-400">
|
||||||
|
{requestHistoryTotal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 text-dark-500 transition-transform ${requestHistoryExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{requestHistoryExpanded && (
|
||||||
|
<div className="border-t border-dark-700/50 px-4 pb-4 pt-3">
|
||||||
|
{/* Subscription selector for multi-tariff */}
|
||||||
|
{userSubscriptions.length > 1 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<select
|
||||||
|
value={activeSubscriptionId || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const subId = Number(e.target.value) || null;
|
||||||
|
setActiveSubscriptionId(subId);
|
||||||
|
setRequestHistory([]);
|
||||||
|
setRequestHistoryOffset(0);
|
||||||
|
setRequestHistoryTotal(0);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100"
|
||||||
|
>
|
||||||
|
{userSubscriptions.map((sub) => (
|
||||||
|
<option key={sub.id} value={sub.id}>
|
||||||
|
{sub.tariff_name || `#${sub.id}`} — {sub.status}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{requestHistoryLoading && requestHistory.length === 0 ? (
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : requestHistory.length === 0 && !requestHistoryLoading ? (
|
||||||
|
<div className="py-6 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.users.detail.noRequests')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.requestHistoryTotal')}: {requestHistoryTotal}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="-mx-4 overflow-x-auto px-4">
|
||||||
|
<table className="w-full min-w-[480px] text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-dark-700/50 text-xs text-dark-500">
|
||||||
|
<th className="pb-2 pr-3 font-medium">
|
||||||
|
{t('admin.users.detail.requestAt')}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-3 font-medium">
|
||||||
|
{t('admin.users.detail.requestIp')}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 font-medium">
|
||||||
|
{t('admin.users.detail.requestUserAgent')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requestHistory.map((record, idx) => (
|
||||||
|
<tr
|
||||||
|
key={record.id}
|
||||||
|
className={`border-b border-dark-700/30 ${idx % 2 === 0 ? 'bg-dark-800/30' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="whitespace-nowrap py-2.5 pr-3 text-dark-200">
|
||||||
|
{formatDate(record.requestAt)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap py-2.5 pr-3 font-mono text-xs text-dark-300">
|
||||||
|
{record.requestIp || '\u2014'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="max-w-[200px] truncate py-2.5 text-xs text-dark-400"
|
||||||
|
title={record.userAgent || ''}
|
||||||
|
>
|
||||||
|
{record.userAgent
|
||||||
|
? record.userAgent.length > 60
|
||||||
|
? `${record.userAgent.slice(0, 60)}...`
|
||||||
|
: record.userAgent
|
||||||
|
: '\u2014'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{requestHistory.length < requestHistoryTotal && (
|
||||||
|
<button
|
||||||
|
onClick={() => loadRequestHistory(requestHistoryOffset, true)}
|
||||||
|
disabled={requestHistoryLoading}
|
||||||
|
className="mt-3 flex min-h-[44px] w-full items-center justify-center rounded-lg bg-dark-700/50 py-2.5 text-sm text-dark-300 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{requestHistoryLoading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
t('admin.users.detail.loadMore')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user