feat:设备列表构建完成

This commit is contained in:
许永平
2025-07-04 17:15:06 +08:00
parent d52f0224b6
commit 034a306077
2 changed files with 731 additions and 5 deletions

View File

@@ -60,7 +60,30 @@ export const deleteDevice = async (id: number): Promise<ApiResponse<any>> => {
};
// 设备管理API
export const deviceApi = {
export const devicesApi = {
// 获取设备列表
async getList(page: number = 1, limit: number = 20, keyword?: string): Promise<ServerDevicesResponse> {
const params = new URLSearchParams();
params.append('page', page.toString());
params.append('limit', limit.toString());
if (keyword) {
params.append('keyword', keyword);
}
return get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
},
// 获取设备二维码
async getQRCode(accountId: string): Promise<ApiResponse<{ qrCode: string }>> {
return post<ApiResponse<{ qrCode: string }>>('/v1/api/device/add', { accountId });
},
// 通过IMEI添加设备
async addByImei(imei: string, name: string): Promise<ApiResponse<any>> {
return post<ApiResponse<any>>('/v1/api/device/add-by-imei', { imei, name });
},
// 创建设备
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
return post<ApiResponse<Device>>(`${API_BASE}`, params);
@@ -97,11 +120,16 @@ export const deviceApi = {
return get<ApiResponse<PaginatedResponse<Device>>>(`${API_BASE}?${queryString}`);
},
// 删除设备
async delete(id: string): Promise<ApiResponse<void>> {
// 删除设备(旧版本)
async deleteById(id: string): Promise<ApiResponse<void>> {
return del<ApiResponse<void>>(`${API_BASE}/${id}`);
},
// 删除设备(新版本)
async delete(id: number): Promise<ApiResponse<any>> {
return del<ApiResponse<any>>(`/v1/devices/${id}`);
},
// 重启设备
async restart(id: string): Promise<ApiResponse<void>> {
return post<ApiResponse<void>>(`${API_BASE}/${id}/restart`);

View File

@@ -1,5 +1,703 @@
import React from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronLeft, Plus, Filter, Search, RefreshCw, QrCode, Smartphone, Loader2, AlertTriangle, Trash2 } 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() {
return <div></div>;
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
});
};
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="flex-1 flex flex-col bg-gray-50">
{/* 固定header */}
<header className="fixed top-0 left-0 right-0 z-20 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<button
className="p-2 hover:bg-gray-100 rounded-full"
onClick={() => navigate(-1)}
>
<ChevronLeft className="h-5 w-5" />
</button>
<h1 className="text-lg font-medium"></h1>
</div>
<button
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center"
onClick={handleOpenAddDeviceModal}
>
<Plus className="h-4 w-4 mr-2" />
</button>
</div>
</header>
{/* 可滚动的内容区域 */}
<div className="flex-1 overflow-y-auto pt-16 pb-24">
<div className="p-4 space-y-4">
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-white rounded-lg shadow">
<div className="text-sm text-gray-500"></div>
<div className="text-xl font-bold text-blue-600">{stats.totalDevices}</div>
</div>
<div className="p-3 bg-white rounded-lg shadow">
<div className="text-sm text-gray-500">线</div>
<div className="text-xl font-bold text-green-600">{stats.onlineDevices}</div>
</div>
</div>
{/* 搜索和过滤 */}
<div className="bg-white p-4 rounded-lg shadow space-y-3">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="搜索设备IMEI/备注"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50">
<Filter className="h-4 w-4" />
</button>
<button
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50"
onClick={handleRefresh}
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg bg-white"
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
</div>
<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"
onClick={handleDeleteClick}
disabled={!selectedDeviceId}
>
</button>
</div>
</div>
{/* 设备列表 */}
<div className="space-y-2">
{filteredDevices.map((device) => (
<div
key={device.id}
className="bg-white p-3 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer relative"
onClick={(e) => handleDeviceClick(device.id, e)}
>
<div className="flex items-center space-x-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"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="font-medium truncate">{device.memo || "未命名设备"}</div>
<span className={`ml-2 px-2 py-1 text-xs rounded-full ${
device.status === "online"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}>
{device.status === "online" ? "在线" : "离线"}
</span>
</div>
<div className="text-sm text-gray-500">
<span className="mr-1">IMEI: {device.imei}</span>
</div>
<div className="text-sm text-gray-500">: {device.wechatId || "未绑定或微信离线"}</div>
<div className="flex items-center justify-between mt-1 text-sm">
<span className="text-gray-500">: {device.totalFriend}</span>
</div>
</div>
</div>
</div>
))}
<div ref={observerTarget} className="h-10 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 bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"></h2>
<button
onClick={handleCloseAddDeviceModal}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="space-y-4">
<div className="flex space-x-2 border-b">
<button
className={`pb-2 px-4 ${activeTab === "scan" ? "border-b-2 border-blue-500 text-blue-500" : "text-gray-500"}`}
onClick={() => setActiveTab("scan")}
>
</button>
<button
className={`pb-2 px-4 ${activeTab === "manual" ? "border-b-2 border-blue-500 text-blue-500" : "text-gray-500"}`}
onClick={() => setActiveTab("manual")}
>
</button>
</div>
{activeTab === "scan" && (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center justify-center p-6 space-y-4 relative">
<div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 z-10">
<div className="flex flex-col items-center w-full py-2">
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
<>
<span className="text-sm text-gray-800"></span>
<div className="flex space-x-1 mt-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>
</>
) : (
<span className="text-sm text-gray-800">5</span>
)}
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow-md border border-gray-200 w-full max-w-[280px] min-h-[280px] flex flex-col items-center justify-center">
{isLoadingQRCode ? (
<div className="flex flex-col items-center justify-center space-y-3">
<Loader2 className="h-12 w-12 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-3">
<div className="relative w-64 h-64 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">
<AlertTriangle className="h-10 w-10 mb-2" />
<p></p>
</div>
</div>
<p className="text-sm text-center text-gray-600 mt-2">
使
</p>
</div>
) : (
<div className="text-center text-gray-500">
<QrCode className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p></p>
</div>
)}
</div>
<button
type="button"
onClick={fetchDeviceQRCode}
disabled={isLoadingQRCode}
className="w-48 mt-8 bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg disabled:bg-gray-300"
>
{isLoadingQRCode ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin inline" />
...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4 inline" />
</>
)}
</button>
</div>
</div>
)}
{activeTab === "manual" && (
<div className="space-y-4 py-4">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<input
type="text"
placeholder="请输入设备名称"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500">
便
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">IMEI</label>
<input
type="text"
placeholder="请输入设备IMEI"
value={deviceImei}
onChange={(e) => setDeviceImei(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500">
IMEI码
</p>
</div>
<div className="flex justify-end space-x-2">
<button
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
onClick={() => setIsAddDeviceOpen(false)}
>
</button>
<button
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-300"
onClick={handleAddDevice}
disabled={!deviceImei.trim() || !deviceName.trim() || isSubmittingImei}
>
{isSubmittingImei ? "添加中..." : "添加"}
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{isDeleteDialogOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 mb-6">
</p>
<div className="flex justify-end space-x-2">
<button
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
onClick={handleCancelDelete}
>
</button>
<button
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg"
onClick={handleConfirmDelete}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}