修复设备列表无限请求数据的问题

This commit is contained in:
柳清爽
2025-03-31 12:04:59 +08:00
parent dfe73f9a22
commit c4698bd22a
78 changed files with 370 additions and 14224 deletions

View File

@@ -40,6 +40,7 @@ interface AuthContextType {
updateToken: (newToken: string) => void
}
// 创建默认上下文
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
token: null,
@@ -56,20 +57,25 @@ interface AuthProviderProps {
}
export function AuthProvider({ children }: AuthProviderProps) {
// 避免在服务端渲染时设置初始状态
const [token, setToken] = useState<string | null>(null)
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// 初始页面加载时显示为false避免在服务端渲染和客户端水合时不匹配
const [isLoading, setIsLoading] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const router = useRouter()
// 检查token有效性并初始化认证状态
// 初始化认证状态
useEffect(() => {
// 仅在客户端执行初始化
setIsLoading(true)
const initAuth = async () => {
setIsLoading(true)
const storedToken = safeLocalStorage.getItem("token")
if (storedToken) {
try {
try {
const storedToken = safeLocalStorage.getItem("token")
if (storedToken) {
// 验证token是否有效
const isValid = await validateToken()
@@ -89,17 +95,18 @@ export function AuthProvider({ children }: AuthProviderProps) {
// token无效清除
handleLogout()
}
} catch (error) {
console.error("验证token时出错:", error)
handleLogout()
}
} catch (error) {
console.error("验证token时出错:", error)
handleLogout()
} finally {
setIsLoading(false)
setIsInitialized(true)
}
setIsLoading(false)
}
initAuth()
}, [])
}, []) // 空依赖数组,仅在组件挂载时执行一次
const handleLogout = () => {
safeLocalStorage.removeItem("token")
@@ -131,7 +138,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
return (
<AuthContext.Provider value={{ isAuthenticated, token, user, login, logout, updateToken }}>
{isLoading ? <div className="flex h-screen w-screen items-center justify-center">...</div> : children}
{isLoading && isInitialized ? (
<div className="flex h-screen w-screen items-center justify-center">...</div>
) : (
children
)}
</AuthContext.Provider>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -11,21 +11,12 @@ 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"
import { fetchDeviceList, deleteDevice } from "@/api/devices"
import { ServerDevice } from "@/types/device"
interface Device {
id: string
imei: string
name: string
remark: string
status: "online" | "offline"
battery: number
wechatId: string
friendCount: number
todayAdded: number
messageCount: number
lastActive: string
addFriendStatus: "normal" | "abnormal"
avatar?: string
// 设备接口更新为与服务端接口对应的类型
interface Device extends ServerDevice {
status: "online" | "offline";
}
export default function DevicesPage() {
@@ -33,58 +24,158 @@ export default function DevicesPage() {
const [devices, setDevices] = useState<Device[]>([])
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
const [stats, setStats] = useState({
totalDevices: 42,
onlineDevices: 35,
totalDevices: 0,
onlineDevices: 0,
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
const devicesPerPage = 10
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)
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]);
// 初始加载和搜索时刷新列表
useEffect(() => {
// 模拟API调用
const fetchDevices = async () => {
const mockDevices = Array.from({ length: 42 }, (_, i) => ({
id: `device-${i + 1}`,
imei: `sd${123123 + i}`,
name: `设备 ${i + 1}`,
remark: `备注 ${i + 1}`,
status: Math.random() > 0.2 ? "online" : "offline",
battery: Math.floor(Math.random() * 100),
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
friendCount: Math.floor(Math.random() * 1000),
todayAdded: Math.floor(Math.random() * 50),
messageCount: Math.floor(Math.random() * 200),
lastActive: new Date(Date.now() - Math.random() * 86400000).toLocaleString(),
addFriendStatus: Math.random() > 0.2 ? "normal" : "abnormal",
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-kYhfQsrrByfbzefv6MEV7W7ogz0IRt.png",
}))
setDevices(mockDevices)
// 重置页码
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)
}
fetchDevices()
}, [])
// 清理观察器
return () => {
isMounted = false;
observer.disconnect();
}
}, [hasMore, isLoading, loadNextPage])
// 刷新设备列表
const handleRefresh = () => {
setCurrentPage(1)
pageRef.current = 1
loadDevices(1, true)
toast({
title: "刷新成功",
description: "设备列表已更新",
})
}
const filteredDevices = devices.filter((device) => {
const matchesSearch =
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.wechatId.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === "all" || device.status === statusFilter
return matchesSearch && matchesStatus
// 筛选设备
const filteredDevices = devices.filter(device => {
const matchesStatus = statusFilter === "all" ||
(statusFilter === "online" && device.alive === 1) ||
(statusFilter === "offline" && device.alive === 0)
return matchesStatus
})
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
const handleBatchDelete = () => {
// 处理批量删除
const handleBatchDelete = async () => {
if (selectedDevices.length === 0) {
toast({
title: "请选择设备",
@@ -93,14 +184,40 @@ export default function DevicesPage() {
})
return
}
toast({
title: "批量删除成功",
description: `已删除 ${selectedDevices.length} 个设备`,
})
setSelectedDevices([])
// 这里需要实现批量删除逻辑
// 目前只是单个删除的循环
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) {
toast({
title: "批量删除成功",
description: `已删除 ${successCount} 个设备`,
})
setSelectedDevices([])
handleRefresh()
} else {
toast({
title: "批量删除失败",
description: "请稍后重试",
variant: "destructive",
})
}
}
const handleDeviceClick = (deviceId: string) => {
// 设备详情页跳转
const handleDeviceClick = (deviceId: number) => {
router.push(`/devices/${deviceId}`)
}
@@ -167,10 +284,10 @@ export default function DevicesPage() {
</Select>
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedDevices.length === paginatedDevices.length}
checked={selectedDevices.length === filteredDevices.length && filteredDevices.length > 0}
onCheckedChange={(checked) => {
if (checked) {
setSelectedDevices(paginatedDevices.map((d) => d.id))
setSelectedDevices(filteredDevices.map((d) => d.id))
} else {
setSelectedDevices([])
}
@@ -190,7 +307,7 @@ export default function DevicesPage() {
</div>
<div className="space-y-2">
{paginatedDevices.map((device) => (
{filteredDevices.map((device) => (
<Card
key={device.id}
className="p-3 hover:shadow-md transition-shadow cursor-pointer relative"
@@ -210,70 +327,52 @@ export default function DevicesPage() {
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="font-medium truncate">{device.name}</div>
<Badge variant={device.status === "online" ? "success" : "secondary"} className="ml-2">
<div className="font-medium truncate">{device.memo}</div>
<Badge variant={device.status === "online" ? "default" : "secondary"} className="ml-2">
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
<div className="text-sm text-gray-500">: {device.wechatId}</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.friendCount}</span>
<span className="text-gray-500">: +{device.todayAdded}</span>
<span className="text-gray-500">: {device.totalFriend}</span>
</div>
</div>
</div>
</Card>
))}
</div>
<div className="flex justify-between items-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-500">
{currentPage} / {Math.ceil(filteredDevices.length / devicesPerPage)}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
}
disabled={currentPage === Math.ceil(filteredDevices.length / devicesPerPage)}
>
</Button>
{/* 加载更多观察点 */}
<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>
</Card>
</div>
{/* 添加设备对话框 */}
<Dialog open={isAddDeviceOpen} onOpenChange={setIsAddDeviceOpen}>
<DialogContent className="sm:max-w-[390px]">
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center p-6 space-y-6">
<div className="w-48 h-48 bg-gray-100 rounded-lg flex items-center justify-center">
<QrCode className="w-12 h-12 text-gray-400" />
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Input placeholder="请输入设备名称" />
</div>
<p className="text-sm text-gray-500 text-center">
使
<br />
ID
</p>
<Input placeholder="请输入设备ID" className="max-w-[280px]" />
<div className="flex space-x-2">
<div className="space-y-2">
<label className="text-sm font-medium">IMEI</label>
<Input placeholder="请输入设备IMEI" />
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setIsAddDeviceOpen(false)}>
</Button>
<Button></Button>
<Button></Button>
</div>
</div>
</DialogContent>

View File

@@ -9,7 +9,7 @@ import LayoutWrapper from "./components/LayoutWrapper"
export const metadata: Metadata = {
title: "存客宝",
description: "智能客户管理系统",
generator: 'v0.dev'
generator: 'v0.dev'
}
export default function RootLayout({
@@ -18,7 +18,7 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<html lang="zh-CN" suppressHydrationWarning>
<body className="bg-gray-100">
<AuthProvider>
<ErrorBoundary>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { ChevronRight, Settings, Bell, LogOut } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -8,6 +8,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useRouter } from "next/navigation"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useAuth } from "@/app/components/AuthProvider"
import ClientOnly from "@/components/ClientOnly"
import { getClientRandomId } from "@/lib/utils"
const menuItems = [
{ href: "/devices", label: "设备管理" },
@@ -20,7 +22,13 @@ export default function ProfilePage() {
const router = useRouter()
const { isAuthenticated, user, logout } = useAuth()
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
const [accountId] = useState(() => user?.account || Math.floor(10000000 + Math.random() * 90000000).toString())
// 处理身份验证状态将路由重定向逻辑移至useEffect
useEffect(() => {
if (!isAuthenticated) {
router.push("/login")
}
}, [isAuthenticated, router])
const handleLogout = () => {
logout() // 使用AuthProvider中的logout方法删除本地保存的用户信息
@@ -28,11 +36,6 @@ export default function ProfilePage() {
router.push("/login")
}
if (!isAuthenticated) {
router.push("/login")
return null
}
return (
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white pb-16">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
@@ -54,12 +57,16 @@ export default function ProfilePage() {
<Card className="p-6">
<div className="flex items-center space-x-4">
<Avatar className="w-20 h-20">
<AvatarImage src={user?.avatar || "https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=400&h=400&auto=format&fit=crop"} />
<AvatarFallback>{user?.username?.slice(0, 2) || "KR"}</AvatarFallback>
<AvatarImage src={user?.avatar || ""} />
<AvatarFallback>{user?.username ? user.username.slice(0, 2) : "用户"}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h2 className="text-xl font-semibold text-blue-600">{user?.username || "用户"}</h2>
<p className="text-gray-500">: {user?.account || accountId}</p>
<p className="text-gray-500">
: <ClientOnly fallback="加载中...">
{user?.account || Math.floor(10000000 + Math.random() * 90000000).toString()}
</ClientOnly>
</p>
<div className="mt-2">
<Button variant="outline" size="sm">
@@ -78,7 +85,6 @@ export default function ProfilePage() {
onClick={() => (item.href ? router.push(item.href) : null)}
>
<div className="flex items-center">
{item.icon && <span className="mr-2">{item.icon}</span>}
<span>{item.label}</span>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />