chore: 以本地为准,上传全部并替换 GitHub

This commit is contained in:
卡若
2026-02-03 11:36:53 +08:00
parent 1219166526
commit b404bf546e
131 changed files with 37618 additions and 3930 deletions

View File

@@ -1,345 +1,634 @@
"use client"
import { useState } from "react"
import { Target, Plus, Search, Users, Download, Share2, Trash2, Filter, BarChart3, PieChart } from "lucide-react"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } 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 { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Target,
Users,
ChevronRight,
ChevronLeft,
Check,
QrCode,
Globe,
MessageCircle,
Loader2,
RefreshCw,
Save,
ArrowLeft,
Eye,
Package,
Star,
TrendingUp,
MapPin,
Activity,
Zap,
Shield,
Crown,
Sparkles,
Filter,
Download,
Search,
} from "lucide-react"
interface CrowdPack {
// 流量池分类
interface PoolCategory {
id: string
name: string
icon: any
color: string
description: string
userCount: number
rules: string[]
createdAt: string
updatedAt: string
status: "active" | "expired" | "computing"
distribution: { label: string; value: number; color: string }[]
pools: Pool[]
}
const MOCK_CROWDS: CrowdPack[] = [
{
id: "1",
name: "高价值活跃用户",
description: "价值评分>80 AND 近7天活跃",
userCount: 125678901,
rules: ["价值评分 > 80", "最后登录 < 7天", "消费金额 > 1000"],
createdAt: "2025-12-10",
updatedAt: "2025-12-12",
status: "active",
distribution: [
{ label: "25-35岁", value: 45, color: "bg-blue-500" },
{ label: "35-45岁", value: 30, color: "bg-green-500" },
{ label: "其他", value: 25, color: "bg-gray-300" },
],
},
{
id: "2",
name: "流失预警用户",
description: "流失风险>0.7 AND 近30天无消费",
userCount: 34567890,
rules: ["流失风险 > 0.7", "最后消费 > 30天", "活跃度等级 = 低"],
createdAt: "2025-12-08",
updatedAt: "2025-12-12",
status: "active",
distribution: [
{ label: "一线城市", value: 35, color: "bg-purple-500" },
{ label: "二线城市", value: 40, color: "bg-orange-500" },
{ label: "其他", value: 25, color: "bg-gray-300" },
],
},
{
id: "3",
name: "新注册潜力用户",
description: "注册<30天 AND 有首购",
userCount: 8901234,
rules: ["注册时间 < 30天", "首购完成 = 是", "消费金额 > 100"],
createdAt: "2025-12-05",
updatedAt: "2025-12-11",
status: "computing",
distribution: [
{ label: "女性", value: 55, color: "bg-pink-500" },
{ label: "男性", value: 45, color: "bg-blue-500" },
],
},
{
id: "4",
name: "品牌忠诚用户",
description: "复购率>50% AND 品牌偏好明确",
userCount: 56789012,
rules: ["复购率 > 50%", "品牌偏好 != 空", "消费频次 > 3次/月"],
createdAt: "2025-12-01",
updatedAt: "2025-12-10",
status: "active",
distribution: [
{ label: "高端消费", value: 60, color: "bg-yellow-500" },
{ label: "中端消费", value: 30, color: "bg-green-500" },
{ label: "其他", value: 10, color: "bg-gray-300" },
],
},
]
// 流量池/标签类型
interface Pool {
id: string
name: string
type: string
count: number
avgScore?: number
description?: string
hotLevel?: number // 热度 1-5
}
const AVAILABLE_CONDITIONS = [
{ category: "用户属性", options: ["性别", "年龄段", "城市等级", "注册时间"] },
{ category: "行为标签", options: ["活跃度等级", "消费等级", "复购率", "最后登录", "最后消费"] },
{ category: "价值标签", options: ["价值评分", "RFM评分", "CLV预测值"] },
{ category: "风险标签", options: ["流失风险", "欺诈风险"] },
{ category: "AI标签", options: ["品牌偏好", "价格敏感度", "内容偏好"] },
]
// 用户类型
interface PoolUser {
id: string
phone: string
name: string
score: number
level: string
tags: string[]
province?: string
city?: string
createdAt: string
}
export default function CrowdSelectionPage() {
const [crowds, setCrowds] = useState<CrowdPack[]>(MOCK_CROWDS)
const [searchQuery, setSearchQuery] = useState("")
const [isCreating, setIsCreating] = useState(false)
const [selectedConditions, setSelectedConditions] = useState<string[]>([])
// 流量池分类定义
const POOL_CATEGORIES: { [key: string]: { name: string; icon: any; color: string; bgColor: string; description: string } } = {
value: {
name: '💎 价值分层',
icon: Crown,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
description: '按用户估值分数划分的流量池'
},
behavior: {
name: '⚡ 行为活跃',
icon: Activity,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
description: '按用户活跃程度划分'
},
region: {
name: '📍 地域分布',
icon: MapPin,
color: 'text-green-600',
bgColor: 'bg-green-50',
description: '按用户地理位置划分'
},
source: {
name: '📥 数据来源',
icon: QrCode,
color: 'text-orange-600',
bgColor: 'bg-orange-50',
description: '按数据接入渠道划分'
},
lifecycle: {
name: '🔄 生命周期',
icon: TrendingUp,
color: 'text-indigo-600',
bgColor: 'bg-indigo-50',
description: '按用户生命周期阶段划分'
},
}
export default function TrafficPoolPage() {
const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState('value')
const [pools, setPools] = useState<Pool[]>([])
const [categorizedPools, setCategorizedPools] = useState<{ [key: string]: Pool[] }>({})
const [selectedPool, setSelectedPool] = useState<Pool | null>(null)
const [users, setUsers] = useState<PoolUser[]>([])
const [totalUsers, setTotalUsers] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [showUserDialog, setShowUserDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [packageName, setPackageName] = useState('')
const [searchQuery, setSearchQuery] = useState('')
// 加载流量池数据
useEffect(() => {
loadPools()
}, [])
const loadPools = async () => {
setLoading(true)
try {
// 获取所有流量池数据
const [poolsRes, tagsRes, packagesRes] = await Promise.all([
fetch('/api/traffic-packages?action=pools'),
fetch('/api/tags'),
fetch('/api/traffic-packages')
])
const poolsData = await poolsRes.json()
const tagsData = await tagsRes.json()
const packagesData = await packagesRes.json()
const allPools: Pool[] = []
const categorized: { [key: string]: Pool[] } = {
value: [],
behavior: [],
region: [],
source: [],
lifecycle: []
}
// 处理流量池数据(价值分层)
if (poolsData.success && poolsData.pools) {
poolsData.pools.forEach((p: any) => {
const pool: Pool = {
id: p.id,
name: p.name,
type: 'value',
count: p.count,
avgScore: p.avgScore,
description: `估值分 ${p.minScore || 0} - ${p.maxScore || '∞'}`,
hotLevel: p.id === 'diamond' ? 5 : p.id === 'gold' ? 4 : p.id === 'silver' ? 3 : 2
}
allPools.push(pool)
categorized.value.push(pool)
})
}
// 处理标签数据
if (tagsData.success && tagsData.tags) {
tagsData.tags.forEach((t: any) => {
const pool: Pool = {
id: `tag_${t.id}`,
name: t.name,
type: t.category,
count: t.userCount,
description: t.description,
hotLevel: Math.min(5, Math.floor(t.userCount / 100000000) + 1)
}
allPools.push(pool)
// 按分类归类
if (t.category === 'value') {
categorized.value.push(pool)
} else if (t.category === 'behavior') {
categorized.behavior.push(pool)
} else if (t.category === 'region') {
categorized.region.push(pool)
} else if (t.category === 'channel') {
categorized.source.push(pool)
} else if (t.category === 'lifecycle') {
categorized.lifecycle.push(pool)
}
})
}
// 添加数据来源流量池
const sourceProjects = [
{ name: '存客宝用户', count: 216000, type: 'source', description: 'CRM系统用户数据' },
{ name: '点了码用户', count: 1000, type: 'source', description: '扫码营销用户' },
{ name: '微博用户', count: 216000000, type: 'source', description: '微博平台用户' },
{ name: 'QQ社交用户', count: 705000000, type: 'source', description: 'QQ平台用户' },
{ name: '京东用户', count: 142000000, type: 'source', description: '京东电商用户' },
{ name: '顺丰用户', count: 39200000, type: 'source', description: '快递地址数据' },
]
sourceProjects.forEach((s, i) => {
const pool: Pool = {
id: `source_${i}`,
name: s.name,
type: 'source',
count: s.count,
description: s.description,
hotLevel: Math.min(5, Math.floor(s.count / 100000000) + 1)
}
allPools.push(pool)
categorized.source.push(pool)
})
setPools(allPools)
setCategorizedPools(categorized)
} catch (error) {
console.error('加载流量池失败:', error)
} finally {
setLoading(false)
}
}
// 加载池内用户
const loadPoolUsers = async (pool: Pool, page: number = 1) => {
setLoading(true)
setSelectedPool(pool)
setCurrentPage(page)
setShowUserDialog(true)
try {
// 根据流量池类型构建查询
let apiUrl = `/api/portrait?action=users&page=${page}&limit=20`
// 如果是价值分层流量池,添加分数筛选
if (pool.type === 'value' && pool.id.startsWith('pool_')) {
// 根据池名确定分数范围
const scoreRanges: { [key: string]: { min?: number; max?: number } } = {
'diamond': { min: 3000 },
'gold': { min: 2000, max: 3000 },
'silver': { min: 1000, max: 2000 },
'bronze': { min: 500, max: 1000 },
'potential': { max: 500 }
}
// 解析pool id
}
const res = await fetch(apiUrl)
const data = await res.json()
if (data.success) {
setUsers(data.users || [])
setTotalUsers(data.total || pool.count)
}
} catch (error) {
console.error('加载用户失败:', error)
} finally {
setLoading(false)
}
}
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(1)}K`
return num.toString()
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
return num.toLocaleString()
}
const totalUsers = crowds.reduce((sum, c) => sum + c.userCount, 0)
// 保存为流量包
const savePackage = async () => {
if (!selectedPool) return
setLoading(true)
try {
await fetch('/api/traffic-packages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'create',
name: packageName || selectedPool.name,
poolId: selectedPool.id,
userCount: totalUsers
})
})
alert('流量包保存成功')
setShowSaveDialog(false)
setPackageName('')
} catch (error) {
console.error('保存失败:', error)
} finally {
setLoading(false)
}
}
// 获取热度星星
const getHotStars = (level: number = 1) => {
return Array(5).fill(0).map((_, i) => (
<Star
key={i}
className={`h-3 w-3 ${i < level ? 'text-yellow-400 fill-yellow-400' : 'text-gray-200'}`}
/>
))
}
// 筛选流量池
const getFilteredPools = (categoryPools: Pool[]) => {
if (!searchQuery) return categoryPools
const query = searchQuery.toLowerCase()
return categoryPools.filter(p =>
p.name.toLowerCase().includes(query) ||
p.description?.toLowerCase().includes(query)
)
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Button
onClick={() => setIsCreating(!isCreating)}
className="bg-gradient-to-r from-blue-500 to-purple-500 text-white"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{/* Create Panel */}
{isCreating && (
<Card className="border-none shadow-md bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<Input placeholder="例如:高价值潜在流失用户" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<Input placeholder="简要描述人群特征" />
</div>
<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-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索流量池..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-64 bg-white"
/>
</div>
<Button variant="outline" onClick={loadPools}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-4">
<label className="text-sm font-medium text-gray-700"></label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{AVAILABLE_CONDITIONS.map((group) => (
<div key={group.category} className="p-4 rounded-xl bg-gray-50">
<h4 className="font-medium text-gray-900 mb-3">{group.category}</h4>
<div className="space-y-2">
{group.options.map((option) => (
<div key={option} className="flex items-center gap-2">
<Checkbox
id={option}
checked={selectedConditions.includes(option)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedConditions([...selectedConditions, option])
} else {
setSelectedConditions(selectedConditions.filter((c) => c !== option))
}
}}
/>
<label htmlFor={option} className="text-sm text-gray-600">
{option}
</label>
</div>
))}
{/* 统计卡片 */}
<div className="grid grid-cols-5 gap-4">
{Object.entries(POOL_CATEGORIES).map(([key, cat]) => {
const categoryPools = categorizedPools[key] || []
const totalCount = categoryPools.reduce((sum, p) => sum + p.count, 0)
const Icon = cat.icon
return (
<Card
key={key}
className={`border-0 shadow-sm cursor-pointer transition-all hover:shadow-md ${
activeCategory === key ? 'ring-2 ring-purple-500' : ''
} ${cat.bgColor}`}
onClick={() => setActiveCategory(key)}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-white/80`}>
<Icon className={`h-5 w-5 ${cat.color}`} />
</div>
<div>
<div className="font-semibold text-gray-900">{cat.name.slice(2)}</div>
<div className="text-xs text-gray-500">{categoryPools.length} </div>
</div>
</div>
))}
</div>
</div>
<div className="mt-3 pt-2 border-t border-white/50">
<div className="text-xl font-bold text-gray-900">{formatNumber(totalCount)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</CardContent>
</Card>
)
})}
</div>
{selectedConditions.length > 0 && (
<div className="p-4 rounded-xl bg-blue-50">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900"></span>
<Badge className="bg-blue-100 text-blue-700">{selectedConditions.length}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{selectedConditions.map((cond) => (
<Badge key={cond} variant="secondary" className="bg-white">
{cond}
<button
onClick={() => setSelectedConditions(selectedConditions.filter((c) => c !== cond))}
className="ml-1 text-gray-400 hover:text-gray-600"
>
x
</button>
</Badge>
))}
</div>
{/* 流量池列表 */}
<Card className="border-0 shadow-lg bg-white/90">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">{POOL_CATEGORIES[activeCategory]?.name.slice(0, 2)}</span>
{POOL_CATEGORIES[activeCategory]?.name.slice(2)}
</CardTitle>
<Badge variant="outline">{getFilteredPools(categorizedPools[activeCategory] || []).length} </Badge>
</div>
<p className="text-sm text-gray-500">{POOL_CATEGORIES[activeCategory]?.description}</p>
</CardHeader>
<CardContent>
{loading && !showUserDialog ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
<span className="ml-3 text-gray-500">...</span>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{getFilteredPools(categorizedPools[activeCategory] || []).map(pool => (
<Card
key={pool.id}
className="border shadow-sm hover:shadow-lg transition-all cursor-pointer bg-white group"
onClick={() => loadPoolUsers(pool)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
{getHotStars(pool.hotLevel)}
</div>
<Badge variant="outline" className="text-xs">
{pool.type === 'value' ? '价值' : pool.type === 'behavior' ? '行为' : pool.type === 'region' ? '地域' : pool.type === 'source' ? '来源' : '周期'}
</Badge>
</div>
<h4 className="font-semibold text-gray-900 text-lg group-hover:text-purple-600 transition-colors">{pool.name}</h4>
{pool.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-1">{pool.description}</p>
)}
<div className="flex items-center justify-between mt-3 pt-2 border-t">
<div>
<div className="text-xl font-bold text-purple-600">{formatNumber(pool.count)}</div>
<div className="text-xs text-gray-500"></div>
</div>
{pool.avgScore && (
<div className="text-right">
<div className="text-lg font-semibold text-orange-500">{Math.round(pool.avgScore)}</div>
<div className="text-xs text-gray-500"></div>
</div>
)}
<ChevronRight className="h-5 w-5 text-gray-300 group-hover:text-purple-500 transition-colors" />
</div>
</CardContent>
</Card>
))}
{getFilteredPools(categorizedPools[activeCategory] || []).length === 0 && (
<div className="col-span-4 text-center py-12 text-gray-500">
<Target className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p></p>
</div>
)}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setIsCreating(false)}>
</Button>
<Button variant="outline"></Button>
<Button></Button>
</div>
</CardContent>
</Card>
)}
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
<Target className="w-4 h-4" />
</div>
<div className="text-2xl font-bold text-gray-900">{crowds.length}</div>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
<Users className="w-4 h-4" />
</div>
<div className="text-2xl font-bold text-gray-900">{formatNumber(totalUsers)}</div>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
<Filter className="w-4 h-4" />
</div>
<div className="text-2xl font-bold text-gray-900">
{AVAILABLE_CONDITIONS.reduce((sum, g) => sum + g.options.length, 0)}
</div>
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
<BarChart3 className="w-4 h-4" />
</div>
<div className="text-2xl font-bold text-blue-600">
{crowds.filter((c) => c.status === "computing").length}
</div>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="搜索人群包..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white/60"
/>
</div>
{/* Crowd List */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{crowds
.filter((c) => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
.map((crowd) => (
<Card key={crowd.id} className="border-none shadow-sm bg-white/60 backdrop-blur-sm hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900">{crowd.name}</h3>
<Badge
className={
crowd.status === "active"
? "bg-green-100 text-green-700"
: crowd.status === "computing"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-700"
}
>
{crowd.status === "active" ? "生效中" : crowd.status === "computing" ? "计算中" : "已过期"}
</Badge>
{/* 用户列表弹窗 */}
<Dialog open={showUserDialog} onOpenChange={setShowUserDialog}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-purple-600" />
{selectedPool?.name} -
</DialogTitle>
<DialogDescription>
{selectedPool?.description} · {formatNumber(totalUsers)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-4">
<Card className="border-0 shadow bg-gradient-to-r from-purple-50 to-blue-50">
<CardContent className="p-4">
<div className="text-sm text-gray-500"></div>
<div className="text-3xl font-bold text-purple-600">{formatNumber(totalUsers)}</div>
</CardContent>
</Card>
<Card className="border-0 shadow bg-white/80">
<CardContent className="p-4">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center gap-1 mt-1">
{getHotStars(selectedPool?.hotLevel || 1)}
</div>
<p className="text-sm text-gray-500">{crowd.description}</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">{formatNumber(crowd.userCount)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow bg-white/80">
<CardContent className="p-4">
<div className="text-sm text-gray-500"></div>
<Badge className="mt-1">{selectedPool?.type}</Badge>
</CardContent>
</Card>
</div>
<div className="space-y-3 mb-4">
<div className="text-sm text-gray-600">
<span className="font-medium"></span>
{crowd.rules.map((rule, i) => (
<Badge key={i} variant="secondary" className="ml-1 text-xs">
{rule}
</Badge>
))}
</div>
{/* 用户表格 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell className="font-mono">{user.phone}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>
<Badge variant={user.score > 2000 ? 'default' : 'secondary'}>
{user.score || '-'}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline">{user.level || '-'}</Badge>
</TableCell>
<TableCell className="text-gray-500">
{user.province} {user.city}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1 max-w-xs">
{(user.tags || []).slice(0, 2).map((tag, i) => (
<Badge key={i} variant="outline" className="text-xs">{tag}</Badge>
))}
{(user.tags || []).length > 2 && (
<Badge variant="outline" className="text-xs">+{user.tags.length - 2}</Badge>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* 分页和操作 */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1 || loading}
onClick={() => selectedPool && loadPoolUsers(selectedPool, currentPage - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={users.length < 20 || loading}
onClick={() => selectedPool && loadPoolUsers(selectedPool, currentPage + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowUserDialog(false)}>
</Button>
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => { setShowUserDialog(false); setShowSaveDialog(true); }}>
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Distribution */}
<div className="mb-4">
<div className="flex h-3 rounded-full overflow-hidden">
{crowd.distribution.map((d, i) => (
<div key={i} className={`${d.color}`} style={{ width: `${d.value}%` }} title={d.label} />
))}
</div>
<div className="flex justify-between mt-2">
{crowd.distribution.map((d, i) => (
<div key={i} className="flex items-center gap-1 text-xs text-gray-500">
<div className={`w-2 h-2 rounded-full ${d.color}`} />
<span>
{d.label} {d.value}%
</span>
</div>
))}
</div>
{/* 保存流量包弹窗 */}
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label></Label>
<Input
placeholder={selectedPool?.name}
value={packageName}
onChange={(e) => setPackageName(e.target.value)}
/>
</div>
<div className="p-4 rounded-lg bg-purple-50 space-y-2">
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">{selectedPool?.name}</span>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="text-xs text-gray-500"> {crowd.updatedAt}</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Share2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<PieChart className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-red-500">
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<Badge>{selectedPool?.type}</Badge>
</div>
</CardContent>
</Card>
))}
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="text-2xl font-bold text-purple-600">{formatNumber(totalUsers)}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSaveDialog(false)}></Button>
<Button onClick={savePackage} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Package className="h-4 w-4 mr-2" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
)

View File

@@ -1,12 +1,272 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import Link from "next/link"
import {
Tags,
Users,
Target,
TrendingUp,
ArrowUpRight,
Diamond,
Award,
Medal,
Shield,
Loader2,
Star,
Activity,
MapPin,
Clock,
Heart,
} from "lucide-react"
// 流量池配置
const TRAFFIC_POOLS = [
{ id: 'diamond', name: '钻石池', icon: Diamond, color: 'from-purple-500 to-pink-500', minScore: 80, desc: 'RFM≥80 顶级价值用户' },
{ id: 'gold', name: '黄金池', icon: Award, color: 'from-yellow-500 to-orange-500', minScore: 60, maxScore: 80, desc: 'RFM 60-80 高价值用户' },
{ id: 'silver', name: '白银池', icon: Medal, color: 'from-gray-400 to-gray-500', minScore: 40, maxScore: 60, desc: 'RFM 40-60 中等价值用户' },
{ id: 'bronze', name: '青铜池', icon: Shield, color: 'from-orange-400 to-orange-600', minScore: 20, maxScore: 40, desc: 'RFM 20-40 待培育用户' },
{ id: 'potential', name: '潜力池', icon: TrendingUp, color: 'from-blue-400 to-cyan-500', maxScore: 20, desc: 'RFM<20 潜在用户' },
]
// 标签分类
const TAG_CATEGORIES = [
{ id: 'value', name: '价值标签', icon: Star, color: 'bg-yellow-100 text-yellow-700', desc: 'S/A/B/C/D等级', count: 0 },
{ id: 'behavior', name: '行为标签', icon: Activity, color: 'bg-blue-100 text-blue-700', desc: '活跃度、互动频次', count: 0 },
{ id: 'region', name: '地域标签', icon: MapPin, color: 'bg-green-100 text-green-700', desc: '省份、城市、区域', count: 0 },
{ id: 'lifecycle', name: '生命周期', icon: Clock, color: 'bg-purple-100 text-purple-700', desc: '新用户、活跃、沉睡', count: 0 },
{ id: 'preference', name: '偏好标签', icon: Heart, color: 'bg-red-100 text-red-700', desc: '兴趣、消费偏好', count: 0 },
]
export default function TagPortraitPage() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [pools, setPools] = useState<any[]>([])
const [categories, setCategories] = useState(TAG_CATEGORIES)
const [stats, setStats] = useState({
totalTags: 0,
totalUsers: 0,
avgCoverage: 0
})
useEffect(() => {
router.replace("/tag-portrait/tags")
}, [router])
return null
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// 加载流量池数据
const poolsRes = await fetch('/api/traffic-packages?action=pools')
const poolsData = await poolsRes.json()
if (poolsData.success) {
setPools(poolsData.pools)
}
// 加载标签分类数据
const tagsRes = await fetch('/api/tags?action=categories')
const tagsData = await tagsRes.json()
if (tagsData.success) {
setCategories(tagsData.categories.map((c: any) => ({
...TAG_CATEGORIES.find(tc => tc.id === c.id) || {},
...c
})))
setStats({
totalTags: tagsData.categories.reduce((sum: number, c: any) => sum + (c.tagCount || 0), 0),
totalUsers: tagsData.categories.reduce((sum: number, c: any) => sum + (c.userCount || 0), 0),
avgCoverage: 82.3
})
}
} catch (error) {
console.error('加载数据失败:', error)
} finally {
setLoading(false)
}
}
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()
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<span className="ml-3 text-gray-500">...</span>
</div>
)
}
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 flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1">
·
</p>
</div>
</div>
{/* 统计概览 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-3xl font-bold text-gray-900">{stats.totalTags}</p>
</div>
<div className="w-14 h-14 rounded-2xl bg-blue-100 flex items-center justify-center">
<Tags className="h-7 w-7 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.totalUsers)}</p>
</div>
<div className="w-14 h-14 rounded-2xl bg-green-100 flex items-center justify-center">
<Users className="h-7 w-7 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-3xl font-bold text-gray-900">{stats.avgCoverage}%</p>
</div>
<div className="w-14 h-14 rounded-2xl bg-purple-100 flex items-center justify-center">
<Target className="h-7 w-7 text-purple-600" />
</div>
</div>
<Progress value={stats.avgCoverage} className="h-2 mt-3" />
</CardContent>
</Card>
</div>
{/* 流量池体系 */}
<Card className="border-none shadow-lg bg-white/80 backdrop-blur">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-purple-500" />
<Badge variant="outline" className="ml-2">RFM分层</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{TRAFFIC_POOLS.map((pool) => {
const poolData = pools.find(p => p.id === pool.id)
const PoolIcon = pool.icon
return (
<Link key={pool.id} href={`/tag-portrait/crowd?pool=${pool.id}`}>
<Card className={`cursor-pointer hover:shadow-lg transition-all duration-300 border-0 overflow-hidden group`}>
<div className={`h-2 bg-gradient-to-r ${pool.color}`} />
<CardContent className="p-4">
<div className={`w-12 h-12 rounded-xl bg-gradient-to-r ${pool.color} flex items-center justify-center mb-3 group-hover:scale-110 transition-transform`}>
<PoolIcon className="h-6 w-6 text-white" />
</div>
<h3 className="font-bold text-gray-900 mb-1">{pool.name}</h3>
<p className="text-xs text-gray-500 mb-3">{pool.desc}</p>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-gray-900">
{formatNumber(poolData?.count || 0)}
</span>
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-purple-500" />
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
<p className="text-xs text-gray-500 mt-4 text-center">
RFM
</p>
</CardContent>
</Card>
{/* 标签分类 */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{categories.map((category) => {
const CategoryIcon = category.icon || Tags
return (
<Link key={category.id} href={`/tag-portrait/tags?category=${category.id}`}>
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-white/70 group hover:-translate-y-1">
<CardContent className="p-5">
<div className={`w-12 h-12 rounded-xl ${category.color.split(' ')[0]} flex items-center justify-center mb-3 group-hover:scale-110 transition-transform`}>
<CategoryIcon className={`h-6 w-6 ${category.color.split(' ')[1]}`} />
</div>
<h3 className="font-bold text-gray-900 mb-1">{category.name}</h3>
<p className="text-xs text-gray-500 mb-3">{category.desc}</p>
<div className="flex items-center justify-between">
<Badge variant="secondary">{category.tagCount || category.count || 0} </Badge>
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-500" />
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
</div>
{/* 快捷入口 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Link href="/tag-portrait/tags">
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-r from-blue-500 to-indigo-500 text-white group">
<CardContent className="p-6 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold mb-1"></h3>
<p className="text-blue-100 text-sm"></p>
</div>
<Tags className="h-10 w-10 text-blue-200 group-hover:scale-110 transition-transform" />
</CardContent>
</Card>
</Link>
<Link href="/tag-portrait/portrait">
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-r from-green-500 to-emerald-500 text-white group">
<CardContent className="p-6 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold mb-1"></h3>
<p className="text-green-100 text-sm"></p>
</div>
<Users className="h-10 w-10 text-green-200 group-hover:scale-110 transition-transform" />
</CardContent>
</Card>
</Link>
<Link href="/tag-portrait/crowd">
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-r from-purple-500 to-pink-500 text-white group">
<CardContent className="p-6 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold mb-1"></h3>
<p className="text-purple-100 text-sm"></p>
</div>
<Target className="h-10 w-10 text-purple-200 group-hover:scale-110 transition-transform" />
</CardContent>
</Card>
</Link>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff