Files
users/app/data-market/packages/page.tsx

523 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}