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

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

@@ -1,3 +1,4 @@
import { api } from "@/lib/api";
import type {
ApiResponse,
Device,
@@ -7,11 +8,31 @@ import type {
QueryDeviceParams,
CreateDeviceParams,
UpdateDeviceParams,
DeviceStatus, // Added DeviceStatus import
DeviceStatus,
ServerDevice,
ServerDevicesResponse
} from "@/types/device"
const API_BASE = "/api/devices"
// 获取设备列表 - 连接到服务器/v1/devices接口
export const fetchDeviceList = async (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 api.get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
};
// 删除设备
export const deleteDevice = async (id: number): Promise<ApiResponse<any>> => {
return api.delete<ApiResponse<any>>(`/v1/devices/${id}`);
};
// 设备管理API
export const deviceApi = {
// 创建设备
@@ -46,12 +67,22 @@ export const deviceApi = {
// 查询设备列表
async query(params: QueryDeviceParams): Promise<ApiResponse<PaginatedResponse<Device>>> {
const queryString = new URLSearchParams({
...params,
tags: params.tags ? JSON.stringify(params.tags) : "",
dateRange: params.dateRange ? JSON.stringify(params.dateRange) : "",
}).toString()
// 创建一个新对象,用于构建URLSearchParams
const queryParams: Record<string, string> = {};
// 按需将params中的属性添加到queryParams
if (params.keyword) queryParams.keyword = params.keyword;
if (params.status) queryParams.status = params.status;
if (params.type) queryParams.type = params.type;
if (params.page) queryParams.page = params.page.toString();
if (params.pageSize) queryParams.pageSize = params.pageSize.toString();
// 特殊处理需要JSON序列化的属性
if (params.tags) queryParams.tags = JSON.stringify(params.tags);
if (params.dateRange) queryParams.dateRange = JSON.stringify(params.dateRange);
// 构建查询字符串
const queryString = new URLSearchParams(queryParams).toString();
const response = await fetch(`${API_BASE}?${queryString}`)
return response.json()
},

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" />

View File

@@ -0,0 +1,18 @@
"use client"
import { useState, useEffect, type ReactNode } from 'react'
/**
* ClientOnly组件
* 该组件专门用于包装那些只能在客户端渲染的内容,避免水合不匹配错误
* 例如使用了Math.random()、Date.now()或window对象的组件
*/
export default function ClientOnly({ children, fallback = null }: { children: ReactNode, fallback?: ReactNode }) {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
return isClient ? <>{children}</> : <>{fallback}</>
}

View File

@@ -97,6 +97,11 @@ export const loginApi = {
// 验证 Token 是否有效
export const validateToken = async (): Promise<boolean> => {
// 如果在服务端直接返回false避免在服务端发起不必要的请求
if (typeof window === 'undefined') {
return false;
}
try {
const response = await loginApi.getUserInfo();
return response.code === 200;
@@ -107,6 +112,11 @@ export const validateToken = async (): Promise<boolean> => {
// 刷新令牌
export const refreshAuthToken = async (): Promise<boolean> => {
// 如果在服务端直接返回false
if (typeof window === 'undefined') {
return false;
}
try {
const response = await loginApi.refreshToken();
if (response.code === 200 && response.data?.token) {

View File

@@ -7,8 +7,11 @@ export const handleTokenExpired = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
// 跳转到登录页
window.location.href = '/login';
// 使用客户端导航而不是直接修改window.location
// 避免在服务端渲染时执行
setTimeout(() => {
window.location.href = '/login';
}, 0);
}
};
@@ -16,7 +19,10 @@ export const handleTokenExpired = () => {
export const handleApiResponse = <T>(response: Response, result: any): T => {
// 处理token过期情况
if (result && (result.code === 401 || result.msg?.includes('token'))) {
handleTokenExpired();
// 仅在客户端处理token过期
if (typeof window !== 'undefined') {
handleTokenExpired();
}
throw new Error(result.msg || '登录已过期,请重新登录');
}
@@ -30,7 +36,10 @@ export const handleApiError = (error: unknown): never => {
if (error instanceof Error) {
// 如果是未授权错误可能是token过期
if (error.message.includes('401') || error.message.includes('token') || error.message.includes('授权')) {
handleTokenExpired();
// 仅在客户端处理token过期
if (typeof window !== 'undefined') {
handleTokenExpired();
}
}
throw error;
}

View File

@@ -5,3 +5,31 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 用于在客户端和服务器端获取一致的日期格式
// 避免因为时区差异导致的水合不匹配
export function getFormattedDate(date: Date | string | number, format: Intl.DateTimeFormatOptions = {}) {
// 使用传入的日期创建一个新的日期对象
const dateObj = new Date(date);
// 使用en-US区域设置创建字符串确保客户端和服务器端一致
return dateObj.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
...format
});
}
// 安全的客户端检查
export function isClient() {
return typeof window !== 'undefined';
}
// 防止在服务端使用Math.random导致水合不匹配
export function getClientRandomId(prefix = '') {
if (isClient()) {
return `${prefix}${Math.random().toString(36).substring(2, 9)}`;
}
return `${prefix}placeholder`;
}

View File

@@ -12,6 +12,28 @@ export enum DeviceType {
IOS = "ios",
}
// 服务端API返回的设备类型
export interface ServerDevice {
id: number;
imei: string;
memo: string;
wechatId: string;
alive: number;
totalFriend: number;
}
// 服务端API返回的设备列表响应
export interface ServerDevicesResponse {
code: number;
msg: string;
data: {
list: ServerDevice[];
total: number;
page: number;
limit: number;
};
}
// 设备基础信息
export interface Device {
id: string