私域操盘手 - 对接流量池列表

This commit is contained in:
柳清爽
2025-05-13 14:34:03 +08:00
parent e08a805e88
commit 0e947401dc
2 changed files with 213 additions and 138 deletions

View File

@@ -56,18 +56,51 @@ interface ApiResponse<T> {
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
}
statistics: {
total: number
todayNew: number
}
}
export default function TrafficPoolPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [users, setUsers] = useState<TrafficUser[]>([])
const [loading, setLoading] = useState(true) // Start with loading state
const [activeCategory, setActiveCategory] = useState("potential") // Changed default from "all" to "potential"
const [loading, setLoading] = useState(true)
const [activeCategory, setActiveCategory] = useState("potential")
const [sourceFilter, setSourceFilter] = useState("all")
const [statusTypes, setStatusTypes] = useState<StatusType[]>([])
const [statusFilter, setStatusFilter] = useState<string>("all")
const [statusFilter, setStatusFilter] = useState("all")
const [searchQuery, setSearchQuery] = useState("")
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [isFetching, setIsFetching] = useState(false)
const [stats, setStats] = useState({
total: 0,
todayNew: 0,
@@ -76,24 +109,45 @@ export default function TrafficPoolPage() {
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 fetchUsers = useCallback(async () => {
// 添加格式化时间的函数
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)
setLoading(true)
const params = new URLSearchParams({
page: currentPage.toString(),
pageSize: "10",
page: page.toString(),
limit: "30", // 设置每页显示30条
search: debouncedSearchQuery,
category: activeCategory,
source: sourceFilter,
source: sourceFilter !== "all" ? sourceFilter : "",
status: statusFilter === "all" ? "" : statusFilter,
})
@@ -103,62 +157,53 @@ export default function TrafficPoolPage() {
params.append("wechatSource", sourceParam)
}
const response = await fetch(`/api/users?${params}`, {
signal: abortControllerRef.current.signal,
})
const response = await api.get<ApiResponse<TrafficPoolResponse>>(`/v1/traffic/pool?${params.toString()}`)
if (!response.ok) throw new Error("请求失败")
if (response.code === 200) {
const { list, pagination, statistics } = response.data
const data = await response.json()
// 为每个用户添加标签
const usersWithTags = data.users.map((user: TrafficUser) => {
// 生成随机标签
const tagPool = [
{ name: "潜在客户", color: "bg-blue-100 text-blue-800" },
{ name: "高意向", color: "bg-green-100 text-green-800" },
{ name: "已成交", color: "bg-purple-100 text-purple-800" },
{ name: "需跟进", color: "bg-yellow-100 text-yellow-800" },
{ name: "活跃用户", color: "bg-indigo-100 text-indigo-800" },
{ name: "沉默用户", color: "bg-gray-100 text-gray-800" },
{ name: "企业客户", color: "bg-red-100 text-red-800" },
{ name: "个人用户", color: "bg-pink-100 text-pink-800" },
{ name: "新增好友", color: "bg-emerald-100 text-emerald-800" },
{ name: "老客户", color: "bg-amber-100 text-amber-800" },
]
const tags = Array.from({ length: Math.floor(Math.random() * 4) + 1 }, () => {
const randomTag = tagPool[Math.floor(Math.random() * tagPool.length)]
return {
id: `tag-${Math.random().toString(36).substring(2, 9)}`,
name: randomTag.name,
color: randomTag.color,
}
})
return {
// 转换数据格式
const transformedUsers = list.map(user => ({
...user,
tags,
}
})
id: user.id.toString(),
status: getStatusFromCode(user.status),
tags: user.tags || [],
category: user.category || "potential",
addTime: formatDateTime(user.createTime),
source: user.fromd || '未知来源',
nickname: user.name || user.nickname || '未知用户'
}))
setUsers(usersWithTags)
setTotalPages(data.pagination.totalPages)
setStats(data.stats)
// 更新用户列表
setUsers(prev => isNewSearch ? transformedUsers : [...prev, ...transformedUsers])
setCurrentPage(page)
setHasMore(list.length > 0 && page < pagination.totalPages)
setStats({
total: statistics.total,
todayNew: statistics.todayNew
})
} else {
toast({
title: "获取数据失败",
description: response.msg || "请稍后重试",
variant: "destructive",
})
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return
}
toast({
title: "错误",
description: "获取数据失败,请稍后重试",
title: "获取数据失败",
description: "请检查网络连接或稍后重试",
variant: "destructive",
})
} finally {
setIsFetching(false)
setLoading(false)
}
}, [currentPage, debouncedSearchQuery, activeCategory, sourceFilter, statusFilter, toast, searchParams])
}, [debouncedSearchQuery, activeCategory, sourceFilter, statusFilter, searchParams])
const fetchStatusTypes = useCallback(async () => {
try {
@@ -183,21 +228,73 @@ export default function TrafficPoolPage() {
}
}, [])
// 处理搜索
const handleSearch = useCallback(() => {
setUsers([])
setCurrentPage(1)
setHasMore(true)
fetchUsers(1, true)
}, [fetchUsers])
// 设置 IntersectionObserver
useEffect(() => {
fetchUsers()
fetchStatusTypes()
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isFetching) {
fetchUsers(currentPage + 1)
}
},
{ threshold: 0.5 }
)
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
}
}
}, [fetchUsers, currentPage, hasMore, isFetching])
// 观察加载指示器
useEffect(() => {
if (loadingRef.current && observerRef.current) {
observerRef.current.observe(loadingRef.current)
}
return () => {
if (loadingRef.current && observerRef.current) {
observerRef.current.unobserve(loadingRef.current)
}
}
}, [loadingRef.current, observerRef.current])
// 初始加载
useEffect(() => {
fetchUsers(1, true)
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [fetchUsers, fetchStatusTypes])
}, [fetchUsers])
const handleUserClick = (user: TrafficUser) => {
setSelectedUser(user)
setShowUserDetail(true)
}
// 添加状态码转换函数
const getStatusFromCode = (statusCode: number): "pending" | "added" | "failed" => {
const statusMap: Record<number, "pending" | "added" | "failed"> = {
1: "pending", // 待处理
2: "pending", // 处理中
3: "added", // 已添加
4: "failed", // 已拒绝
5: "failed", // 已过期
6: "failed", // 已取消
}
return statusMap[statusCode] || "pending"
}
return (
<div className="flex-1 bg-white min-h-screen flex flex-col">
<header className="sticky top-0 z-10 bg-white border-b">
@@ -307,7 +404,7 @@ export default function TrafficPoolPage() {
{/* 用户列表 */}
<div className="space-y-2">
{loading ? (
{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>
@@ -315,12 +412,13 @@ export default function TrafficPoolPage() {
) : 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={fetchUsers}>
<Button variant="outline" className="mt-4" onClick={() => fetchUsers(1, true)}>
</Button>
</div>
) : (
users.map((user) => (
<>
{users.map((user) => (
<Card
key={user.id}
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
@@ -345,7 +443,7 @@ export default function TrafficPoolPage() {
</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">: {new Date(user.addTime).toLocaleString()}</div>
<div className="text-sm text-gray-500">: {user.addTime}</div>
{/* 标签展示 */}
<div className="flex flex-wrap gap-1 mt-2">
@@ -363,51 +461,28 @@ export default function TrafficPoolPage() {
</div>
</div>
</Card>
))
))}
{/* 加载更多指示器 */}
{hasMore && (
<div ref={loadingRef} className="py-4 flex justify-center">
{isFetching && <RefreshCw className="h-6 w-6 animate-spin text-blue-500" />}
</div>
)}
{/* 显示加载状态和总数 */}
<div className="text-sm text-gray-500 text-center">
{stats.total > 0 && (
<span>
{users.length} / {stats.total}
</span>
)}
</div>
{/* 分页 */}
{!loading && users.length > 0 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.max(1, prev - 1))
}}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={currentPage === page}
onClick={(e) => {
e.preventDefault()
setCurrentPage(page)
}}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</>
)}
</div>
</div>
</div>
{/* 用户详情弹窗 */}
<Dialog open={showUserDetail} onOpenChange={setShowUserDetail}>
@@ -458,7 +533,7 @@ export default function TrafficPoolPage() {
</div>
<div className="space-y-1">
<div className="text-sm text-gray-500"></div>
<div className="font-medium">{new Date(selectedUser.addTime).toLocaleString()}</div>
<div className="font-medium">{selectedUser.addTime}</div>
</div>
</div>

View File

@@ -41,7 +41,7 @@ class GetDissociateListWithInCompanyV1Controller extends BaseController
$query = TrafficPoolModel::alias('t')
->field(
[
't.identifier', 't.mobile', 't.wechatId',
't.identifier nickname', 't.mobile', 't.wechatId', 't.identifier',
's.id', 's.fromd', 's.status', 's.createTime'
]
)