mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
fix: hide panel info/traffic/devices from subscription list level, show only in detail
This commit is contained in:
@@ -1818,312 +1818,327 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Panel Info */}
|
{/* Panel Info, Traffic, Devices — only inside subscription detail */}
|
||||||
{panelInfoLoading ? (
|
{(subscriptionDetailView || userSubscriptions.length <= 1) && (
|
||||||
<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 */}
|
{panelInfoLoading ? (
|
||||||
{(panelInfo.subscription_url || panelInfo.happ_link) && (
|
<div className="flex justify-center rounded-xl bg-dark-800/50 py-8">
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
<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>
|
</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">
|
||||||
{/* Config */}
|
{t('admin.users.detail.panelNotFound')}
|
||||||
{(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>
|
</div>
|
||||||
)}
|
) : panelInfo && panelInfo.found ? (
|
||||||
|
<>
|
||||||
{/* Connection info */}
|
{/* Links */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
{(panelInfo.subscription_url || panelInfo.happ_link) && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
<div>
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
<div className="text-xs text-dark-500">
|
{t('admin.users.detail.subscriptionUrl')} /{' '}
|
||||||
{t('admin.users.detail.firstConnected')}
|
{t('admin.users.detail.happLink')}
|
||||||
</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>
|
||||||
<div className="text-sm text-dark-100">
|
<div className="space-y-2">
|
||||||
{panelInfo.last_connected_node_name}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live traffic */}
|
{/* Config */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
{(panelInfo.trojan_password ||
|
||||||
<div className="mb-3 text-sm font-medium text-dark-200">
|
panelInfo.vless_uuid ||
|
||||||
{t('admin.users.detail.liveTraffic')}
|
panelInfo.ss_password) && (
|
||||||
</div>
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
<div className="mb-2">
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
<div className="mb-1 flex justify-between text-xs">
|
{t('admin.users.detail.panelConfig')}
|
||||||
<span className="text-dark-400">
|
</div>
|
||||||
{formatBytes(panelInfo.used_traffic_bytes)}
|
<div className="space-y-2">
|
||||||
</span>
|
{panelInfo.trojan_password && (
|
||||||
<span className="text-dark-500">
|
<button
|
||||||
{panelInfo.traffic_limit_bytes > 0
|
onClick={() => copyToClipboard(panelInfo.trojan_password!)}
|
||||||
? formatBytes(panelInfo.traffic_limit_bytes)
|
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
|
||||||
: '∞'}
|
>
|
||||||
</span>
|
<div className="mb-0.5 text-xs text-dark-500">
|
||||||
</div>
|
{t('admin.users.detail.trojanPassword')}
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-dark-700">
|
</div>
|
||||||
<div
|
<div className="truncate font-mono text-xs text-dark-200">
|
||||||
className="h-full rounded-full bg-accent-500 transition-all"
|
{panelInfo.trojan_password}
|
||||||
style={{
|
</div>
|
||||||
width:
|
</button>
|
||||||
panelInfo.traffic_limit_bytes > 0
|
)}
|
||||||
? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%`
|
{panelInfo.vless_uuid && (
|
||||||
: '0%',
|
<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>
|
>
|
||||||
</div>
|
<div className="mb-0.5 text-xs text-dark-500">
|
||||||
<div className="text-xs text-dark-500">
|
{t('admin.users.detail.vlessUuid')}
|
||||||
{t('admin.users.detail.lifetime')}:{' '}
|
</div>
|
||||||
{formatBytes(panelInfo.lifetime_used_traffic_bytes)}
|
<div className="truncate font-mono text-xs text-dark-200">
|
||||||
</div>
|
{panelInfo.vless_uuid}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Node usage */}
|
{/* 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 items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 3, 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>
|
||||||
|
<button
|
||||||
|
onClick={() => loadSubscriptionData()}
|
||||||
|
className="rounded-lg p-1 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
|
title={t('common.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nodeUsageForPeriod.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{nodeUsageForPeriod.map((item) => {
|
||||||
|
const maxBytes = nodeUsageForPeriod[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.country_code && (
|
||||||
|
<span className="mr-1">
|
||||||
|
{getCountryFlag(item.country_code)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{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}
|
||||||
|
|
||||||
|
{/* Devices */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-dark-200">
|
<span className="text-sm font-medium text-dark-200">
|
||||||
{t('admin.users.detail.nodeUsage')}
|
{t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex gap-1">
|
|
||||||
{[1, 3, 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>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => loadSubscriptionData()}
|
onClick={() => loadDevices()}
|
||||||
className="rounded-lg p-1 text-dark-500 transition-colors hover:text-dark-300"
|
className="rounded-lg p-1 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
title={t('common.refresh')}
|
title={t('common.refresh')}
|
||||||
>
|
>
|
||||||
<RefreshIcon className="h-3.5 w-3.5" />
|
<RefreshIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleInlineConfirm('resetDevices', handleResetDevices)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-2 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === 'resetDevices'
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === 'resetDevices'
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.detail.devices.resetAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{nodeUsageForPeriod.length > 0 ? (
|
{devicesLoading ? (
|
||||||
|
<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>
|
||||||
|
) : devices.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{nodeUsageForPeriod.map((item) => {
|
{devices.map((device) => (
|
||||||
const maxBytes = nodeUsageForPeriod[0].total_bytes;
|
<div
|
||||||
const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0;
|
key={device.hwid}
|
||||||
return (
|
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
|
||||||
<div key={item.node_uuid}>
|
>
|
||||||
<div className="mb-1 flex justify-between text-xs">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-dark-300">
|
<div className="truncate text-xs font-medium text-dark-200">
|
||||||
{item.country_code && (
|
{device.platform || device.device_model || device.hwid.slice(0, 12)}
|
||||||
<span className="mr-1">{getCountryFlag(item.country_code)}</span>
|
|
||||||
)}
|
|
||||||
{item.node_name}
|
|
||||||
</span>
|
|
||||||
<span className="text-dark-400">{formatBytes(item.total_bytes)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-dark-700">
|
<div className="flex items-center gap-2 text-[10px] text-dark-500">
|
||||||
<div
|
{device.device_model && device.platform && (
|
||||||
className="h-full rounded-full bg-accent-500/60"
|
<span>{device.device_model}</span>
|
||||||
style={{ width: `${pct}%` }}
|
)}
|
||||||
/>
|
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
|
||||||
|
{device.created_at && (
|
||||||
|
<span>
|
||||||
|
{new Date(device.created_at).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
<button
|
||||||
})}
|
onClick={() =>
|
||||||
|
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
|
||||||
|
handleDeleteDevice(device.hwid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === `deleteDevice_${device.hwid}`
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === `deleteDevice_${device.hwid}` ? '?' : '\u00D7'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-2 text-center text-xs text-dark-500">-</div>
|
<div className="py-2 text-center text-xs text-dark-500">
|
||||||
|
{t('admin.users.detail.devices.none')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{/* Devices */}
|
|
||||||
<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.devices.title')} ({devicesTotal}/{deviceLimit})
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => loadDevices()}
|
|
||||||
className="rounded-lg p-1 text-dark-500 transition-colors hover:text-dark-300"
|
|
||||||
title={t('common.refresh')}
|
|
||||||
>
|
|
||||||
<RefreshIcon className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleInlineConfirm('resetDevices', handleResetDevices)}
|
|
||||||
disabled={actionLoading}
|
|
||||||
className={`rounded-lg px-2 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
|
|
||||||
confirmingAction === 'resetDevices'
|
|
||||||
? 'bg-error-500 text-white'
|
|
||||||
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{confirmingAction === 'resetDevices'
|
|
||||||
? t('admin.users.detail.actions.areYouSure')
|
|
||||||
: t('admin.users.detail.devices.resetAll')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{devicesLoading ? (
|
|
||||||
<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>
|
|
||||||
) : devices.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{devices.map((device) => (
|
|
||||||
<div
|
|
||||||
key={device.hwid}
|
|
||||||
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-xs font-medium text-dark-200">
|
|
||||||
{device.platform || device.device_model || device.hwid.slice(0, 12)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] text-dark-500">
|
|
||||||
{device.device_model && device.platform && (
|
|
||||||
<span>{device.device_model}</span>
|
|
||||||
)}
|
|
||||||
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
|
|
||||||
{device.created_at && (
|
|
||||||
<span>{new Date(device.created_at).toLocaleDateString(locale)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
|
|
||||||
handleDeleteDevice(device.hwid),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={actionLoading}
|
|
||||||
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
|
||||||
confirmingAction === `deleteDevice_${device.hwid}`
|
|
||||||
? 'bg-error-500 text-white'
|
|
||||||
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{confirmingAction === `deleteDevice_${device.hwid}` ? '?' : '\u00D7'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-2 text-center text-xs text-dark-500">
|
|
||||||
{t('admin.users.detail.devices.none')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user