Files
cunkebao_v3/nkebao/src/pages/devices/Devices.tsx
2025-07-05 21:09:28 +08:00

724 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { smartGoBack } from '@/utils/navigation';
import { ChevronLeft, Plus, Search, RefreshCw, QrCode, Smartphone, Loader2, AlertTriangle, Trash2, X } from 'lucide-react';
import { devicesApi } from '@/api';
import { useToast } from '@/components/ui/toast';
// 设备接口
interface Device {
id: number;
imei: string;
memo: string;
wechatId: string;
totalFriend: number;
alive: number;
status: "online" | "offline";
}
export default function Devices() {
const navigate = useNavigate();
const { toast } = useToast();
const [devices, setDevices] = useState<Device[]>([]);
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false);
const [stats, setStats] = useState({
totalDevices: 0,
onlineDevices: 0,
});
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [currentPage, setCurrentPage] = useState(1);
const [selectedDeviceId, setSelectedDeviceId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const observerTarget = useRef<HTMLDivElement>(null);
const pageRef = useRef(1);
const [deviceImei, setDeviceImei] = useState("");
const [deviceName, setDeviceName] = useState("");
const [qrCodeImage, setQrCodeImage] = useState("");
const [isLoadingQRCode, setIsLoadingQRCode] = useState(false);
const [isSubmittingImei, setIsSubmittingImei] = useState(false);
const [activeTab, setActiveTab] = useState("scan");
const [pollingStatus, setPollingStatus] = useState<{
isPolling: boolean;
message: string;
messageType: 'default' | 'success' | 'error';
showAnimation: boolean;
}>({
isPolling: false,
message: '',
messageType: 'default',
showAnimation: false
});
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
const devicesPerPage = 20;
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deviceToDelete, setDeviceToDelete] = useState<number | null>(null);
const loadDevices = useCallback(async (page: number, refresh: boolean = false) => {
if (isLoading) return;
try {
setIsLoading(true);
const response = await devicesApi.getList(page, devicesPerPage, searchQuery);
if (response.code === 200 && response.data) {
const serverDevices = response.data.list.map((device: any) => ({
...device,
status: device.alive === 1 ? "online" as const : "offline" as const
}));
if (refresh) {
setDevices(serverDevices);
} else {
setDevices(prev => [...prev, ...serverDevices]);
}
const total = response.data.total;
const online = response.data.list.filter((d: any) => d.alive === 1).length;
setStats({
totalDevices: total,
onlineDevices: online
});
setTotalCount(response.data.total);
const hasMoreData = serverDevices.length > 0 &&
serverDevices.length === devicesPerPage &&
(page * devicesPerPage) < response.data.total;
setHasMore(hasMoreData);
pageRef.current = page;
} else {
toast({
title: "获取设备列表失败",
description: response.message || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("获取设备列表失败", error);
toast({
title: "获取设备列表失败",
description: "请检查网络连接后重试",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [searchQuery, isLoading, toast]);
const loadNextPage = useCallback(() => {
if (isLoading || !hasMore) return;
const nextPage = pageRef.current + 1;
setCurrentPage(nextPage);
loadDevices(nextPage, false);
}, [hasMore, isLoading, loadDevices]);
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
if (!isMounted.current) return;
setCurrentPage(1);
pageRef.current = 1;
loadDevices(1, true);
}, [searchQuery, loadDevices]);
useEffect(() => {
if (!hasMore || isLoading) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted.current) {
loadNextPage();
}
},
{ threshold: 0.5 }
);
if (typeof window !== 'undefined' && observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => {
observer.disconnect();
};
}, [hasMore, isLoading, loadNextPage]);
const fetchDeviceQRCode = async () => {
try {
setIsLoadingQRCode(true);
setQrCodeImage("");
const accountId = localStorage.getItem('s2_accountId');
if (!accountId) {
toast({
title: "获取二维码失败",
description: "未获取到用户信息,请重新登录",
variant: "destructive",
});
return;
}
const response = await devicesApi.getQRCode(accountId);
if (response.code === 200 && response.data) {
setQrCodeImage(response.data.qrCode);
// 开始轮询检测设备添加结果
setTimeout(() => {
startPolling();
}, 5000);
} else {
toast({
title: "获取二维码失败",
description: response.message || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("获取二维码失败:", error);
toast({
title: "获取二维码失败",
description: "请稍后重试",
variant: "destructive",
});
} finally {
setIsLoadingQRCode(false);
}
};
const startPolling = () => {
setPollingStatus({
isPolling: true,
message: "正在检测添加结果...",
messageType: 'default',
showAnimation: true
});
const poll = async () => {
try {
const response = await devicesApi.getList(1, 1);
if (response.code === 200 && response.data) {
const currentCount = response.data.total;
if (currentCount > totalCount) {
setPollingStatus({
isPolling: false,
message: "设备添加成功!",
messageType: 'success',
showAnimation: false
});
setIsAddDeviceOpen(false);
loadDevices(1, true);
if (pollingTimerRef.current) {
clearTimeout(pollingTimerRef.current);
}
return;
}
}
} catch (error) {
console.error("轮询检测失败:", error);
}
// 继续轮询
pollingTimerRef.current = setTimeout(poll, 2000);
};
poll();
};
const handleOpenAddDeviceModal = () => {
setIsAddDeviceOpen(true);
setActiveTab("scan");
setQrCodeImage("");
setDeviceImei("");
setDeviceName("");
setPollingStatus({
isPolling: false,
message: '',
messageType: 'default',
showAnimation: false
});
// 自动获取二维码
setTimeout(() => {
fetchDeviceQRCode();
}, 100);
};
const handleCloseAddDeviceModal = () => {
setIsAddDeviceOpen(false);
if (pollingTimerRef.current) {
clearTimeout(pollingTimerRef.current);
}
};
const handleAddDeviceByImei = async () => {
if (!deviceImei.trim() || !deviceName.trim()) {
toast({
title: "请填写完整信息",
description: "设备名称和IMEI不能为空",
variant: "destructive",
});
return;
}
try {
setIsSubmittingImei(true);
const response = await devicesApi.addByImei(deviceImei, deviceName);
if (response.code === 200) {
toast({
title: "添加成功",
description: "设备已成功添加",
});
setIsAddDeviceOpen(false);
loadDevices(1, true);
} else {
toast({
title: "添加失败",
description: response.message || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error('添加设备失败:', error);
toast({
title: '添加设备失败,请稍后重试',
variant: "destructive",
});
} finally {
setIsSubmittingImei(false);
}
};
const handleRefresh = () => {
setCurrentPage(1);
pageRef.current = 1;
loadDevices(1, true);
};
const handleDeleteClick = () => {
if (!selectedDeviceId) {
toast({
title: "请选择要删除的设备",
variant: "destructive",
});
return;
}
setDeviceToDelete(selectedDeviceId);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!deviceToDelete) return;
try {
const response = await devicesApi.delete(deviceToDelete);
if (response.code === 200) {
toast({
title: "删除成功",
description: "设备已成功删除",
});
setSelectedDeviceId(null);
loadDevices(1, true);
} else {
toast({
title: "删除失败",
description: response.message || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error('删除设备失败:', error);
toast({
title: '删除设备失败,请稍后重试',
variant: "destructive",
});
} finally {
setIsDeleteDialogOpen(false);
setDeviceToDelete(null);
}
};
const handleCancelDelete = () => {
setIsDeleteDialogOpen(false);
setDeviceToDelete(null);
};
const handleDeviceClick = (deviceId: number, event: React.MouseEvent) => {
event.stopPropagation();
navigate(`/devices/${deviceId}`);
};
const handleAddDevice = async () => {
if (activeTab === "manual") {
await handleAddDeviceByImei();
}
};
// 过滤设备列表
const filteredDevices = devices.filter(device => {
if (statusFilter === "online") return device.status === "online";
if (statusFilter === "offline") return device.status === "offline";
return true;
});
return (
<div className="min-h-screen bg-gray-50">
{/* 固定header */}
<header className="fixed top-0 left-0 right-0 z-20 bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
onClick={() => smartGoBack(navigate)}
>
<ChevronLeft className="h-5 w-5" />
</button>
<h1 className="text-lg font-semibold"></h1>
</div>
<button
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
onClick={handleOpenAddDeviceModal}
>
<Plus className="h-4 w-4" />
</button>
</div>
</header>
{/* 内容区域 */}
<div className="pt-16 pb-20">
<div className="p-4 space-y-4">
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-bold text-blue-600">{stats.totalDevices}</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="text-sm text-gray-500 mb-1">线</div>
<div className="text-2xl font-bold text-green-600">{stats.onlineDevices}</div>
</div>
</div>
{/* 搜索和过滤 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="搜索设备IMEI/备注"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<button
className="p-2.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
onClick={handleRefresh}
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<button
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
onClick={handleDeleteClick}
disabled={!selectedDeviceId}
>
</button>
</div>
</div>
{/* 设备列表 */}
<div className="space-y-3">
{filteredDevices.map((device) => (
<div
key={device.id}
className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition-all cursor-pointer"
onClick={(e) => handleDeviceClick(device.id, e)}
>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selectedDeviceId === device.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedDeviceId(device.id);
} else {
setSelectedDeviceId(null);
}
}}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-gray-900 truncate">{device.memo || "未命名设备"}</div>
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
device.status === "online"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-600"
}`}>
{device.status === "online" ? "在线" : "离线"}
</span>
</div>
<div className="space-y-1 text-sm text-gray-600">
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId || "未绑定或微信离线"}</div>
<div>: {device.totalFriend}</div>
</div>
</div>
</div>
</div>
))}
<div ref={observerTarget} className="py-4 flex items-center justify-center">
{isLoading && <div className="text-sm text-gray-500">...</div>}
{!hasMore && devices.length > 0 && <div className="text-sm text-gray-500"></div>}
{!hasMore && devices.length === 0 && <div className="text-sm text-gray-500"></div>}
</div>
</div>
</div>
</div>
{/* 添加设备弹窗 */}
{isAddDeviceOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<button
onClick={handleCloseAddDeviceModal}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="h-5 w-5 text-gray-400" />
</button>
</div>
<div className="space-y-4">
<div className="flex border-b border-gray-200">
<button
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
activeTab === "scan"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => {
setActiveTab("scan");
// 切换到扫码添加时自动获取二维码
setTimeout(() => {
fetchDeviceQRCode();
}, 100);
}}
>
</button>
<button
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
activeTab === "manual"
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
onClick={() => setActiveTab("manual")}
>
</button>
</div>
{activeTab === "scan" && (
<div className="py-3">
<div className="flex flex-col items-center space-y-4">
{/* 状态提示 */}
<div className="text-center">
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
<div className="space-y-1">
<span className="text-sm text-gray-700"></span>
<div className="flex justify-center space-x-1">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
</div>
) : (
<span className="text-sm text-gray-600">5</span>
)}
</div>
{/* 二维码区域 */}
<div className="bg-gray-50 p-3 rounded-xl w-full max-w-[220px] min-h-[220px] flex flex-col items-center justify-center">
{isLoadingQRCode ? (
<div className="flex flex-col items-center space-y-2">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-gray-500">...</p>
</div>
) : qrCodeImage ? (
<div id="qrcode-container" className="flex flex-col items-center space-y-2">
<div className="relative w-44 h-44 flex items-center justify-center">
<img
src={qrCodeImage}
alt="设备添加二维码"
className="w-full h-full object-contain"
onError={(e) => {
console.error("二维码图片加载失败");
e.currentTarget.style.display = 'none';
const container = document.getElementById('qrcode-container');
if (container) {
const errorEl = container.querySelector('.qrcode-error');
if (errorEl) {
errorEl.classList.remove('hidden');
}
}
}}
/>
<div className="qrcode-error hidden absolute inset-0 flex flex-col items-center justify-center text-center text-red-500 bg-white rounded-lg">
<AlertTriangle className="h-6 w-6 mb-1" />
<p className="text-xs"></p>
</div>
</div>
<p className="text-sm text-center text-gray-600">
使
</p>
</div>
) : (
<div className="text-center text-gray-500">
<QrCode className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
)}
</div>
{/* 操作按钮 */}
<button
type="button"
onClick={fetchDeviceQRCode}
disabled={isLoadingQRCode}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-xl disabled:bg-gray-300 transition-colors flex items-center justify-center gap-2"
>
{isLoadingQRCode ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="h-4 w-4" />
</>
)}
</button>
</div>
</div>
)}
{activeTab === "manual" && (
<div className="py-3 space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<input
type="text"
placeholder="请输入设备名称"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-500">
便
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">IMEI</label>
<input
type="text"
placeholder="请输入设备IMEI"
value={deviceImei}
onChange={(e) => setDeviceImei(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<p className="text-xs text-gray-500">
IMEI码
</p>
</div>
</div>
<div className="flex gap-3">
<button
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
onClick={() => setIsAddDeviceOpen(false)}
>
</button>
<button
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl disabled:bg-gray-300 transition-colors"
onClick={handleAddDevice}
disabled={!deviceImei.trim() || !deviceName.trim() || isSubmittingImei}
>
{isSubmittingImei ? "添加中..." : "添加"}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{isDeleteDialogOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-md w-full p-6">
<div className="text-center mb-6">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600">
</p>
</div>
<div className="flex gap-3">
<button
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
onClick={handleCancelDelete}
>
</button>
<button
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-colors"
onClick={handleConfirmDelete}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}