存客宝 React

This commit is contained in:
柳清爽
2025-03-29 16:50:39 +08:00
parent caea0b4b99
commit 7e7c199996
388 changed files with 53282 additions and 2076 deletions

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Plus } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
return channelMap[channel] || channel
}
interface Customer {
id: string
nickname: string
avatar: string
tags: string[]
acquiredTime: string
source: string
}
export default function AcquiredCustomersPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const channelName = getChannelName(params.channel)
const [customers] = useState<Customer[]>(
Array.from({ length: 31 }, (_, i) => ({
id: `customer-${i + 1}`,
nickname: `用户${i + 1}`,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
tags: ["直播间", "高互动", Math.random() > 0.5 ? "潜在客户" : "意向客户"],
acquiredTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
source: Math.random() > 0.5 ? "直播间" : "评论区",
})),
)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const totalPages = Math.ceil(customers.length / itemsPerPage)
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm 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-xl font-semibold text-blue-600">{channelName}</h1>
</div>
<Button variant="default" onClick={() => router.push(`/scenarios/new`)} className="flex items-center gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
{currentCustomers.map((customer) => (
<Card key={customer.id} className="p-4">
<div className="flex items-start space-x-4">
<img
src={customer.avatar || "/placeholder.svg"}
alt={customer.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">{customer.nickname}</h3>
<span className="text-sm text-gray-500">{customer.acquiredTime}</span>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{customer.tags.map((tag, index) => (
<Badge key={index} variant="secondary">
{tag}
</Badge>
))}
</div>
<div className="mt-2 text-sm text-gray-500">{customer.source}</div>
</div>
</div>
</Card>
))}
{totalPages > 1 && (
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-500">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Plus } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
return channelMap[channel] || channel
}
interface AddedCustomer {
id: string
nickname: string
avatar: string
tags: string[]
addedTime: string
source: string
wechatId: string
}
export default function AddedCustomersPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const { channel } = params // Extract channel from params
const channelName = getChannelName(channel)
const [customers] = useState<AddedCustomer[]>(
Array.from({ length: 25 }, (_, i) => ({
id: `customer-${i + 1}`,
nickname: `用户${i + 1}`,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=" + (i + 1),
tags: ["已添加", Math.random() > 0.5 ? "高意向" : "待跟进", "直播间"],
addedTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
source: Math.random() > 0.5 ? "直播间" : "评论区",
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
})),
)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const totalPages = Math.ceil(customers.length / itemsPerPage)
const currentCustomers = customers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm 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-xl font-semibold text-blue-600">{channelName}</h1>
</div>
<Button variant="default" onClick={() => router.push(`/scenarios/new`)} className="flex items-center gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
{currentCustomers.map((customer) => (
<Card key={customer.id} className="p-4">
<div className="flex items-start space-x-4">
<img
src={customer.avatar || "/placeholder.svg"}
alt={customer.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium">{customer.nickname}</h3>
<span className="text-sm text-gray-500">{customer.addedTime}</span>
</div>
<div className="mt-1 text-sm text-gray-500">{customer.wechatId}</div>
<div className="mt-2 flex flex-wrap gap-2">
{customer.tags.map((tag, index) => (
<Badge key={index} variant="secondary">
{tag}
</Badge>
))}
</div>
<div className="mt-2 text-sm text-gray-500">{customer.source}</div>
</div>
</div>
</Card>
))}
{totalPages > 1 && (
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-500">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,463 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Copy, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
haibao: "海报",
phone: "电话",
}
return channelMap[channel] || channel
}
interface ApiKey {
id: string
name: string
key: string
createdAt: string
lastUsed: string | null
status: "active" | "inactive"
}
interface Webhook {
id: string
name: string
url: string
events: string[]
createdAt: string
lastTriggered: string | null
status: "active" | "inactive"
}
export default function ApiManagementPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const channel = params.channel
const channelName = getChannelName(channel)
// 模拟API密钥数据
const [apiKeys, setApiKeys] = useState<ApiKey[]>([
{
id: "1",
name: `${channelName}获客API密钥`,
key: `api_${channel}_${Math.random().toString(36).substring(2, 10)}`,
createdAt: "2024-03-20 14:30:00",
lastUsed: "2024-03-21 09:15:22",
status: "active",
},
])
// 模拟Webhook数据
const [webhooks, setWebhooks] = useState<Webhook[]>([
{
id: "1",
name: `${channelName}获客回调`,
url: `https://api.example.com/webhooks/${channel}`,
events: ["customer.created", "customer.updated", "tag.added"],
createdAt: "2024-03-20 14:35:00",
lastTriggered: "2024-03-21 09:16:45",
status: "active",
},
])
// 对话框状态
const [showNewApiKeyDialog, setShowNewApiKeyDialog] = useState(false)
const [showNewWebhookDialog, setShowNewWebhookDialog] = useState(false)
const [newApiKeyName, setNewApiKeyName] = useState("")
const [newWebhookData, setNewWebhookData] = useState({
name: "",
url: "",
events: ["customer.created", "customer.updated", "tag.added"],
})
// 创建新API密钥
const handleCreateApiKey = () => {
if (!newApiKeyName.trim()) {
toast({
title: "错误",
description: "请输入API密钥名称",
variant: "destructive",
})
return
}
const newKey: ApiKey = {
id: `${Date.now()}`,
name: newApiKeyName,
key: `api_${channel}_${Math.random().toString(36).substring(2, 15)}`,
createdAt: new Date().toLocaleString(),
lastUsed: null,
status: "active",
}
setApiKeys([...apiKeys, newKey])
setNewApiKeyName("")
setShowNewApiKeyDialog(false)
toast({
title: "创建成功",
description: "新的API密钥已创建",
variant: "success",
})
}
// 创建新Webhook
const handleCreateWebhook = () => {
if (!newWebhookData.name.trim() || !newWebhookData.url.trim()) {
toast({
title: "错误",
description: "请填写所有必填字段",
variant: "destructive",
})
return
}
const newWebhook: Webhook = {
id: `${Date.now()}`,
name: newWebhookData.name,
url: newWebhookData.url,
events: newWebhookData.events,
createdAt: new Date().toLocaleString(),
lastTriggered: null,
status: "active",
}
setWebhooks([...webhooks, newWebhook])
setNewWebhookData({
name: "",
url: "",
events: ["customer.created", "customer.updated", "tag.added"],
})
setShowNewWebhookDialog(false)
toast({
title: "创建成功",
description: "新的Webhook已创建",
variant: "success",
})
}
// 删除API密钥
const handleDeleteApiKey = (id: string) => {
setApiKeys(apiKeys.filter((key) => key.id !== id))
toast({
title: "删除成功",
description: "API密钥已删除",
variant: "success",
})
}
// 删除Webhook
const handleDeleteWebhook = (id: string) => {
setWebhooks(webhooks.filter((webhook) => webhook.id !== id))
toast({
title: "删除成功",
description: "Webhook已删除",
variant: "success",
})
}
// 切换API密钥状态
const toggleApiKeyStatus = (id: string) => {
setApiKeys(
apiKeys.map((key) => (key.id === id ? { ...key, status: key.status === "active" ? "inactive" : "active" } : key)),
)
}
// 切换Webhook状态
const toggleWebhookStatus = (id: string) => {
setWebhooks(
webhooks.map((webhook) =>
webhook.id === id ? { ...webhook, status: webhook.status === "active" ? "inactive" : "active" } : webhook,
),
)
}
return (
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
<div className="flex items-center p-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-xl font-semibold text-blue-600 ml-2">{channelName}</h1>
</div>
</header>
<div className="p-4 max-w-7xl mx-auto">
<Tabs defaultValue="api-keys" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="api-keys">API密钥</TabsTrigger>
<TabsTrigger value="webhooks">Webhook</TabsTrigger>
</TabsList>
<TabsContent value="api-keys" className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-medium">API密钥管理</h2>
<Button onClick={() => setShowNewApiKeyDialog(true)}>
<Plus className="w-4 h-4 mr-2" />
API密钥
</Button>
</div>
<div className="space-y-4">
{apiKeys.map((apiKey) => (
<Card key={apiKey.id}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle>{apiKey.name}</CardTitle>
<CardDescription> {apiKey.createdAt}</CardDescription>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<Switch
checked={apiKey.status === "active"}
onCheckedChange={() => toggleApiKeyStatus(apiKey.id)}
/>
<span className="text-sm text-gray-500">{apiKey.status === "active" ? "启用" : "禁用"}</span>
</div>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDeleteApiKey(apiKey.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Input value={apiKey.key} readOnly className="font-mono text-sm" />
<Button
variant="outline"
size="icon"
onClick={() => {
navigator.clipboard.writeText(apiKey.key)
toast({
title: "已复制",
description: "API密钥已复制到剪贴板",
variant: "success",
})
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
{apiKey.lastUsed && <p className="text-sm text-gray-500">使: {apiKey.lastUsed}</p>}
</div>
</CardContent>
</Card>
))}
{apiKeys.length === 0 && (
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
<p className="text-gray-500">API密钥</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="webhooks" className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-medium">Webhook管理</h2>
<Button onClick={() => setShowNewWebhookDialog(true)}>
<Plus className="w-4 h-4 mr-2" />
Webhook
</Button>
</div>
<div className="space-y-4">
{webhooks.map((webhook) => (
<Card key={webhook.id}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle>{webhook.name}</CardTitle>
<CardDescription> {webhook.createdAt}</CardDescription>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<Switch
checked={webhook.status === "active"}
onCheckedChange={() => toggleWebhookStatus(webhook.id)}
/>
<span className="text-sm text-gray-500">{webhook.status === "active" ? "启用" : "禁用"}</span>
</div>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDeleteWebhook(webhook.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Input value={webhook.url} readOnly className="font-mono text-sm" />
<Button
variant="outline"
size="icon"
onClick={() => {
navigator.clipboard.writeText(webhook.url)
toast({
title: "已复制",
description: "Webhook URL已复制到剪贴板",
variant: "success",
})
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{webhook.events.map((event) => (
<span key={event} className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs">
{event}
</span>
))}
</div>
{webhook.lastTriggered && (
<p className="text-sm text-gray-500">: {webhook.lastTriggered}</p>
)}
</div>
</CardContent>
</Card>
))}
{webhooks.length === 0 && (
<div className="text-center py-8 bg-white rounded-lg shadow-sm">
<p className="text-gray-500">Webhook</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
{/* 创建API密钥对话框 */}
<Dialog open={showNewApiKeyDialog} onOpenChange={setShowNewApiKeyDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>API密钥</DialogTitle>
<DialogDescription>API密钥用于访问{channelName}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="api-key-name">API密钥名称</Label>
<Input
id="api-key-name"
placeholder="例如:获客系统集成"
value={newApiKeyName}
onChange={(e) => setNewApiKeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewApiKeyDialog(false)}>
</Button>
<Button onClick={handleCreateApiKey}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 创建Webhook对话框 */}
<Dialog open={showNewWebhookDialog} onOpenChange={setShowNewWebhookDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Webhook</DialogTitle>
<DialogDescription>Webhook用于接收{channelName}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="webhook-name">Webhook名称</Label>
<Input
id="webhook-name"
placeholder="例如CRM系统集成"
value={newWebhookData.name}
onChange={(e) => setNewWebhookData({ ...newWebhookData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<Input
id="webhook-url"
placeholder="https://example.com/webhook"
value={newWebhookData.url}
onChange={(e) => setNewWebhookData({ ...newWebhookData, url: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-1 gap-2">
{["customer.created", "customer.updated", "tag.added", "tag.removed"].map((event) => (
<div key={event} className="flex items-center space-x-2">
<Switch
checked={newWebhookData.events.includes(event)}
onCheckedChange={(checked) => {
if (checked) {
setNewWebhookData({
...newWebhookData,
events: [...newWebhookData.events, event],
})
} else {
setNewWebhookData({
...newWebhookData,
events: newWebhookData.events.filter((e) => e !== event),
})
}
}}
/>
<span>{event}</span>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewWebhookDialog(false)}>
</Button>
<Button onClick={handleCreateWebhook}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,266 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } 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 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "@/components/ui/use-toast"
import type { Device } from "@/types/device"
export default function ScenarioDevicesPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const [devices, setDevices] = useState<Device[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
const devicesPerPage = 10
const maxDevices = 5
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
return channelMap[channel] || channel
}
const channelName = getChannelName(params.channel)
useEffect(() => {
// 模拟API调用
const fetchDevices = async () => {
const mockDevices = Array.from({ length: 15 }, (_, i) => ({
id: `device-${i + 1}`,
imei: `sd${123123 + i}`,
name: `设备 ${i + 1}`,
remark: `${channelName}获客设备 ${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",
}))
setDevices(mockDevices)
}
fetchDevices()
}, [channelName])
const handleRefresh = () => {
toast({
title: "刷新成功",
description: "设备列表已更新",
})
}
const handleSelectAll = () => {
if (selectedDevices.length === devices.length || selectedDevices.length === maxDevices) {
setSelectedDevices([])
} else {
const newSelection = devices.slice(0, maxDevices).map((d) => d.id)
setSelectedDevices(newSelection)
}
}
const handleDeviceSelect = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
} else {
if (selectedDevices.length >= maxDevices) {
toast({
title: "选择超出限制",
description: `最多可选择${maxDevices}个设备`,
variant: "destructive",
})
return
}
setSelectedDevices([...selectedDevices, deviceId])
}
}
const handleSave = async () => {
try {
// 这里应该是实际的API调用来保存选中的设备
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "保存成功",
description: "已更新计划设备",
})
router.back()
} catch (error) {
toast({
title: "保存失败",
description: "更新设备失败,请重试",
variant: "destructive",
})
}
}
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 paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
<div className="flex items-center 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-xl font-semibold text-blue-600">{channelName}</h1>
</div>
</div>
</header>
<div className="p-4 space-y-4">
<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>
<div className="text-sm text-gray-500">
{selectedDevices.length}/{maxDevices}
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={
selectedDevices.length > 0 &&
(selectedDevices.length === devices.length || selectedDevices.length === maxDevices)
}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm"></span>
</div>
<div className="space-y-2">
{paginatedDevices.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
paginatedDevices.map((device) => (
<Card
key={device.id}
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
}`}
onClick={() => handleDeviceSelect(device.id)}
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedDevices.includes(device.id)}
className="mt-1"
onClick={(e) => {
e.stopPropagation()
handleDeviceSelect(device.id)
}}
/>
<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"}>
{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="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>
</div>
</div>
</div>
</Card>
))
)}
</div>
{filteredDevices.length > devicesPerPage && (
<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>
)}
</div>
</Card>
<div className="fixed bottom-0 left-0 right-0 bg-white p-4 border-t flex justify-end space-x-2">
<Button variant="outline" onClick={() => router.back()}>
</Button>
<Button onClick={handleSave} disabled={selectedDevices.length === 0}>
({selectedDevices.length})
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
"use client"
import { useState, useEffect } from "react"
import { ChevronLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { BasicSettings } from "../../../new/steps/BasicSettings"
import { FriendRequestSettings } from "../../../new/steps/FriendRequestSettings"
import { MessageSettings } from "../../../new/steps/MessageSettings"
import { TagSettings } from "../../../new/steps/TagSettings"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
const steps = [
{ id: 1, title: "步骤一", subtitle: "基础设置" },
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
{ id: 3, title: "步骤三", subtitle: "消息设置" },
{ id: 4, title: "步骤四", subtitle: "流量标签设置" },
]
export default function EditAcquisitionPlan({ params }: { params: { channel: string; id: string } }) {
const router = useRouter()
const [currentStep, setCurrentStep] = useState(1)
const [loading, setLoading] = useState(true)
const [formData, setFormData] = useState({
planName: "",
accounts: [],
dailyLimit: 10,
enabled: true,
remarkType: "phone",
remarkKeyword: "",
greeting: "",
addFriendTimeStart: "09:00",
addFriendTimeEnd: "18:00",
addFriendInterval: 1,
maxDailyFriends: 20,
messageInterval: 1,
messageContent: "",
})
useEffect(() => {
// 模拟从API获取计划数据
const fetchPlanData = async () => {
try {
// 这里应该是实际的API调用
const mockData = {
planName: "测试计划",
accounts: ["account1"],
dailyLimit: 15,
enabled: true,
remarkType: "phone",
remarkKeyword: "测试",
greeting: "你好",
addFriendTimeStart: "09:00",
addFriendTimeEnd: "18:00",
addFriendInterval: 2,
maxDailyFriends: 25,
messageInterval: 2,
messageContent: "欢迎",
}
setFormData(mockData)
setLoading(false)
} catch (error) {
toast({
title: "加载失败",
description: "获取计划数据失败,请重试",
variant: "destructive",
})
setLoading(false)
}
}
fetchPlanData()
}, [])
const handleSave = async () => {
try {
// 这里应该是实际的API调用
await new Promise((resolve) => setTimeout(resolve, 1000))
toast({
title: "保存成功",
description: "获客计划已更新",
})
router.push(`/scenarios/${params.channel}`)
} catch (error) {
toast({
title: "保存失败",
description: "更新计划失败,请重试",
variant: "destructive",
})
}
}
const handlePrev = () => {
setCurrentStep((prevStep) => Math.max(prevStep - 1, 1))
}
const handleNext = () => {
if (isStepValid()) {
if (currentStep === steps.length) {
handleSave()
} else {
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length))
}
}
}
const isStepValid = () => {
switch (currentStep) {
case 1:
if (!formData.planName.trim() || formData.accounts.length === 0) {
toast({
title: "请完善信息",
description: "请填写计划名称并选择至少一个账号",
variant: "destructive",
})
return false
}
return true
case 2:
if (!formData.greeting.trim()) {
toast({
title: "请完善信息",
description: "请填写好友申请信息",
variant: "destructive",
})
return false
}
return true
case 3:
if (!formData.messageContent.trim()) {
toast({
title: "请完善信息",
description: "请填写消息内容",
variant: "destructive",
})
return false
}
return true
case 4:
return true
default:
return true
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-600">...</p>
</div>
</div>
)
}
const renderStepContent = () => {
switch (currentStep) {
case 1:
return <BasicSettings formData={formData} onChange={setFormData} onNext={handleNext} isEdit />
case 2:
return (
<FriendRequestSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
)
case 3:
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
case 4:
return <TagSettings formData={formData} onChange={setFormData} onNext={handleSave} onPrev={handlePrev} />
default:
return null
}
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center h-14 px-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="ml-2 text-lg font-medium"></h1>
</div>
</header>
<div className="flex-1 flex flex-col">
<div className="px-4 py-6">
<div className="relative flex justify-between">
{steps.map((step) => (
<div
key={step.id}
className={cn(
"flex flex-col items-center relative z-10",
currentStep >= step.id ? "text-blue-600" : "text-gray-400",
)}
>
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center border-2 transition-colors",
currentStep >= step.id
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-300 bg-white text-gray-400",
)}
>
{step.id}
</div>
<div className="text-xs mt-1">{step.title}</div>
<div className="text-xs mt-0.5 font-medium">{step.subtitle}</div>
</div>
))}
<div className="absolute top-4 left-0 right-0 h-0.5 bg-gray-200 -z-10">
<div
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
/>
</div>
</div>
</div>
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
<div className="sticky bottom-0 left-0 right-0 bg-white border-t p-4">
<div className="flex justify-between max-w-[390px] mx-auto">
{currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
</Button>
)}
<Button className={cn("min-w-[120px]", currentStep === 1 ? "w-full" : "ml-auto")} onClick={handleNext}>
{currentStep === steps.length ? "保存" : "下一步"}
</Button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,321 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Copy, Link, HelpCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { toast } from "@/components/ui/use-toast"
import { ScenarioAcquisitionCard } from "@/app/components/acquisition/ScenarioAcquisitionCard"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
haibao: "海报",
phone: "电话",
gongzhonghao: "公众号",
weixinqun: "微信群",
payment: "付款码",
api: "API",
}
return channelMap[channel] || channel
}
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
acquired: number
added: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
}
interface DeviceStats {
active: number
}
// API文档提示组件
function ApiDocumentationTooltip() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-4 w-4 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">
API将外部系统的客户数据直接导入到存客宝
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export default function ChannelPage({ params }: { params: { channel: string } }) {
const router = useRouter()
const channel = params.channel
const channelName = getChannelName(params.channel)
const initialTasks = [
{
id: "1",
name: `${channelName}直播获客计划`,
status: "running",
stats: {
devices: 5,
acquired: 31,
added: 25,
},
lastUpdated: "2024-02-09 15:30",
executionTime: "2024-02-09 17:24:10",
nextExecutionTime: "2024-02-09 17:25:36",
trend: Array.from({ length: 7 }, (_, i) => ({
date: `2月${String(i + 1)}`,
customers: Math.floor(Math.random() * 30) + 30,
})),
},
{
id: "2",
name: `${channelName}评论区获客计划`,
status: "paused",
stats: {
devices: 3,
acquired: 15,
added: 12,
},
lastUpdated: "2024-02-09 14:00",
executionTime: "2024-02-09 16:30:00",
nextExecutionTime: "2024-02-09 16:45:00",
trend: Array.from({ length: 7 }, (_, i) => ({
date: `2月${String(i + 1)}`,
customers: Math.floor(Math.random() * 20) + 20,
})),
},
]
const [tasks, setTasks] = useState<Task[]>(initialTasks)
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
active: 5,
})
const [showApiDialog, setShowApiDialog] = useState(false)
const [currentApiSettings, setCurrentApiSettings] = useState({
apiKey: "",
webhookUrl: "",
taskId: "",
})
const handleEditPlan = (taskId: string) => {
router.push(`/scenarios/${channel}/edit/${taskId}`)
}
const handleCopyPlan = (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId)
if (taskToCopy) {
const newTask = {
...taskToCopy,
id: `${Date.now()}`,
name: `${taskToCopy.name} (副本)`,
status: "paused" as const,
}
setTasks([...tasks, newTask])
toast({
title: "计划已复制",
description: `已成功复制"${taskToCopy.name}"`,
variant: "success",
})
}
}
const handleDeletePlan = (taskId: string) => {
const taskToDelete = tasks.find((t) => t.id === taskId)
if (taskToDelete) {
setTasks(tasks.filter((t) => t.id !== taskId))
toast({
title: "计划已删除",
description: `已成功删除"${taskToDelete.name}"`,
variant: "success",
})
}
}
const handleStatusChange = (taskId: string, newStatus: "running" | "paused") => {
setTasks(tasks.map((task) => (task.id === taskId ? { ...task, status: newStatus } : task)))
toast({
title: newStatus === "running" ? "计划已启动" : "计划已暂停",
description: `${newStatus === "running" ? "启动" : "暂停"}获客计划`,
variant: "success",
})
}
const handleOpenApiSettings = (taskId: string) => {
const task = tasks.find((t) => t.id === taskId)
if (task) {
setCurrentApiSettings({
apiKey: `api_${taskId}_${Math.random().toString(36).substring(2, 10)}`,
webhookUrl: `${window.location.origin}/api/scenarios/${channel}/${taskId}/webhook`,
taskId,
})
setShowApiDialog(true)
}
}
const handleCopyApiUrl = (url: string, withParams = false) => {
let copyUrl = url
if (withParams) {
copyUrl = `${url}?name=张三&phone=13800138000&source=外部系统&remark=测试数据`
}
navigator.clipboard.writeText(copyUrl)
toast({
title: "已复制",
description: withParams ? "接口地址(含示例参数)已复制到剪贴板" : "接口地址已复制到剪贴板",
variant: "success",
})
}
return (
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
<div className="flex items-center 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-xl font-semibold text-blue-600">{channelName}</h1>
</div>
</div>
</header>
<div className="p-4 max-w-7xl mx-auto">
{tasks.length > 0 ? (
tasks.map((task) => (
<div key={task.id} className="mb-6">
<ScenarioAcquisitionCard
task={task}
channel={channel}
onEdit={() => handleEditPlan(task.id)}
onCopy={handleCopyPlan}
onDelete={handleDeletePlan}
onStatusChange={handleStatusChange}
onOpenSettings={handleOpenApiSettings}
/>
</div>
))
) : (
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
<div className="text-gray-400 mb-4"></div>
</div>
)}
</div>
{/* API接口设置对话框 */}
<Dialog open={showApiDialog} onOpenChange={setShowApiDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle></DialogTitle>
<ApiDocumentationTooltip />
</div>
<DialogDescription>使</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="api-key">API密钥</Label>
<div className="flex items-center space-x-2">
<Input id="api-key" value={currentApiSettings.apiKey} readOnly className="flex-1" />
<Button
variant="outline"
size="icon"
onClick={() => {
navigator.clipboard.writeText(currentApiSettings.apiKey)
toast({
title: "已复制",
description: "API密钥已复制到剪贴板",
variant: "success",
})
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-url"></Label>
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl, true)}
>
</button>
</div>
<div className="flex items-center space-x-2">
<Input id="webhook-url" value={currentApiSettings.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="icon" onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl)}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-gray-500">GET/POST请求namephone</p>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center justify-between">
<Button
className="w-full flex items-center justify-center gap-2"
onClick={() => {
window.open(`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}`, "_blank")
}}
>
<Link className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-gray-500 text-center">
<a
href={`/api/docs/scenarios/${channel}/${currentApiSettings.taskId}#examples`}
target="_blank"
className="text-blue-600 hover:underline"
rel="noreferrer"
>
PythonJava等多语言示例代码
</a>
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowApiDialog(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,236 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ChevronLeft, Search, Filter, RefreshCw, Plus } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
interface TrafficUser {
id: string
avatar: string
nickname: string
wechatId: string
phone: string
region: string
note: string
status: "pending" | "added" | "failed"
addTime: string
source: string
tags: string[]
}
export default function ChannelTrafficPage({
params,
searchParams,
}: {
params: { channel: string }
searchParams: { type?: string }
}) {
const router = useRouter()
const [users, setUsers] = useState<TrafficUser[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState(searchParams.type || "all")
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音",
kuaishou: "快手",
xiaohongshu: "小红书",
weibo: "微博",
}
return channelMap[channel] || channel
}
const channelName = getChannelName(params.channel)
useEffect(() => {
const fetchUsers = async () => {
// 模拟API调用
const mockUsers = Array.from({ length: 50 }, (_, i) => ({
id: `user-${i + 1}`,
avatar: "/placeholder.svg?height=40&width=40",
nickname: `用户${i + 1}`,
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () =>
Math.floor(Math.random() * 10),
).join("")}`,
region: ["广东", "浙江", "江苏", "北京", "上海"][Math.floor(Math.random() * 5)],
note: ["感兴趣", "需要了解", "想购买", "咨询价格"][Math.floor(Math.random() * 4)],
status: searchParams.type === "added" ? "added" : ["pending", "added", "failed"][Math.floor(Math.random() * 3)],
addTime: new Date(Date.now() - Math.random() * 86400000 * 7).toLocaleString(),
source: params.channel,
tags: ["意向客户", "高活跃度", "新用户"][Math.floor(Math.random() * 3)].split(" "),
}))
setUsers(mockUsers)
}
fetchUsers()
}, [params.channel, searchParams.type])
const filteredUsers = users.filter((user) => {
const matchesSearch =
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.phone.includes(searchQuery)
const matchesStatus = statusFilter === "all" || user.status === statusFilter
return matchesSearch && matchesStatus
})
const paginatedUsers = filteredUsers.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage)
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm 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-xl font-semibold text-blue-600">{channelName}</h1>
</div>
<Button
variant="default"
onClick={() => router.push(`/scenarios/${params.channel}/new`)}
className="flex items-center gap-1"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</header>
<div className="p-4 space-y-4">
<Card className="p-4">
<div className="space-y-4">
<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)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-between">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="added"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
{paginatedUsers.map((user) => (
<Card key={user.id} className="p-4">
<div className="flex items-start space-x-4">
<img
src={user.avatar || "/placeholder.svg"}
alt={user.nickname}
className="w-12 h-12 rounded-full"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="font-medium truncate">{user.nickname}</div>
<Badge
variant={
user.status === "added" ? "success" : user.status === "failed" ? "destructive" : "secondary"
}
>
{user.status === "added" ? "已添加" : user.status === "failed" ? "已失败" : "待处理"}
</Badge>
</div>
<div className="mt-1 text-sm text-gray-500">
<div>: {user.wechatId}</div>
<div>: {user.phone}</div>
<div>: {user.region}</div>
<div>: {user.addTime}</div>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{user.tags.map((tag, index) => (
<Badge key={index} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
</div>
</Card>
))}
</div>
{totalPages > 1 && (
<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>
</Card>
</div>
</div>
)
}