feat: 本次提交更新内容如下
场景获客列表搞定
This commit is contained in:
311
Cunkebao/app/traffic-pool/components/analytics-dashboard.tsx
Normal file
311
Cunkebao/app/traffic-pool/components/analytics-dashboard.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { BarChart3, TrendingUp, TrendingDown, Users, Star, Target, DollarSign, Percent } from "lucide-react"
|
||||
|
||||
interface AnalyticsData {
|
||||
totalUsers: number
|
||||
highValueUsers: number
|
||||
mediumValueUsers: number
|
||||
lowValueUsers: number
|
||||
duplicateUsers: number
|
||||
pendingUsers: number
|
||||
addedUsers: number
|
||||
failedUsers: number
|
||||
avgSpent: number
|
||||
conversionRate: number
|
||||
addSuccessRate: number
|
||||
duplicateRate: number
|
||||
dailyGrowth: number
|
||||
weeklyGrowth: number
|
||||
monthlyGrowth: number
|
||||
}
|
||||
|
||||
interface AnalyticsDashboardProps {
|
||||
data: AnalyticsData
|
||||
}
|
||||
|
||||
export function AnalyticsDashboard({ data }: AnalyticsDashboardProps) {
|
||||
// 计算百分比
|
||||
const highValuePercentage = (data.highValueUsers / data.totalUsers) * 100
|
||||
const mediumValuePercentage = (data.mediumValueUsers / data.totalUsers) * 100
|
||||
const lowValuePercentage = (data.lowValueUsers / data.totalUsers) * 100
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + "k"
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 格式化百分比
|
||||
const formatPercentage = (num: number) => {
|
||||
return num.toFixed(1) + "%"
|
||||
}
|
||||
|
||||
// 获取趋势图标和颜色
|
||||
const getTrendIcon = (value: number) => {
|
||||
if (value > 0) {
|
||||
return <TrendingUp className="h-4 w-4 text-green-500" />
|
||||
} else if (value < 0) {
|
||||
return <TrendingDown className="h-4 w-4 text-red-500" />
|
||||
}
|
||||
return <div className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const getTrendColor = (value: number) => {
|
||||
if (value > 0) return "text-green-600"
|
||||
if (value < 0) return "text-red-600"
|
||||
return "text-gray-600"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 核心指标卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 总用户数 */}
|
||||
<Card className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600">{formatNumber(data.totalUsers)}</div>
|
||||
<div className="text-xs text-gray-500">总用户数</div>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs">
|
||||
{getTrendIcon(data.dailyGrowth)}
|
||||
<span className={getTrendColor(data.dailyGrowth)}>
|
||||
{data.dailyGrowth > 0 ? "+" : ""}
|
||||
{data.dailyGrowth}% 日增长
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 高价值用户 */}
|
||||
<Card className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-600">{formatNumber(data.highValueUsers)}</div>
|
||||
<div className="text-xs text-gray-500">高价值用户</div>
|
||||
</div>
|
||||
<Star className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs">
|
||||
<span className="text-gray-600">占比 {formatPercentage(highValuePercentage)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 平均消费 */}
|
||||
<Card className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">¥{data.avgSpent}</div>
|
||||
<div className="text-xs text-gray-500">平均消费</div>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs">
|
||||
{getTrendIcon(data.weeklyGrowth)}
|
||||
<span className={getTrendColor(data.weeklyGrowth)}>
|
||||
{data.weeklyGrowth > 0 ? "+" : ""}
|
||||
{data.weeklyGrowth}% 周增长
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 转化率 */}
|
||||
<Card className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-purple-600">{formatPercentage(data.conversionRate)}</div>
|
||||
<div className="text-xs text-gray-500">转化率</div>
|
||||
</div>
|
||||
<Target className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs">
|
||||
{getTrendIcon(data.monthlyGrowth)}
|
||||
<span className={getTrendColor(data.monthlyGrowth)}>
|
||||
{data.monthlyGrowth > 0 ? "+" : ""}
|
||||
{data.monthlyGrowth}% 月增长
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 用户价值分布 */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<BarChart3 className="h-5 w-5 mr-2 text-gray-600" />
|
||||
<h3 className="text-sm font-medium">用户价值分布</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* 高价值用户 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-red-500 rounded mr-2"></div>
|
||||
<span>高价值用户</span>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{data.highValueUsers} ({formatPercentage(highValuePercentage)})
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={highValuePercentage} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 中价值用户 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded mr-2"></div>
|
||||
<span>中价值用户</span>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{data.mediumValueUsers} ({formatPercentage(mediumValuePercentage)})
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={mediumValuePercentage} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 低价值用户 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-gray-500 rounded mr-2"></div>
|
||||
<span>低价值用户</span>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{data.lowValueUsers} ({formatPercentage(lowValuePercentage)})
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={lowValuePercentage} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 添加效率分析 */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<Target className="h-5 w-5 mr-2 text-gray-600" />
|
||||
<h3 className="text-sm font-medium">添加效率分析</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">成功率</span>
|
||||
<span className="font-medium text-green-600">{formatPercentage(data.addSuccessRate)}</span>
|
||||
</div>
|
||||
<Progress value={data.addSuccessRate} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">重复率</span>
|
||||
<span className="font-medium text-orange-600">{formatPercentage(data.duplicateRate)}</span>
|
||||
</div>
|
||||
<Progress value={data.duplicateRate} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-600">{data.addedUsers}</div>
|
||||
<div className="text-xs text-gray-500">已添加</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-yellow-600">{data.pendingUsers}</div>
|
||||
<div className="text-xs text-gray-500">待添加</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-red-600">{data.failedUsers}</div>
|
||||
<div className="text-xs text-gray-500">添加失败</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 数据质量指标 */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<Percent className="h-5 w-5 mr-2 text-gray-600" />
|
||||
<h3 className="text-sm font-medium">数据质量指标</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">重复用户</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="bg-orange-50 text-orange-800">
|
||||
{data.duplicateUsers} 个
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{formatPercentage(data.duplicateRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">数据完整性</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-800">
|
||||
优秀
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">98.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">标签覆盖率</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-800">
|
||||
良好
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">85.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 增长趋势 */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<TrendingUp className="h-5 w-5 mr-2 text-gray-600" />
|
||||
<h3 className="text-sm font-medium">增长趋势</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center justify-center mb-1">
|
||||
{getTrendIcon(data.dailyGrowth)}
|
||||
<span className={`text-sm font-medium ml-1 ${getTrendColor(data.dailyGrowth)}`}>
|
||||
{data.dailyGrowth > 0 ? "+" : ""}
|
||||
{formatPercentage(data.dailyGrowth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">日增长</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center justify-center mb-1">
|
||||
{getTrendIcon(data.weeklyGrowth)}
|
||||
<span className={`text-sm font-medium ml-1 ${getTrendColor(data.weeklyGrowth)}`}>
|
||||
{data.weeklyGrowth > 0 ? "+" : ""}
|
||||
{formatPercentage(data.weeklyGrowth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">周增长</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center justify-center mb-1">
|
||||
{getTrendIcon(data.monthlyGrowth)}
|
||||
<span className={`text-sm font-medium ml-1 ${getTrendColor(data.monthlyGrowth)}`}>
|
||||
{data.monthlyGrowth > 0 ? "+" : ""}
|
||||
{formatPercentage(data.monthlyGrowth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">月增长</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,4 +51,3 @@ export function DateRangePicker({ className, value, onChange }: DateRangePickerP
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
309
Cunkebao/app/traffic-pool/components/multi-device-selector.tsx
Normal file
309
Cunkebao/app/traffic-pool/components/multi-device-selector.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Search, Smartphone, Wifi, WifiOff, Clock, Battery, MapPin, Users, X } from "lucide-react"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
status: "online" | "offline" | "busy"
|
||||
battery: number
|
||||
location: string
|
||||
wechatAccounts: number
|
||||
dailyAddLimit: number
|
||||
todayAdded: number
|
||||
lastActiveTime: string
|
||||
model: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface MultiDeviceSelectorProps {
|
||||
devices: Device[]
|
||||
selectedDevices: string[]
|
||||
onDeviceSelect: (deviceIds: string[]) => void
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MultiDeviceSelector({
|
||||
devices,
|
||||
selectedDevices,
|
||||
onDeviceSelect,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MultiDeviceSelectorProps) {
|
||||
const { toast } = useToast()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [tempSelectedDevices, setTempSelectedDevices] = useState<string[]>(selectedDevices)
|
||||
|
||||
// 过滤设备
|
||||
const filteredDevices = devices.filter(
|
||||
(device) =>
|
||||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.model.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = useCallback((deviceId: string, checked: boolean) => {
|
||||
setTempSelectedDevices((prev) => (checked ? [...prev, deviceId] : prev.filter((id) => id !== deviceId)))
|
||||
}, [])
|
||||
|
||||
// 处理全选
|
||||
const handleSelectAll = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
setTempSelectedDevices(filteredDevices.map((device) => device.id))
|
||||
} else {
|
||||
setTempSelectedDevices([])
|
||||
}
|
||||
},
|
||||
[filteredDevices],
|
||||
)
|
||||
|
||||
// 处理确认选择
|
||||
const handleConfirm = () => {
|
||||
onDeviceSelect(tempSelectedDevices)
|
||||
onOpenChange(false)
|
||||
toast({
|
||||
title: "设备选择成功",
|
||||
description: `已选择 ${tempSelectedDevices.length} 个设备`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
setTempSelectedDevices(selectedDevices)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// 获取设备状态图标
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return <Wifi className="h-4 w-4 text-green-500" />
|
||||
case "offline":
|
||||
return <WifiOff className="h-4 w-4 text-gray-500" />
|
||||
case "busy":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />
|
||||
default:
|
||||
return <WifiOff className="h-4 w-4 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "在线"
|
||||
case "offline":
|
||||
return "离线"
|
||||
case "busy":
|
||||
return "忙碌"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "bg-green-100 text-green-800"
|
||||
case "offline":
|
||||
return "bg-gray-100 text-gray-800"
|
||||
case "busy":
|
||||
return "bg-yellow-100 text-yellow-800"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800"
|
||||
}
|
||||
}
|
||||
|
||||
// 获取电池颜色
|
||||
const getBatteryColor = (battery: number) => {
|
||||
if (battery > 50) return "text-green-600"
|
||||
if (battery > 20) return "text-yellow-600"
|
||||
return "text-red-600"
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeString: string) => {
|
||||
const date = new Date(timeString)
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// 设备卡片组件
|
||||
const DeviceCard = ({ device }: { device: Device }) => {
|
||||
const isSelected = tempSelectedDevices.includes(device.id)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`p-4 cursor-pointer transition-all ${isSelected ? "border-blue-500 bg-blue-50" : "hover:shadow-md"}`}
|
||||
onClick={() => handleDeviceToggle(device.id, !isSelected)}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => handleDeviceToggle(device.id, checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 设备名称和状态 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Smartphone className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-medium">{device.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{getStatusIcon(device.status)}
|
||||
<Badge className={getStatusColor(device.status)} variant="outline">
|
||||
{getStatusText(device.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备信息 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-2">
|
||||
<div className="flex items-center">
|
||||
<Battery className={`h-3 w-3 mr-1 ${getBatteryColor(device.battery)}`} />
|
||||
<span>电量 {device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MapPin className="h-3 w-3 mr-1" />
|
||||
<span>{device.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
<span>{device.wechatAccounts} 个微信号</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
<span>{formatTime(device.lastActiveTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备型号和版本 */}
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
{device.model} • {device.version}
|
||||
</div>
|
||||
|
||||
{/* 添加限制信息 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">
|
||||
今日添加: {device.todayAdded}/{device.dailyAddLimit}
|
||||
</span>
|
||||
<div className="w-16 bg-gray-200 rounded-full h-1">
|
||||
<div
|
||||
className="bg-blue-500 h-1 rounded-full"
|
||||
style={{ width: `${(device.todayAdded / device.dailyAddLimit) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 搜索栏 */}
|
||||
<div className="relative">
|
||||
<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>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredDevices.length > 0 &&
|
||||
filteredDevices.every((device) => tempSelectedDevices.includes(device.id))
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
id="select-all-devices"
|
||||
/>
|
||||
<label htmlFor="select-all-devices" className="text-sm font-medium">
|
||||
全选当前页
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {tempSelectedDevices.length} / {filteredDevices.length} 个设备
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已选择设备标签 */}
|
||||
{tempSelectedDevices.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-blue-50 rounded-md">
|
||||
<span className="text-sm font-medium text-blue-800">已选择设备:</span>
|
||||
{tempSelectedDevices.map((deviceId) => {
|
||||
const device = devices.find((d) => d.id === deviceId)
|
||||
return device ? (
|
||||
<Badge
|
||||
key={deviceId}
|
||||
variant="outline"
|
||||
className="bg-white cursor-pointer"
|
||||
onClick={() => handleDeviceToggle(deviceId, false)}
|
||||
>
|
||||
{device.name}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设备列表 */}
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="grid gap-3">
|
||||
{filteredDevices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} />
|
||||
))}
|
||||
|
||||
{filteredDevices.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchQuery ? "未找到匹配的设备" : "暂无可用设备"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={tempSelectedDevices.length === 0}>
|
||||
确认选择 ({tempSelectedDevices.length})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
420
Cunkebao/app/traffic-pool/components/pool-management.tsx
Normal file
420
Cunkebao/app/traffic-pool/components/pool-management.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Plus, Edit, Trash2, Users, Calendar, Tag } from "lucide-react"
|
||||
|
||||
interface TrafficPool {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
userCount: number
|
||||
tags: string[]
|
||||
createdAt: string
|
||||
status: "active" | "inactive"
|
||||
priority: "high" | "medium" | "low"
|
||||
}
|
||||
|
||||
interface PoolManagementProps {
|
||||
pools: TrafficPool[]
|
||||
onPoolCreate: (pool: Omit<TrafficPool, "id" | "createdAt" | "userCount">) => void
|
||||
onPoolUpdate: (id: string, pool: Partial<TrafficPool>) => void
|
||||
onPoolDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function PoolManagement({ pools, onPoolCreate, onPoolUpdate, onPoolDelete }: PoolManagementProps) {
|
||||
const { toast } = useToast()
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [editingPool, setEditingPool] = useState<TrafficPool | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
tags: "",
|
||||
status: "active" as "active" | "inactive",
|
||||
priority: "medium" as "high" | "medium" | "low",
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
tags: "",
|
||||
status: "active",
|
||||
priority: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
// 处理创建流量池
|
||||
const handleCreate = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "请输入流量池名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newPool = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
tags: formData.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
status: formData.status,
|
||||
priority: formData.priority,
|
||||
}
|
||||
|
||||
onPoolCreate(newPool)
|
||||
setShowCreateDialog(false)
|
||||
resetForm()
|
||||
|
||||
toast({
|
||||
title: "创建成功",
|
||||
description: `流量池 "${newPool.name}" 已创建`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理编辑流量池
|
||||
const handleEdit = (pool: TrafficPool) => {
|
||||
setEditingPool(pool)
|
||||
setFormData({
|
||||
name: pool.name,
|
||||
description: pool.description,
|
||||
tags: pool.tags.join(", "),
|
||||
status: pool.status,
|
||||
priority: pool.priority,
|
||||
})
|
||||
setShowEditDialog(true)
|
||||
}
|
||||
|
||||
// 处理更新流量池
|
||||
const handleUpdate = () => {
|
||||
if (!editingPool || !formData.name.trim()) {
|
||||
toast({
|
||||
title: "请输入流量池名称",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const updatedPool = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
tags: formData.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
status: formData.status,
|
||||
priority: formData.priority,
|
||||
}
|
||||
|
||||
onPoolUpdate(editingPool.id, updatedPool)
|
||||
setShowEditDialog(false)
|
||||
setEditingPool(null)
|
||||
resetForm()
|
||||
|
||||
toast({
|
||||
title: "更新成功",
|
||||
description: `流量池 "${updatedPool.name}" 已更新`,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理删除流量池
|
||||
const handleDelete = (pool: TrafficPool) => {
|
||||
if (pool.userCount > 0) {
|
||||
toast({
|
||||
title: "无法删除",
|
||||
description: "该流量池中还有用户,请先清空用户后再删除",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (confirm(`确定要删除流量池 "${pool.name}" 吗?此操作不可恢复。`)) {
|
||||
onPoolDelete(pool.id)
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: `流量池 "${pool.name}" 已删除`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取优先级颜色
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "bg-red-100 text-red-800"
|
||||
case "medium":
|
||||
return "bg-blue-100 text-blue-800"
|
||||
case "low":
|
||||
return "bg-gray-100 text-gray-800"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800"
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-100 text-green-800"
|
||||
case "inactive":
|
||||
return "bg-gray-100 text-gray-800"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800"
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 头部操作栏 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">流量池管理</h3>
|
||||
<Button onClick={() => setShowCreateDialog(true)} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建流量池
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 流量池列表 */}
|
||||
<div className="grid gap-4">
|
||||
{pools.map((pool) => (
|
||||
<Card key={pool.id} className="p-4">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h4 className="font-medium text-lg">{pool.name}</h4>
|
||||
<Badge className={getStatusColor(pool.status)}>{pool.status === "active" ? "活跃" : "停用"}</Badge>
|
||||
<Badge className={getPriorityColor(pool.priority)}>
|
||||
{pool.priority === "high" ? "高优先级" : pool.priority === "medium" ? "中优先级" : "低优先级"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">{pool.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{pool.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
<Tag className="h-3 w-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(pool)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleDelete(pool)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 pt-3 border-t">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
<span>{pool.userCount} 个用户</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>创建于 {formatDate(pool.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{pools.length === 0 && (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="text-gray-500 mb-4">暂无流量池</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
创建第一个流量池
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 创建流量池对话框 */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建流量池</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">流量池名称 *</label>
|
||||
<Input
|
||||
placeholder="请输入流量池名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea
|
||||
placeholder="请输入流量池描述"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">标签</label>
|
||||
<Input
|
||||
placeholder="请输入标签,用逗号分隔"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
/>
|
||||
<div className="text-xs text-gray-500">例如:高价值,重要客户,优先添加</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">状态</label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: "active" | "inactive") => setFormData({ ...formData, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">活跃</SelectItem>
|
||||
<SelectItem value="inactive">停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">优先级</label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value: "high" | "medium" | "low") => setFormData({ ...formData, priority: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">高优先级</SelectItem>
|
||||
<SelectItem value="medium">中优先级</SelectItem>
|
||||
<SelectItem value="low">低优先级</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 编辑流量池对话框 */}
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑流量池</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">流量池名称 *</label>
|
||||
<Input
|
||||
placeholder="请输入流量池名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea
|
||||
placeholder="请输入流量池描述"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">标签</label>
|
||||
<Input
|
||||
placeholder="请输入标签,用逗号分隔"
|
||||
value={formData.tags}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
/>
|
||||
<div className="text-xs text-gray-500">例如:高价值,重要客户,优先添加</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">状态</label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: "active" | "inactive") => setFormData({ ...formData, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">活跃</SelectItem>
|
||||
<SelectItem value="inactive">停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">优先级</label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value: "high" | "medium" | "low") => setFormData({ ...formData, priority: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">高优先级</SelectItem>
|
||||
<SelectItem value="medium">中优先级</SelectItem>
|
||||
<SelectItem value="low">低优先级</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowEditDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleUpdate}>更新</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,4 +30,3 @@ export function PoolUsageChart({ deviceStats, poolLimit }: PoolUsageChartProps)
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user