523 lines
19 KiB
TypeScript
523 lines
19 KiB
TypeScript
"use client"
|
||
|
||
import { useState } from "react"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Checkbox } from "@/components/ui/checkbox"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import {
|
||
Package,
|
||
Plus,
|
||
Download,
|
||
Send,
|
||
Mail,
|
||
MessageSquare,
|
||
Users,
|
||
Calendar,
|
||
RefreshCw,
|
||
CheckCircle2,
|
||
Clock,
|
||
Target,
|
||
Loader2,
|
||
Settings,
|
||
Trash2,
|
||
Copy,
|
||
} from "lucide-react"
|
||
|
||
// 流量包接口
|
||
interface TrafficPackage {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
pool: string
|
||
userCount: number
|
||
criteria: string[]
|
||
createdAt: string
|
||
status: 'active' | 'expired' | 'processing'
|
||
downloadCount: number
|
||
lastSentAt?: string
|
||
sentTo?: string[]
|
||
}
|
||
|
||
// 发送目标
|
||
interface SendTarget {
|
||
type: 'email' | 'wechat' | 'feishu'
|
||
value: string
|
||
name: string
|
||
}
|
||
|
||
// 预定义流量包
|
||
const DEFAULT_PACKAGES: TrafficPackage[] = [
|
||
{
|
||
id: 'pkg_1',
|
||
name: '钻石用户包-2024Q1',
|
||
description: 'RFM≥90的高价值用户,适合高端产品推广',
|
||
pool: 'diamond',
|
||
userCount: 12500000,
|
||
criteria: ['RFM≥90', '月活跃≥20天', '消费≥10次'],
|
||
createdAt: '2024-01-15',
|
||
status: 'active',
|
||
downloadCount: 15,
|
||
lastSentAt: '2024-01-28',
|
||
sentTo: ['marketing@company.com', '飞书-营销群'],
|
||
},
|
||
{
|
||
id: 'pkg_2',
|
||
name: '厦门本地高价值用户',
|
||
description: '厦门地区A级以上用户',
|
||
pool: 'gold',
|
||
userCount: 850000,
|
||
criteria: ['地区=厦门', 'RFM≥80'],
|
||
createdAt: '2024-01-20',
|
||
status: 'active',
|
||
downloadCount: 8,
|
||
},
|
||
{
|
||
id: 'pkg_3',
|
||
name: '电商活跃用户包',
|
||
description: '京东、淘宝高频购买用户',
|
||
pool: 'silver',
|
||
userCount: 45000000,
|
||
criteria: ['电商消费≥5次/月', '客单价≥200'],
|
||
createdAt: '2024-01-22',
|
||
status: 'active',
|
||
downloadCount: 22,
|
||
lastSentAt: '2024-01-29',
|
||
sentTo: ['sales@company.com'],
|
||
},
|
||
{
|
||
id: 'pkg_4',
|
||
name: '沉默用户唤醒包',
|
||
description: '30天未活跃但曾有高价值行为的用户',
|
||
pool: 'potential',
|
||
userCount: 28000000,
|
||
criteria: ['30天未活跃', '历史RFM≥60'],
|
||
createdAt: '2024-01-25',
|
||
status: 'active',
|
||
downloadCount: 5,
|
||
},
|
||
]
|
||
|
||
// 预定义发送目标
|
||
const SAVED_TARGETS: SendTarget[] = [
|
||
{ type: 'email', value: 'marketing@company.com', name: '营销部邮箱' },
|
||
{ type: 'email', value: 'sales@company.com', name: '销售部邮箱' },
|
||
{ type: 'feishu', value: 'oc_xxx', name: '飞书-营销群' },
|
||
{ type: 'feishu', value: 'oc_yyy', name: '飞书-运营群' },
|
||
{ type: 'wechat', value: 'wxid_xxx', name: '企微-客户群' },
|
||
]
|
||
|
||
export default function PackagesPage() {
|
||
const [packages, setPackages] = useState<TrafficPackage[]>(DEFAULT_PACKAGES)
|
||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||
const [showSendDialog, setShowSendDialog] = useState(false)
|
||
const [selectedPackage, setSelectedPackage] = useState<TrafficPackage | null>(null)
|
||
const [sending, setSending] = useState(false)
|
||
const [selectedTargets, setSelectedTargets] = useState<string[]>([])
|
||
const [customEmail, setCustomEmail] = useState('')
|
||
|
||
const formatNumber = (num: number): string => {
|
||
if (num >= 1000000000) return `${(num / 1000000000).toFixed(2)}B`
|
||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
||
return num.toLocaleString()
|
||
}
|
||
|
||
const getPoolConfig = (pool: string) => {
|
||
switch (pool) {
|
||
case 'diamond': return { name: '钻石池', icon: '💎', color: 'from-blue-500 to-purple-600' }
|
||
case 'gold': return { name: '黄金池', icon: '🏆', color: 'from-yellow-400 to-orange-500' }
|
||
case 'silver': return { name: '白银池', icon: '🥈', color: 'from-gray-300 to-gray-500' }
|
||
case 'bronze': return { name: '青铜池', icon: '🥉', color: 'from-orange-300 to-orange-500' }
|
||
default: return { name: '潜力池', icon: '🌱', color: 'from-green-300 to-green-500' }
|
||
}
|
||
}
|
||
|
||
const getTargetIcon = (type: string) => {
|
||
switch (type) {
|
||
case 'email': return <Mail className="h-4 w-4" />
|
||
case 'feishu': return <span className="text-sm">🪶</span>
|
||
case 'wechat': return <MessageSquare className="h-4 w-4" />
|
||
default: return <Send className="h-4 w-4" />
|
||
}
|
||
}
|
||
|
||
const toggleTarget = (value: string) => {
|
||
setSelectedTargets(prev =>
|
||
prev.includes(value)
|
||
? prev.filter(t => t !== value)
|
||
: [...prev, value]
|
||
)
|
||
}
|
||
|
||
const openSendDialog = (pkg: TrafficPackage) => {
|
||
setSelectedPackage(pkg)
|
||
setSelectedTargets([])
|
||
setCustomEmail('')
|
||
setShowSendDialog(true)
|
||
}
|
||
|
||
// 下载流量包为CSV
|
||
const downloadPackage = async (pkg: TrafficPackage) => {
|
||
try {
|
||
// 从API获取用户数据
|
||
const res = await fetch(`/api/traffic-packages?action=export&packageId=${pkg.id}`)
|
||
const data = await res.json()
|
||
|
||
if (data.success && data.users) {
|
||
// 生成CSV内容
|
||
const headers = ['手机号', '姓名', '等级', '估值分', '标签']
|
||
const rows = data.users.map((u: any) => [
|
||
u.phone || '',
|
||
u.name || '',
|
||
u.level || '',
|
||
u.score || '',
|
||
(u.tags || []).join(';')
|
||
])
|
||
|
||
const csvContent = [
|
||
headers.join(','),
|
||
...rows.map((r: string[]) => r.join(','))
|
||
].join('\n')
|
||
|
||
// 创建下载链接
|
||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `${pkg.name}_${new Date().toISOString().split('T')[0]}.csv`
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
URL.revokeObjectURL(url)
|
||
|
||
// 更新下载次数
|
||
setPackages(prev => prev.map(p =>
|
||
p.id === pkg.id ? { ...p, downloadCount: p.downloadCount + 1 } : p
|
||
))
|
||
} else {
|
||
alert('导出失败:' + (data.error || '未知错误'))
|
||
}
|
||
} catch (error) {
|
||
console.error('下载失败:', error)
|
||
alert('下载失败,请重试')
|
||
}
|
||
}
|
||
|
||
const sendPackage = async () => {
|
||
if (!selectedPackage) return
|
||
setSending(true)
|
||
|
||
try {
|
||
// 调用API发送流量包
|
||
const targets = [...selectedTargets]
|
||
if (customEmail) targets.push(customEmail)
|
||
|
||
await fetch('/api/traffic-packages', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
action: 'send',
|
||
packageId: selectedPackage.id,
|
||
targets,
|
||
})
|
||
})
|
||
|
||
// 更新包状态
|
||
setPackages(packages.map(p => p.id === selectedPackage.id ? {
|
||
...p,
|
||
lastSentAt: new Date().toLocaleString(),
|
||
sentTo: targets,
|
||
} : p))
|
||
|
||
} catch (error) {
|
||
console.error('发送失败:', error)
|
||
} finally {
|
||
setSending(false)
|
||
setShowSendDialog(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||
<div className="p-6 space-y-6">
|
||
{/* 顶部标题 */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">流量包</h1>
|
||
<p className="text-sm text-gray-500 mt-1">管理和分发用户流量包</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline">
|
||
<RefreshCw className="h-4 w-4 mr-2" />
|
||
刷新
|
||
</Button>
|
||
<Button onClick={() => setShowCreateDialog(true)}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
创建流量包
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统计卡片 */}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
<Card className="border-0 shadow-sm bg-white/80">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-500">流量包总数</p>
|
||
<p className="text-2xl font-bold">{packages.length}</p>
|
||
</div>
|
||
<Package className="h-8 w-8 text-purple-500 opacity-50" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-0 shadow-sm bg-white/80">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-500">覆盖用户</p>
|
||
<p className="text-2xl font-bold">{formatNumber(packages.reduce((sum, p) => sum + p.userCount, 0))}</p>
|
||
</div>
|
||
<Users className="h-8 w-8 text-blue-500 opacity-50" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-0 shadow-sm bg-white/80">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-500">总下载次数</p>
|
||
<p className="text-2xl font-bold">{packages.reduce((sum, p) => sum + p.downloadCount, 0)}</p>
|
||
</div>
|
||
<Download className="h-8 w-8 text-green-500 opacity-50" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-0 shadow-sm bg-white/80">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-gray-500">本周发送</p>
|
||
<p className="text-2xl font-bold">12</p>
|
||
</div>
|
||
<Send className="h-8 w-8 text-orange-500 opacity-50" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 流量包列表 */}
|
||
<div className="space-y-3">
|
||
{packages.map(pkg => {
|
||
const poolConfig = getPoolConfig(pkg.pool)
|
||
|
||
return (
|
||
<Card key={pkg.id} className="border-0 shadow-sm bg-white/80 hover:shadow-md transition-all">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-start gap-4">
|
||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-r ${poolConfig.color} flex items-center justify-center text-2xl`}>
|
||
{poolConfig.icon}
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<h3 className="font-semibold text-gray-900">{pkg.name}</h3>
|
||
<Badge className="bg-green-100 text-green-700">
|
||
<CheckCircle2 className="h-3 w-3 mr-1" />可用
|
||
</Badge>
|
||
</div>
|
||
<p className="text-sm text-gray-500 mb-2">{pkg.description}</p>
|
||
<div className="flex flex-wrap gap-1 mb-2">
|
||
{pkg.criteria.map((c, i) => (
|
||
<Badge key={i} variant="outline" className="text-xs">{c}</Badge>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||
<span className="flex items-center gap-1">
|
||
<Users className="h-3 w-3" />
|
||
{formatNumber(pkg.userCount)} 用户
|
||
</span>
|
||
<span className="flex items-center gap-1">
|
||
<Calendar className="h-3 w-3" />
|
||
创建于 {pkg.createdAt}
|
||
</span>
|
||
<span className="flex items-center gap-1">
|
||
<Download className="h-3 w-3" />
|
||
下载 {pkg.downloadCount} 次
|
||
</span>
|
||
{pkg.lastSentAt && (
|
||
<span className="flex items-center gap-1">
|
||
<Send className="h-3 w-3" />
|
||
上次发送: {pkg.lastSentAt}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => downloadPackage(pkg)}>
|
||
<Download className="h-4 w-4 mr-1" />
|
||
下载
|
||
</Button>
|
||
<Button size="sm" onClick={() => openSendDialog(pkg)}>
|
||
<Send className="h-4 w-4 mr-1" />
|
||
发送
|
||
</Button>
|
||
<Button variant="ghost" size="sm">
|
||
<Settings className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 发送流量包弹窗 */}
|
||
<Dialog open={showSendDialog} onOpenChange={setShowSendDialog}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>发送流量包</DialogTitle>
|
||
<DialogDescription>选择发送目标,支持邮箱、微信群、飞书群</DialogDescription>
|
||
</DialogHeader>
|
||
{selectedPackage && (
|
||
<div className="space-y-4 py-4">
|
||
{/* 流量包信息 */}
|
||
<div className="p-3 rounded-lg bg-purple-50">
|
||
<div className="font-medium text-gray-900">{selectedPackage.name}</div>
|
||
<div className="text-sm text-gray-500">{formatNumber(selectedPackage.userCount)} 用户</div>
|
||
</div>
|
||
|
||
{/* 已保存的目标 */}
|
||
<div className="space-y-2">
|
||
<Label>选择发送目标</Label>
|
||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||
{SAVED_TARGETS.map(target => (
|
||
<div
|
||
key={target.value}
|
||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all ${
|
||
selectedTargets.includes(target.value)
|
||
? 'border-purple-500 bg-purple-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
onClick={() => toggleTarget(target.value)}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
{getTargetIcon(target.type)}
|
||
<div>
|
||
<div className="font-medium text-sm">{target.name}</div>
|
||
<div className="text-xs text-gray-500">{target.value}</div>
|
||
</div>
|
||
</div>
|
||
<Checkbox checked={selectedTargets.includes(target.value)} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 自定义邮箱 */}
|
||
<div className="space-y-2">
|
||
<Label>或输入自定义邮箱</Label>
|
||
<Input
|
||
type="email"
|
||
placeholder="example@email.com"
|
||
value={customEmail}
|
||
onChange={(e) => setCustomEmail(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* 发送说明 */}
|
||
<div className="p-3 rounded-lg bg-blue-50 text-sm text-blue-700">
|
||
<p>📌 发送说明:</p>
|
||
<ul className="list-disc list-inside text-xs mt-1 space-y-1">
|
||
<li>邮箱发送:将生成CSV附件发送到指定邮箱</li>
|
||
<li>飞书群:通过飞书机器人发送流量包链接</li>
|
||
<li>微信群:通过企微机器人发送流量包链接</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowSendDialog(false)}>取消</Button>
|
||
<Button
|
||
onClick={sendPackage}
|
||
disabled={sending || (selectedTargets.length === 0 && !customEmail)}
|
||
>
|
||
{sending ? (
|
||
<><Loader2 className="h-4 w-4 animate-spin mr-2" />发送中...</>
|
||
) : (
|
||
<><Send className="h-4 w-4 mr-2" />确认发送</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 创建流量包弹窗 */}
|
||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>创建流量包</DialogTitle>
|
||
<DialogDescription>从人群圈选创建流量包</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label>流量包名称</Label>
|
||
<Input placeholder="例如:高价值用户包-2024Q1" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>来源</Label>
|
||
<Select>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择来源" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="crowd">从人群圈选导入</SelectItem>
|
||
<SelectItem value="pool">从流量池导入</SelectItem>
|
||
<SelectItem value="tag">从标签筛选导入</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>描述</Label>
|
||
<Textarea placeholder="描述该流量包的用途..." />
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>取消</Button>
|
||
<Button onClick={() => {
|
||
setShowCreateDialog(false)
|
||
// 跳转到人群圈选页面
|
||
window.location.href = '/tag-portrait/crowd'
|
||
}}>
|
||
<Target className="h-4 w-4 mr-2" />
|
||
去圈选
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|