Files
cunkebao_v3/Cunkebao/app/devices/page.tsx

771 lines
28 KiB
TypeScript
Raw Normal View History

2025-03-29 16:50:39 +08:00
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
2025-03-29 16:50:39 +08:00
import { useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
2025-04-01 10:00:20 +08:00
import { ChevronLeft, Plus, Filter, Search, RefreshCw, QrCode, Smartphone, Loader2, AlertTriangle } from "lucide-react"
2025-03-29 16:50:39 +08:00
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "@/components/ui/use-toast"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
2025-04-01 10:00:20 +08:00
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { fetchDeviceList, deleteDevice } from "@/api/devices"
import { ServerDevice } from "@/types/device"
2025-04-01 10:00:20 +08:00
import { api } from "@/lib/api"
import { ImeiDisplay } from "@/components/ImeiDisplay"
2025-03-29 16:50:39 +08:00
// 设备接口更新为与服务端接口对应的类型
interface Device extends ServerDevice {
status: "online" | "offline";
2025-03-29 16:50:39 +08:00
}
export default function DevicesPage() {
const router = useRouter()
const [devices, setDevices] = useState<Device[]>([])
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
const [stats, setStats] = useState({
totalDevices: 0,
onlineDevices: 0,
2025-03-29 16:50:39 +08:00
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const [selectedDevices, setSelectedDevices] = useState<number[]>([])
const [isLoading, setIsLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [totalCount, setTotalCount] = useState(0)
const observerTarget = useRef<HTMLDivElement>(null)
// 使用ref来追踪当前页码避免依赖effect循环
const pageRef = useRef(1)
2025-04-01 10:00:20 +08:00
// 添加设备相关状态
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")
2025-03-29 16:50:39 +08:00
const devicesPerPage = 20 // 每页显示20条记录
// 获取设备列表
const loadDevices = useCallback(async (page: number, refresh: boolean = false) => {
// 检查是否已经在加载中,避免重复请求
if (isLoading) return;
try {
setIsLoading(true)
const response = await fetchDeviceList(page, devicesPerPage, searchQuery)
if (response.code === 200 && response.data) {
// 转换数据格式确保status类型正确
const serverDevices = response.data.list.map(device => ({
...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 => d.alive === 1).length
setStats({
totalDevices: total,
onlineDevices: online
})
// 更新分页信息
setTotalCount(response.data.total)
// 更新hasMore状态确保有更多数据且返回的数据数量等于每页数量
const hasMoreData = serverDevices.length > 0 &&
serverDevices.length === devicesPerPage &&
(page * devicesPerPage) < response.data.total;
setHasMore(hasMoreData)
// 更新当前页码的ref值
pageRef.current = page
} else {
toast({
title: "获取设备列表失败",
description: response.msg || "请稍后重试",
variant: "destructive",
})
}
} catch (error) {
console.error("获取设备列表失败", error)
toast({
title: "获取设备列表失败",
description: "请检查网络连接后重试",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
// 移除isLoading依赖只保留真正需要的依赖
}, [searchQuery, devicesPerPage])
// 加载下一页数据的函数使用ref来追踪页码避免依赖循环
const loadNextPage = useCallback(() => {
// 如果正在加载或者没有更多数据,直接返回
if (isLoading || !hasMore) return;
// 使用ref来获取下一页码避免依赖currentPage
const nextPage = pageRef.current + 1;
// 设置UI显示的当前页
setCurrentPage(nextPage);
// 加载下一页数据
loadDevices(nextPage, false);
// 只依赖必要的状态
}, [hasMore, isLoading, loadDevices]);
// 初始加载和搜索时刷新列表
2025-03-29 16:50:39 +08:00
useEffect(() => {
// 重置页码
setCurrentPage(1)
pageRef.current = 1
// 加载第一页数据
loadDevices(1, true)
}, [searchQuery, loadDevices])
// 无限滚动加载实现
useEffect(() => {
// 如果没有更多数据或者正在加载不创建observer
if (!hasMore || isLoading) return;
let isMounted = true; // 追踪组件是否已挂载
// 创建观察器观察加载点
const observer = new IntersectionObserver(
entries => {
// 如果交叉了,且有更多数据,且当前不在加载状态,且组件仍然挂载
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted) {
loadNextPage();
}
},
{ threshold: 0.5 }
)
// 只在客户端时观察节点
if (typeof window !== 'undefined' && observerTarget.current) {
observer.observe(observerTarget.current)
2025-03-29 16:50:39 +08:00
}
// 清理观察器
return () => {
isMounted = false;
observer.disconnect();
}
}, [hasMore, isLoading, loadNextPage])
2025-03-29 16:50:39 +08:00
2025-04-01 10:00:20 +08:00
// 获取设备二维码
const fetchDeviceQRCode = async () => {
try {
setIsLoadingQRCode(true)
setQrCodeImage("") // 清空当前二维码
console.log("正在请求二维码...");
// 发起请求获取二维码 - 直接使用fetch避免api工具添加基础URL
const response = await fetch('http://yi.54word.com/v1/api/device/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({})
})
console.log("二维码请求响应状态:", response.status);
// 保存原始响应文本以便调试
const responseText = await response.text();
console.log("原始响应内容:", responseText);
// 尝试将响应解析为JSON
let result;
try {
result = JSON.parse(responseText);
} catch (e) {
console.error("响应不是有效的JSON:", e);
toast({
title: "获取二维码失败",
description: "服务器返回的数据格式无效",
variant: "destructive",
});
return;
}
console.log("二维码响应数据:", result);
if (result && result.code === 200) {
// 尝试多种可能的返回数据结构
let qrcodeData = null;
if (result.data?.qrCode) {
qrcodeData = result.data.qrCode;
console.log("找到二维码数据在 result.data.qrCode");
} else if (result.data?.qrcode) {
qrcodeData = result.data.qrcode;
console.log("找到二维码数据在 result.data.qrcode");
} else if (result.data?.image) {
qrcodeData = result.data.image;
console.log("找到二维码数据在 result.data.image");
} else if (result.data?.url) {
// 如果返回的是URL而不是base64
qrcodeData = result.data.url;
console.log("找到二维码URL在 result.data.url");
setQrCodeImage(qrcodeData);
toast({
title: "二维码已更新",
description: "请使用手机扫描新的二维码添加设备",
});
return; // 直接返回不进行base64处理
} else if (typeof result.data === 'string') {
// 如果data直接是字符串
qrcodeData = result.data;
console.log("二维码数据直接在 result.data 字符串中");
} else {
console.error("无法找到二维码数据:", result);
toast({
title: "获取二维码失败",
description: "返回数据格式不正确",
variant: "destructive",
});
return;
}
// 检查数据是否为空
if (!qrcodeData) {
console.error("二维码数据为空");
toast({
title: "获取二维码失败",
description: "服务器返回的二维码数据为空",
variant: "destructive",
});
return;
}
console.log("处理前的二维码数据:", qrcodeData);
// 检查是否已经是完整的data URL
if (qrcodeData.startsWith('data:image')) {
console.log("数据已包含data:image前缀");
setQrCodeImage(qrcodeData);
}
// 检查是否是URL
else if (qrcodeData.startsWith('http')) {
console.log("数据是HTTP URL");
setQrCodeImage(qrcodeData);
}
// 尝试作为base64处理
else {
try {
// 确保base64字符串没有空格等干扰字符
const cleanedBase64 = qrcodeData.trim();
console.log("处理后的base64数据:", cleanedBase64.substring(0, 30) + "...");
// 直接以图片src格式设置
setQrCodeImage(`data:image/png;base64,${cleanedBase64}`);
// 预加载图片,确认是否有效
const img = new Image();
img.onload = () => {
console.log("二维码图片加载成功");
};
img.onerror = (e) => {
console.error("二维码图片加载失败:", e);
toast({
title: "二维码加载失败",
description: "服务器返回的数据无法显示为图片",
variant: "destructive",
});
};
img.src = `data:image/png;base64,${cleanedBase64}`;
} catch (e) {
console.error("处理base64数据出错:", e);
toast({
title: "获取二维码失败",
description: "图片数据处理失败",
variant: "destructive",
});
return;
}
}
toast({
title: "二维码已更新",
description: "请使用手机扫描新的二维码添加设备",
});
} else {
console.error("获取二维码失败:", result);
toast({
title: "获取二维码失败",
description: result?.msg || "请稍后重试",
variant: "destructive",
});
}
} catch (error) {
console.error("获取二维码失败", error);
toast({
title: "获取二维码失败",
description: "请检查网络连接后重试",
variant: "destructive",
});
} finally {
setIsLoadingQRCode(false);
}
}
// 打开添加设备模态框时获取二维码
const handleOpenAddDeviceModal = () => {
setIsAddDeviceOpen(true)
setDeviceImei("")
setDeviceName("")
fetchDeviceQRCode()
}
// 通过IMEI添加设备
const handleAddDeviceByImei = async () => {
if (!deviceImei) {
toast({
title: "IMEI不能为空",
description: "请输入有效的设备IMEI",
variant: "destructive",
});
return;
}
try {
setIsSubmittingImei(true);
console.log("正在添加设备IMEI:", deviceImei, "设备名称:", deviceName);
// 使用api.post发送请求到/v1/devices
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/devices`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
imei: deviceImei,
memo: deviceName
})
});
console.log("添加设备响应状态:", response.status);
// 保存原始响应文本以便调试
const responseText = await response.text();
console.log("原始响应内容:", responseText);
// 尝试将响应解析为JSON
let result;
try {
result = JSON.parse(responseText);
} catch (e) {
console.error("响应不是有效的JSON:", e);
toast({
title: "添加设备失败",
description: "服务器返回的数据格式无效",
variant: "destructive",
});
return;
}
console.log("添加设备响应:", result);
if (result && result.code === 200) {
toast({
title: "设备添加成功",
description: result.data?.msg || "设备已成功添加",
});
// 清空输入并关闭弹窗
setDeviceImei("");
setDeviceName("");
setIsAddDeviceOpen(false);
// 刷新设备列表
loadDevices(1, true);
} else {
console.error("添加设备失败:", result);
toast({
title: "添加设备失败",
description: result?.msg || "请检查设备信息是否正确",
variant: "destructive",
});
}
} catch (error) {
console.error("添加设备请求失败:", error);
toast({
title: "请求失败",
description: "网络错误,请稍后重试",
variant: "destructive",
});
} finally {
setIsSubmittingImei(false);
}
}
// 刷新设备列表
2025-03-29 16:50:39 +08:00
const handleRefresh = () => {
setCurrentPage(1)
pageRef.current = 1
loadDevices(1, true)
2025-03-29 16:50:39 +08:00
toast({
title: "刷新成功",
description: "设备列表已更新",
})
}
// 筛选设备
const filteredDevices = devices.filter(device => {
const matchesStatus = statusFilter === "all" ||
(statusFilter === "online" && device.alive === 1) ||
(statusFilter === "offline" && device.alive === 0)
return matchesStatus
2025-03-29 16:50:39 +08:00
})
// 处理批量删除
const handleBatchDelete = async () => {
2025-03-29 16:50:39 +08:00
if (selectedDevices.length === 0) {
toast({
title: "请选择设备",
description: "您需要选择至少一个设备来执行批量删除操作",
variant: "destructive",
})
return
}
// 这里需要实现批量删除逻辑
// 目前只是单个删除的循环
let successCount = 0
for (const deviceId of selectedDevices) {
try {
const response = await deleteDevice(deviceId)
if (response.code === 200) {
successCount++
}
} catch (error) {
console.error(`删除设备 ${deviceId} 失败`, error)
}
}
// 删除后刷新列表
if (successCount > 0) {
2025-04-08 16:01:59 +08:00
toast({
title: "批量删除成功",
description: `已删除 ${successCount} 个设备`,
2025-04-08 16:01:59 +08:00
})
setSelectedDevices([])
handleRefresh()
} else {
toast({
title: "批量删除失败",
description: "请稍后重试",
variant: "destructive",
})
}
2025-03-29 16:50:39 +08:00
}
// 设备详情页跳转
const handleDeviceClick = (deviceId: number, event: React.MouseEvent) => {
// 判断点击事件是否来自ImeiDisplay组件或其后代元素
// 如果点击事件已经被处理例如ImeiDisplay中已阻止传播则不执行跳转
if (event.defaultPrevented) {
return;
}
// 如果点击的元素或其父元素有imei-display类则不跳转
let target = event.target as HTMLElement;
while (target && target !== event.currentTarget) {
if (target.classList.contains('imei-display-area')) {
return;
}
target = target.parentElement as HTMLElement;
}
router.push(`/devices/${deviceId}`);
2025-03-29 16:50:39 +08:00
}
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-lg font-medium"></h1>
</div>
2025-04-01 10:00:20 +08:00
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleOpenAddDeviceModal}>
2025-03-29 16:50:39 +08:00
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<Card className="p-3">
<div className="text-sm text-gray-500"></div>
<div className="text-xl font-bold text-blue-600">{stats.totalDevices}</div>
</Card>
<Card className="p-3">
<div className="text-sm text-gray-500">线</div>
<div className="text-xl font-bold text-green-600">{stats.onlineDevices}</div>
</Card>
</div>
<Card className="p-4">
<div className="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
placeholder="搜索设备IMEI/备注"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" 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} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="online">线</SelectItem>
<SelectItem value="offline">线</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedDevices.length === filteredDevices.length && filteredDevices.length > 0}
2025-03-29 16:50:39 +08:00
onCheckedChange={(checked) => {
if (checked) {
setSelectedDevices(filteredDevices.map((d) => d.id))
2025-03-29 16:50:39 +08:00
} else {
setSelectedDevices([])
}
}}
/>
<span className="text-sm text-gray-500"></span>
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleBatchDelete}
disabled={selectedDevices.length === 0}
>
</Button>
</div>
<div className="space-y-2">
{filteredDevices.map((device) => (
2025-03-29 16:50:39 +08:00
<Card
key={device.id}
className="p-3 hover:shadow-md transition-shadow cursor-pointer relative"
onClick={(e) => handleDeviceClick(device.id, e)}
2025-03-29 16:50:39 +08:00
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedDevices.includes(device.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedDevices([...selectedDevices, device.id])
} else {
setSelectedDevices(selectedDevices.filter((id) => id !== device.id))
}
}}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="font-medium truncate">{device.memo}</div>
<Badge variant={device.status === "online" ? "default" : "secondary"} className="ml-2">
2025-03-29 16:50:39 +08:00
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="text-sm text-gray-500 flex items-center imei-display-area">
<span className="mr-1">IMEI:</span>
<ImeiDisplay imei={device.imei} containerWidth={180} />
</div>
<div className="text-sm text-gray-500">: {device.wechatId || "未绑定"}</div>
2025-03-29 16:50:39 +08:00
<div className="flex items-center justify-between mt-1 text-sm">
<span className="text-gray-500">: {device.totalFriend}</span>
2025-03-29 16:50:39 +08:00
</div>
</div>
</div>
</Card>
))}
{/* 加载更多观察点 */}
<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>}
2025-04-08 16:01:59 +08:00
</div>
2025-03-29 16:50:39 +08:00
</div>
</div>
</Card>
</div>
{/* 添加设备对话框 */}
2025-03-29 16:50:39 +08:00
<Dialog open={isAddDeviceOpen} onOpenChange={setIsAddDeviceOpen}>
<DialogContent>
2025-03-29 16:50:39 +08:00
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
2025-04-01 10:00:20 +08:00
<Tabs defaultValue="scan" value={activeTab} onValueChange={setActiveTab} className="mt-4">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="scan" className="flex items-center">
<QrCode className="h-4 w-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="manual" className="flex items-center">
<Smartphone className="h-4 w-4 mr-2" />
</TabsTrigger>
</TabsList>
<TabsContent value="scan" className="space-y-4 py-4">
<div className="flex flex-col items-center justify-center p-6 space-y-4">
<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-primary" />
<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"
>
{isLoadingQRCode ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</TabsContent>
<TabsContent value="manual" className="space-y-4 py-4">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Input
placeholder="请输入设备名称"
value={deviceName}
onChange={(e) => setDeviceName(e.target.value)}
/>
<p className="text-xs text-gray-500">
便
</p>
2025-04-08 16:01:59 +08:00
</div>
2025-04-01 10:00:20 +08:00
<div className="space-y-2">
<label className="text-sm font-medium">IMEI</label>
<Input
placeholder="请输入设备IMEI"
value={deviceImei}
onChange={(e) => setDeviceImei(e.target.value)}
/>
<p className="text-xs text-gray-500">
IMEI码
</p>
</div>
<div className="flex justify-end space-x-2">
2025-04-08 16:01:59 +08:00
<Button variant="outline" onClick={() => setIsAddDeviceOpen(false)}>
</Button>
2025-04-01 10:00:20 +08:00
<Button
onClick={handleAddDeviceByImei}
disabled={isSubmittingImei || !deviceImei.trim()}
>
{isSubmittingImei ? (
<>
<div className="w-4 h-4 mr-2 rounded-full border-2 border-white border-t-transparent animate-spin"></div>
...
</>
) : "添加"}
</Button>
2025-04-08 16:01:59 +08:00
</div>
</div>
2025-04-01 10:00:20 +08:00
</TabsContent>
</Tabs>
2025-03-29 16:50:39 +08:00
</DialogContent>
</Dialog>
</div>
)
}