mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: add device management UI in admin user card
Show connected devices in subscription tab with ability to: - View device platform, model, HWID and connection date - Delete individual devices - Reset all devices at once
This commit is contained in:
@@ -563,4 +563,33 @@ export const adminUsersApi = {
|
|||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get user devices
|
||||||
|
getUserDevices: async (
|
||||||
|
userId: number,
|
||||||
|
): Promise<{
|
||||||
|
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
|
||||||
|
total: number;
|
||||||
|
device_limit: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/devices`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete single device
|
||||||
|
deleteUserDevice: async (
|
||||||
|
userId: number,
|
||||||
|
hwid: string,
|
||||||
|
): Promise<{ success: boolean; message: string; deleted_hwid: string | null }> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset all devices
|
||||||
|
resetUserDevices: async (
|
||||||
|
userId: number,
|
||||||
|
): Promise<{ success: boolean; message: string; deleted_count: number }> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1909,6 +1909,13 @@
|
|||||||
"nodeUsage": "Node usage",
|
"nodeUsage": "Node usage",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"panelNotFound": "User not found in panel",
|
"panelNotFound": "User not found in panel",
|
||||||
|
"devices": {
|
||||||
|
"title": "Devices",
|
||||||
|
"none": "No connected devices",
|
||||||
|
"resetAll": "Reset all",
|
||||||
|
"deleted": "Device removed",
|
||||||
|
"allDeleted": "All devices reset"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Referral program",
|
"title": "Referral program",
|
||||||
"referrals": "Referrals",
|
"referrals": "Referrals",
|
||||||
|
|||||||
@@ -1612,6 +1612,13 @@
|
|||||||
"nodeUsage": "مصرف نودها",
|
"nodeUsage": "مصرف نودها",
|
||||||
"copied": "کپی شد",
|
"copied": "کپی شد",
|
||||||
"panelNotFound": "کاربر در پنل یافت نشد",
|
"panelNotFound": "کاربر در پنل یافت نشد",
|
||||||
|
"devices": {
|
||||||
|
"title": "دستگاهها",
|
||||||
|
"none": "دستگاه متصلی وجود ندارد",
|
||||||
|
"resetAll": "بازنشانی همه",
|
||||||
|
"deleted": "دستگاه حذف شد",
|
||||||
|
"allDeleted": "همه دستگاهها بازنشانی شدند"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "برنامه ارجاع",
|
"title": "برنامه ارجاع",
|
||||||
"referrals": "ارجاعات",
|
"referrals": "ارجاعات",
|
||||||
|
|||||||
@@ -2435,6 +2435,13 @@
|
|||||||
"nodeUsage": "Расход по нодам",
|
"nodeUsage": "Расход по нодам",
|
||||||
"copied": "Скопировано",
|
"copied": "Скопировано",
|
||||||
"panelNotFound": "Пользователь не найден в панели",
|
"panelNotFound": "Пользователь не найден в панели",
|
||||||
|
"devices": {
|
||||||
|
"title": "Устройства",
|
||||||
|
"none": "Нет подключённых устройств",
|
||||||
|
"resetAll": "Сбросить все",
|
||||||
|
"deleted": "Устройство удалено",
|
||||||
|
"allDeleted": "Все устройства сброшены"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Реферальная программа",
|
"title": "Реферальная программа",
|
||||||
"referrals": "Рефералов",
|
"referrals": "Рефералов",
|
||||||
|
|||||||
@@ -1611,6 +1611,13 @@
|
|||||||
"nodeUsage": "节点用量",
|
"nodeUsage": "节点用量",
|
||||||
"copied": "已复制",
|
"copied": "已复制",
|
||||||
"panelNotFound": "面板中未找到用户",
|
"panelNotFound": "面板中未找到用户",
|
||||||
|
"devices": {
|
||||||
|
"title": "设备",
|
||||||
|
"none": "没有已连接的设备",
|
||||||
|
"resetAll": "重置全部",
|
||||||
|
"deleted": "设备已删除",
|
||||||
|
"allDeleted": "所有设备已重置"
|
||||||
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "推荐计划",
|
"title": "推荐计划",
|
||||||
"referrals": "推荐人数",
|
"referrals": "推荐人数",
|
||||||
|
|||||||
@@ -195,6 +195,14 @@ export default function AdminUserDetail() {
|
|||||||
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
|
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
|
||||||
const [offerSending, setOfferSending] = useState(false);
|
const [offerSending, setOfferSending] = useState(false);
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
const [devices, setDevices] = useState<
|
||||||
|
{ hwid: string; platform: string; device_model: string; created_at: string | null }[]
|
||||||
|
>([]);
|
||||||
|
const [devicesTotal, setDevicesTotal] = useState(0);
|
||||||
|
const [deviceLimit, setDeviceLimit] = useState(0);
|
||||||
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||||
|
|
||||||
const userId = id ? parseInt(id, 10) : null;
|
const userId = id ? parseInt(id, 10) : null;
|
||||||
|
|
||||||
const loadUser = useCallback(async () => {
|
const loadUser = useCallback(async () => {
|
||||||
@@ -293,9 +301,24 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
|
const loadDevices = useCallback(async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
setDevicesLoading(true);
|
||||||
|
const data = await adminUsersApi.getUserDevices(userId);
|
||||||
|
setDevices(data.devices);
|
||||||
|
setDevicesTotal(data.total);
|
||||||
|
setDeviceLimit(data.device_limit);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setDevicesLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
const loadSubscriptionData = useCallback(async () => {
|
const loadSubscriptionData = useCallback(async () => {
|
||||||
await Promise.all([loadPanelInfo(), loadNodeUsage()]);
|
await Promise.all([loadPanelInfo(), loadNodeUsage(), loadDevices()]);
|
||||||
}, [loadPanelInfo, loadNodeUsage]);
|
}, [loadPanelInfo, loadNodeUsage, loadDevices]);
|
||||||
|
|
||||||
const loadPromoGroups = useCallback(async () => {
|
const loadPromoGroups = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -493,6 +516,34 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteDevice = async (hwid: string) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.deleteUserDevice(userId, hwid);
|
||||||
|
notify.success(t('admin.users.detail.devices.deleted'));
|
||||||
|
await loadDevices();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetDevices = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.resetUserDevices(userId);
|
||||||
|
notify.success(t('admin.users.detail.devices.allDeleted'));
|
||||||
|
await loadDevices();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangePromoGroup = async (groupId: number | null) => {
|
const handleChangePromoGroup = async (groupId: number | null) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -1453,6 +1504,87 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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