存客宝 React
This commit is contained in:
99
Cunkebao/app/components/acquisition/AcquisitionPlanChart.tsx
Normal file
99
Cunkebao/app/components/acquisition/AcquisitionPlanChart.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid, Area, AreaChart, Legend } from "recharts"
|
||||
|
||||
interface AcquisitionPlanChartProps {
|
||||
data: { date: string; customers: number }[]
|
||||
}
|
||||
|
||||
export function AcquisitionPlanChart({ data }: AcquisitionPlanChartProps) {
|
||||
const [chartData, setChartData] = useState<any[]>([])
|
||||
|
||||
// 生成更真实的数据
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) return
|
||||
|
||||
// 使用获客计划中的获客数和添加数作为指标,模拟近7天数据
|
||||
const enhancedData = data.map((item) => {
|
||||
// 添加数通常是获客数的一定比例,这里使用70%-90%的随机比例
|
||||
const addRate = 0.7 + Math.random() * 0.2
|
||||
const addedCount = Math.round(item.customers * addRate)
|
||||
|
||||
return {
|
||||
date: item.date,
|
||||
获客数: item.customers,
|
||||
添加数: addedCount,
|
||||
}
|
||||
})
|
||||
|
||||
setChartData(enhancedData)
|
||||
}, [data])
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (!data || data.length === 0 || chartData.length === 0) {
|
||||
return <div className="h-[180px] flex items-center justify-center text-gray-400">暂无数据</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorCustomers" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorAdded" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} width={30} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||
border: "none",
|
||||
padding: "8px",
|
||||
}}
|
||||
labelStyle={{ fontWeight: "bold", marginBottom: "4px" }}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={36}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value) => <span style={{ color: "#6b7280", fontSize: "12px" }}>{value}</span>}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="获客数"
|
||||
name="获客数"
|
||||
stroke="#3b82f6"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCustomers)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 2 }}
|
||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="添加数"
|
||||
name="添加数"
|
||||
stroke="#8b5cf6"
|
||||
fillOpacity={0.5}
|
||||
fill="url(#colorAdded)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 2 }}
|
||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
||||
|
||||
interface DailyAcquisitionData {
|
||||
date: string
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
|
||||
interface DailyAcquisitionChartProps {
|
||||
data: DailyAcquisitionData[]
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function DailyAcquisitionChart({ data, height = 200 }: DailyAcquisitionChartProps) {
|
||||
return (
|
||||
<div className="w-full" style={{ height: `${height}px` }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="acquired"
|
||||
name="获客数量"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6" }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="added"
|
||||
name="添加成功"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#10b981" }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
75
Cunkebao/app/components/acquisition/DeviceTreeChart.tsx
Normal file
75
Cunkebao/app/components/acquisition/DeviceTreeChart.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
|
||||
|
||||
// 模拟数据
|
||||
const weeklyData = [
|
||||
{ day: "周一", 获客数: 12, 添加好友: 8 },
|
||||
{ day: "周二", 获客数: 18, 添加好友: 12 },
|
||||
{ day: "周三", 获客数: 15, 添加好友: 10 },
|
||||
{ day: "周四", 获客数: 25, 添加好友: 18 },
|
||||
{ day: "周五", 获客数: 30, 添加好友: 22 },
|
||||
{ day: "周六", 获客数: 18, 添加好友: 14 },
|
||||
{ day: "周日", 获客数: 15, 添加好友: 11 },
|
||||
]
|
||||
|
||||
const monthlyData = [
|
||||
{ day: "1月", 获客数: 120, 添加好友: 85 },
|
||||
{ day: "2月", 获客数: 180, 添加好友: 130 },
|
||||
{ day: "3月", 获客数: 150, 添加好友: 110 },
|
||||
{ day: "4月", 获客数: 250, 添加好友: 180 },
|
||||
{ day: "5月", 获客数: 300, 添加好友: 220 },
|
||||
{ day: "6月", 获客数: 280, 添加好友: 210 },
|
||||
]
|
||||
|
||||
export function DeviceTreeChart() {
|
||||
const [period, setPeriod] = useState("week")
|
||||
|
||||
const data = period === "week" ? weeklyData : monthlyData
|
||||
|
||||
return (
|
||||
<Card className="w-full mt-4">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">获客趋势</CardTitle>
|
||||
<Tabs defaultValue="week" value={period} onValueChange={setPeriod} className="h-9">
|
||||
<TabsList className="grid w-[180px] grid-cols-2">
|
||||
<TabsTrigger value="week">本周</TabsTrigger>
|
||||
<TabsTrigger value="month">本月</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="获客数"
|
||||
stroke="#4f46e5"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="添加好友"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronUp, MoreVertical, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { DailyAcquisitionChart } from "./DailyAcquisitionChart"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
|
||||
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 }[]
|
||||
dailyData?: { date: string; acquired: number; added: number }[]
|
||||
}
|
||||
|
||||
interface ExpandableAcquisitionCardProps {
|
||||
task: Task
|
||||
channel: string
|
||||
onCopy: (taskId: string) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onOpenSettings?: (taskId: string) => void
|
||||
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
|
||||
}
|
||||
|
||||
export function ExpandableAcquisitionCard({
|
||||
task,
|
||||
channel,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onOpenSettings,
|
||||
onStatusChange,
|
||||
}: ExpandableAcquisitionCardProps) {
|
||||
const router = useRouter()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
|
||||
const passRate = calculatePassRate(acquiredCount, addedCount)
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
router.push(`/scenarios/${channel}/edit/${taskId}`)
|
||||
}
|
||||
|
||||
const toggleTaskStatus = () => {
|
||||
if (onStatusChange) {
|
||||
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Card className="p-6 hover:shadow-lg transition-all bg-white/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||
<Badge
|
||||
variant={task.status === "running" ? "success" : "secondary"}
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
onClick={toggleTaskStatus}
|
||||
>
|
||||
{task.status === "running" ? "进行中" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative z-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => handleEdit(task.id)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopy(task.id)}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</DropdownMenuItem>
|
||||
{onOpenSettings && (
|
||||
<DropdownMenuItem onClick={() => onOpenSettings(task.id)}>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
计划接口
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onDelete(task.id)} className="text-red-600">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除计划
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<a href={`/scenarios/${channel}/devices`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
||||
<div className="text-2xl font-semibold">{deviceCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/acquired`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
||||
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/added`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
||||
<div className="text-2xl font-semibold">{addedCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
||||
<div className="text-2xl font-semibold">{passRate}%</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="h-48 bg-white rounded-lg p-4 mb-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={task.trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="customers"
|
||||
name="获客数"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6" }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>上次执行:{task.lastUpdated}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>下次执行:{task.nextExecutionTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{expanded && task.dailyData && (
|
||||
<div className="mt-4 bg-white p-6 rounded-lg shadow-sm">
|
||||
<h4 className="text-lg font-medium mb-4">每日获客数据</h4>
|
||||
<div className="h-64">
|
||||
<DailyAcquisitionChart data={task.dailyData} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)} className="text-gray-500">
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
展开
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 计算通过率
|
||||
function calculatePassRate(acquired: number, added: number) {
|
||||
if (acquired === 0) return 0
|
||||
return Math.round((added / acquired) * 100)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { TrafficTeamSettings } from "@/app/components/TrafficTeamSettings"
|
||||
|
||||
interface NewAcquisitionPlanFormProps {
|
||||
onSubmit: (data: any) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function NewAcquisitionPlanForm({ onSubmit, onCancel }: NewAcquisitionPlanFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
trafficTeams: [], // Initialize trafficTeams as an empty array
|
||||
})
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
const handleChange = (data: any) => {
|
||||
setFormData(data)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="steps-container">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="step-indicators flex justify-between mb-8">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`flex flex-col items-center ${currentStep >= step ? "text-primary" : "text-gray-400"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full mb-2 ${
|
||||
currentStep >= step ? "bg-primary text-white" : "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{step}
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{step === 1 && "基础设置"}
|
||||
{step === 2 && "好友设置"}
|
||||
{step === 3 && "消息设置"}
|
||||
{step === 4 && "流量标签"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">基本信息</h2>
|
||||
<div className="space-y-2">
|
||||
<Label>计划名称</Label>
|
||||
<Input
|
||||
placeholder="输入计划名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>计划描述</Label>
|
||||
<Input
|
||||
placeholder="输入计划描述"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<TrafficTeamSettings formData={formData} onChange={handleChange} />
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">创建计划</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
202
Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx
Normal file
202
Cunkebao/app/components/acquisition/ScenarioAcquisitionCard.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import type React from "react"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
|
||||
|
||||
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 ScenarioAcquisitionCardProps {
|
||||
task: Task
|
||||
channel: string
|
||||
onEdit: (taskId: string) => void
|
||||
onCopy: (taskId: string) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onOpenSettings?: (taskId: string) => void
|
||||
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
|
||||
}
|
||||
|
||||
export function ScenarioAcquisitionCard({
|
||||
task,
|
||||
channel,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onOpenSettings,
|
||||
onStatusChange,
|
||||
}: ScenarioAcquisitionCardProps) {
|
||||
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
|
||||
const passRate = calculatePassRate(acquiredCount, addedCount)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleStatusChange = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onStatusChange) {
|
||||
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onEdit(task.id)
|
||||
}
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onCopy(task.id)
|
||||
}
|
||||
|
||||
const handleOpenSettings = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
if (onOpenSettings) {
|
||||
onOpenSettings(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onDelete(task.id)
|
||||
}
|
||||
|
||||
const toggleMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(!menuOpen)
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||
<Badge
|
||||
variant={task.status === "running" ? "success" : "secondary"}
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
onClick={handleStatusChange}
|
||||
>
|
||||
{task.status === "running" ? "进行中" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative z-20" ref={menuRef}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑计划
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleOpenSettings}
|
||||
>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
计划接口
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除计划
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<a href={`/scenarios/${channel}/devices`} className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
||||
<div className="text-2xl font-semibold">{deviceCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/acquired`} className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
||||
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/added`} className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
||||
<div className="text-2xl font-semibold">{addedCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
||||
<div className="text-2xl font-semibold">{passRate}%</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>上次执行:{task.lastUpdated}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>下次执行:{task.nextExecutionTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 计算通过率
|
||||
function calculatePassRate(acquired: number, added: number) {
|
||||
if (acquired === 0) return 0
|
||||
return Math.round((added / acquired) * 100)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user