Files
cunkebao_v3/Cunkebao/app/traffic-pool/page.tsx

647 lines
22 KiB
TypeScript

"use client"
import { useState, useCallback, useRef, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ChevronLeft, Filter, Search, RefreshCw, Tag } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { useToast } from "@/components/ui/use-toast"
import { useDebounce } from "@/hooks/use-debounce"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { api } from "@/lib/api"
interface UserTag {
id: string
name: string
color: string
}
interface TrafficUser {
id: string
avatar: string
nickname: string
wechatId: string
phone: string
region: string
note: string
status: number
addTime: string
source: string
assignedTo: string
category: "potential" | "customer" | "lost"
tags: UserTag[]
}
interface StatusType {
id: number
name: string
code: string
}
interface SourceType {
id: number
name: string
}
interface ApiResponse<T> {
code: number
msg: string
data: T
}
// 修改流量池数据类型定义
interface TrafficPoolUser {
id: string
avatar: string
nickname: string
name: string
wechatId: string
phone: string
region: string
note: string
status: number
createTime: string
fromd: string
assignedTo: string
category: "potential" | "customer" | "lost"
tags: UserTag[]
}
interface TrafficPoolResponse {
list: TrafficPoolUser[]
pagination: {
total: number
current: number
pageSize: number
totalPages: number
}
}
interface Statistics {
totalCount: number
todayAddCount: number
}
export default function TrafficPoolPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [users, setUsers] = useState<TrafficUser[]>([])
const [loading, setLoading] = useState(true)
const [activeCategory, setActiveCategory] = useState("potential")
const [sourceFilter, setSourceFilter] = useState("all")
const [statusTypes, setStatusTypes] = useState<StatusType[]>([])
const [sourceTypes, setSourceTypes] = useState<SourceType[]>([])
const [statusFilter, setStatusFilter] = useState("all")
const [searchQuery, setSearchQuery] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [isFetching, setIsFetching] = useState(false)
const [stats, setStats] = useState<Statistics>({
totalCount: 0,
todayAddCount: 0
})
const [selectedUser, setSelectedUser] = useState<TrafficUser | null>(null)
const [showUserDetail, setShowUserDetail] = useState(false)
const { toast } = useToast()
const observerRef = useRef<IntersectionObserver | null>(null)
const loadingRef = useRef<HTMLDivElement | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const debouncedSearchQuery = useDebounce(searchQuery, 300)
// 添加格式化时间的函数
const formatDateTime = (dateString: string) => {
if (!dateString) return '--';
try {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).replace(/\//g, '-');
} catch (error) {
return dateString;
}
};
const fetchUsers = useCallback(async (page: number = 1, isNewSearch: boolean = false) => {
try {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current = new AbortController()
setIsFetching(true)
const params = new URLSearchParams({
page: page.toString(),
limit: "30"
})
// 只有在有搜索关键词时才添加 keyword 参数
if (debouncedSearchQuery) {
params.append("keyword", debouncedSearchQuery)
}
// 只有在选择了特定来源时才添加 fromd 参数
if (sourceFilter !== "all") {
const selectedSource = sourceTypes.find(source => source.id.toString() === sourceFilter)
if (selectedSource) {
params.append("fromd", selectedSource.name)
}
}
// 只有在选择了特定状态时才添加 status 参数
if (statusFilter !== "all") {
params.append("status", statusFilter)
}
const response = await api.get<ApiResponse<TrafficPoolResponse>>(`/v1/traffic/pool?${params.toString()}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
} as any)
if (response.code === 200) {
const { list, pagination } = response.data
const transformedUsers = list.map(user => ({
id: user.id.toString(),
avatar: user.avatar,
nickname: user.name || user.nickname || '未知用户',
wechatId: user.wechatId,
phone: user.phone,
region: user.region,
note: user.note,
status: user.status,
addTime: formatDateTime(user.createTime),
source: user.fromd || '未知来源',
assignedTo: user.assignedTo,
category: user.category || "potential",
tags: user.tags || []
}))
setUsers(prev => isNewSearch ? transformedUsers : [...prev, ...transformedUsers])
setCurrentPage(page)
setHasMore(list.length > 0 && page < pagination.totalPages)
} else {
toast({
title: "获取数据失败",
description: response.msg || "请稍后重试",
variant: "destructive",
})
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return
}
toast({
title: "获取数据失败",
description: "请检查网络连接或稍后重试",
variant: "destructive",
})
} finally {
setIsFetching(false)
setLoading(false)
}
}, [debouncedSearchQuery, sourceFilter, statusFilter, sourceTypes])
const fetchStatusTypes = useCallback(async () => {
try {
const response = await api.get<ApiResponse<StatusType[]>>('/v1/traffic/pool/types', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
} as any)
if (response.code === 200) {
setStatusTypes(response.data)
} else {
toast({
title: "获取状态列表失败",
description: response.msg || "请稍后重试",
variant: "destructive",
})
}
} catch (error) {
console.error("获取状态列表失败:", error)
toast({
title: "获取状态列表失败",
description: "请检查网络连接或稍后重试",
variant: "destructive",
})
}
}, [])
const fetchSourceTypes = useCallback(async () => {
try {
const response = await api.get<ApiResponse<SourceType[]>>('/v1/traffic/pool/sources', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
} as any)
if (response.code === 200) {
setSourceTypes(response.data)
} else {
toast({
title: "获取来源列表失败",
description: response.msg || "请稍后重试",
variant: "destructive",
})
}
} catch (error) {
console.error("获取来源列表失败:", error)
toast({
title: "获取来源列表失败",
description: "请检查网络连接或稍后重试",
variant: "destructive",
})
}
}, [])
const fetchStatistics = useCallback(async () => {
try {
const response = await api.get<ApiResponse<Statistics>>('/v1/traffic/pool/statistics', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
} as any)
if (response.code === 200) {
setStats(response.data)
} else {
toast({
title: "获取统计数据失败",
description: response.msg || "请稍后重试",
variant: "destructive",
})
}
} catch (error) {
console.error("获取统计数据失败:", error)
toast({
title: "获取统计数据失败",
description: "请检查网络连接或稍后重试",
variant: "destructive",
})
}
}, [])
// 处理搜索
const handleSearch = useCallback(() => {
setUsers([])
setCurrentPage(1)
setHasMore(true)
fetchUsers(1, true)
}, [fetchUsers])
// 初始化 IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isFetching) {
fetchUsers(currentPage + 1)
}
},
{ threshold: 0.5 }
)
if (loadingRef.current) {
observer.observe(loadingRef.current)
}
return () => {
if (loadingRef.current) {
observer.unobserve(loadingRef.current)
}
}
}, [hasMore, isFetching, currentPage, fetchUsers])
// 初始化数据
useEffect(() => {
fetchStatusTypes()
fetchSourceTypes()
fetchStatistics()
fetchUsers(1, true)
}, [])
// 监听筛选条件变化
useEffect(() => {
setUsers([])
setCurrentPage(1)
setHasMore(true)
fetchUsers(1, true)
}, [activeCategory, sourceFilter, statusFilter, debouncedSearchQuery])
const handleUserClick = (user: TrafficUser) => {
setSelectedUser(user)
setShowUserDetail(true)
}
// 添加状态码转换函数
const getStatusFromCode = (statusCode: number): number => {
return statusCode;
}
// 添加刷新处理函数
const handleRefresh = useCallback(() => {
setUsers([])
setCurrentPage(1)
setHasMore(true)
fetchUsers(1, true)
fetchStatistics()
}, [fetchUsers, fetchStatistics])
return (
<div className="flex-1 bg-white min-h-screen flex flex-col">
<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>
<Button variant="outline" size="icon" onClick={handleRefresh}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</header>
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-6">
{/* 搜索和筛选区域 */}
<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="搜索用户"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-blue-600">{stats.totalCount}</div>
</Card>
<Card className="p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-2xl font-bold text-green-600">{stats.todayAddCount}</div>
</Card>
</div>
{/* 分类标签页 */}
<Tabs
defaultValue="potential"
value={activeCategory}
onValueChange={(value) => {
setActiveCategory(value)
setCurrentPage(1)
}}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="potential"></TabsTrigger>
<TabsTrigger value="customer"></TabsTrigger>
</TabsList>
</Tabs>
{/* 筛选器 */}
<div className="flex space-x-2">
<Select
value={sourceFilter}
onValueChange={(value) => {
setSourceFilter(value)
setCurrentPage(1)
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{sourceTypes.map((source) => (
<SelectItem key={source.id} value={source.id.toString()}>
{source.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={statusFilter}
onValueChange={(value) => {
setStatusFilter(value)
setCurrentPage(1)
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{statusTypes.map((status) => (
<SelectItem key={status.id} value={status.id.toString()}>
{status.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 用户列表 */}
<div className="space-y-2">
{loading && users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<RefreshCw className="h-8 w-8 text-blue-500 animate-spin mb-4" />
<div className="text-gray-500">...</div>
</div>
) : users.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<div className="text-gray-500"></div>
<Button variant="outline" className="mt-4" onClick={handleRefresh}>
</Button>
</div>
) : (
<>
{users.map((user) => (
<Card
key={user.id}
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => handleUserClick(user)}
>
<div className="flex items-center space-x-3">
<img src={user.avatar || "/placeholder.svg"} alt="" className="w-10 h-10 rounded-full bg-gray-100" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium truncate">{user.nickname}</div>
<div
className={`text-xs px-2 py-1 rounded-full ${
user.status === 2
? "bg-green-100 text-green-800"
: user.status === 1
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{user.status === 2 ? "已添加" : user.status === 1 ? "待处理" : "已失败"}
</div>
</div>
<div className="text-sm text-gray-500">: {user.wechatId}</div>
<div className="text-sm text-gray-500">: {user.source}</div>
<div className="text-sm text-gray-500">: {user.addTime}</div>
{/* 标签展示 */}
<div className="flex flex-wrap gap-1 mt-2">
{user.tags.slice(0, 2).map((tag) => (
<span key={tag.id} className={`text-xs px-2 py-0.5 rounded-full ${tag.color}`}>
{tag.name}
</span>
))}
{user.tags.length > 2 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-800">
+{user.tags.length - 2}
</span>
)}
</div>
</div>
</div>
</Card>
))}
{/* 加载状态显示 */}
<div ref={loadingRef} className="flex justify-center py-4">
{isFetching && (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-600">...</span>
</div>
)}
{!hasMore && users.length > 0 && (
<span className="text-gray-500"></span>
)}
{!isFetching && users.length === 0 && (
<span className="text-gray-500"></span>
)}
</div>
</>
)}
</div>
</div>
</div>
{/* 用户详情弹窗 */}
<Dialog open={showUserDetail} onOpenChange={setShowUserDetail}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedUser && (
<div className="space-y-4">
<div className="flex items-center space-x-3">
<Avatar className="h-16 w-16">
<AvatarImage src={selectedUser.avatar} />
<AvatarFallback>{selectedUser.nickname[0]}</AvatarFallback>
</Avatar>
<div>
<div className="text-xl font-medium">{selectedUser.nickname}</div>
<div className="text-sm text-gray-500">{selectedUser.wechatId}</div>
<Badge
className={`mt-1 ${
selectedUser.status === 2
? "bg-green-100 text-green-800"
: selectedUser.status === 1
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{selectedUser.status === 2 ? "已添加" : selectedUser.status === 1 ? "待处理" : "已失败"}
</Badge>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedUser.phone}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedUser.region}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedUser.source}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{selectedUser.addTime}</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm text-gray-500 flex items-center">
<Tag className="h-4 w-4 mr-1" />
</div>
<div className="flex flex-wrap gap-2">
{selectedUser.tags.map((tag) => (
<span key={tag.id} className={`text-sm px-2 py-1 rounded-full ${tag.color}`}>
{tag.name}
</span>
))}
{selectedUser.tags.length === 0 && <span className="text-sm text-gray-500"></span>}
</div>
</div>
{selectedUser.note && (
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="p-3 bg-gray-50 rounded-lg text-sm">{selectedUser.note}</div>
</div>
)}
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setShowUserDetail(false)}>
</Button>
<Button></Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}