refactor: restructure project into 5 core modules
Organize project by 5 core modules based on requirement docs. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
@@ -1,239 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import MobileHeader from "@/app/components/MobileHeader"
|
||||
import BottomNav from "@/app/components/BottomNav"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Search, Filter, Plus } from 'lucide-react'
|
||||
import FilterDrawer, { type FilterValues } from "@/components/user-portrait/filter-drawer"
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
tags: string[]
|
||||
rfmScore: number
|
||||
lastActivity: string
|
||||
status: "活跃" | "沉睡" | "已封禁"
|
||||
}
|
||||
|
||||
type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } }
|
||||
|
||||
export default function UserPortraitPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Users, Search, Tag, TrendingUp, Filter, UserCircle, Mail, Phone, MapPin, Calendar, Activity, Target } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
|
||||
export default function UserPortrait() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isAddingUser, setIsAddingUser] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ name: "", phone: "", email: "", tags: [] as string[] })
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [allTags, setAllTags] = useState<string[]>([])
|
||||
const [filters, setFilters] = useState<FilterValues>({ tags: [], status: [], rfm: [0, 100] })
|
||||
// 用户群体分布
|
||||
const userSegments = [
|
||||
{ name: "高价值用户", count: 128456700, percentage: 3.0, avgValue: 580, color: "purple" },
|
||||
{ name: "活跃用户", count: 1285670000, percentage: 30.0, avgValue: 280, color: "blue" },
|
||||
{ name: "沉默用户", count: 1714283500, percentage: 40.0, avgValue: 85, color: "yellow" },
|
||||
{ name: "流失预警", count: 857134000, percentage: 20.0, avgValue: 45, color: "orange" },
|
||||
{ name: "新增用户", count: 300125800, percentage: 7.0, avgValue: 120, color: "green" },
|
||||
]
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
const p = new URLSearchParams()
|
||||
if (searchQuery) p.set("q", searchQuery)
|
||||
if (filters.tags.length) p.set("tags", filters.tags.join(","))
|
||||
if (filters.status.length) p.set("status", filters.status.join(","))
|
||||
p.set("rfmMin", String(filters.rfm[0]))
|
||||
p.set("rfmMax", String(filters.rfm[1]))
|
||||
p.set("page", "1")
|
||||
p.set("pageSize", "50")
|
||||
return p.toString()
|
||||
}, [searchQuery, filters])
|
||||
// 标签分类统计
|
||||
const tagCategories = [
|
||||
{ name: "基础属性", count: 485, usage: 4285670000, coverage: 100 },
|
||||
{ name: "行为标签", count: 328, usage: 3256780000, coverage: 76 },
|
||||
{ name: "兴趣偏好", count: 256, usage: 2570000000, coverage: 60 },
|
||||
{ name: "消费能力", count: 89, usage: 1714283500, coverage: 40 },
|
||||
{ name: "自定义标签", count: 100, usage: 857134000, coverage: 20 },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [queryString])
|
||||
// 最近用户示例
|
||||
const recentUsers = [
|
||||
{
|
||||
id: "U001285",
|
||||
name: "张**",
|
||||
phone: "138****5678",
|
||||
level: "S",
|
||||
tags: ["高净值", "美妆爱好者", "VIP客户"],
|
||||
value: 5800,
|
||||
lastActive: "2小时前",
|
||||
},
|
||||
{
|
||||
id: "U002456",
|
||||
name: "李**",
|
||||
phone: "186****1234",
|
||||
level: "A",
|
||||
tags: ["活跃用户", "教育培训", "复购客户"],
|
||||
value: 3200,
|
||||
lastActive: "5小时前",
|
||||
},
|
||||
{
|
||||
id: "U003789",
|
||||
name: "王**",
|
||||
phone: "159****9876",
|
||||
level: "B",
|
||||
tags: ["潜力用户", "电商购物", "新客户"],
|
||||
value: 1800,
|
||||
lastActive: "1天前",
|
||||
},
|
||||
{
|
||||
id: "U004123",
|
||||
name: "赵**",
|
||||
phone: "177****5432",
|
||||
level: "C",
|
||||
tags: ["普通用户", "低频互动"],
|
||||
value: 850,
|
||||
lastActive: "3天前",
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users?meta=tags")
|
||||
.then((r) => r.json())
|
||||
.then((res: any) => setAllTags(res?.data?.tags ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
const resp = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newUser),
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (data?.success) {
|
||||
setIsAddingUser(false)
|
||||
setNewUser({ name: "", phone: "", email: "", tags: [] })
|
||||
// 触发刷新
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
const getLevelColor = (level: string) => {
|
||||
const colors = {
|
||||
S: "border-purple-500/50 text-purple-400 bg-purple-500/10",
|
||||
A: "border-blue-500/50 text-blue-400 bg-blue-500/10",
|
||||
B: "border-green-500/50 text-green-400 bg-green-500/10",
|
||||
C: "border-yellow-500/50 text-yellow-400 bg-yellow-500/10",
|
||||
D: "border-gray-500/50 text-gray-400 bg-gray-500/10",
|
||||
}
|
||||
return colors[level as keyof typeof colors] || colors.D
|
||||
}
|
||||
|
||||
const getSegmentColor = (color: string) => {
|
||||
const colors = {
|
||||
purple: "from-purple-500/20 to-purple-900/20 border-purple-500/30",
|
||||
blue: "from-blue-500/20 to-blue-900/20 border-blue-500/30",
|
||||
green: "from-green-500/20 to-green-900/20 border-green-500/30",
|
||||
yellow: "from-yellow-500/20 to-yellow-900/20 border-yellow-500/30",
|
||||
orange: "from-orange-500/20 to-orange-900/20 border-orange-500/30",
|
||||
}
|
||||
return colors[color as keyof typeof colors]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户画像" />
|
||||
<div className="min-h-screen bg-black text-white p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">用户画像</h1>
|
||||
<p className="text-gray-400">用户管理、标签体系与群体分析</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" className="bg-white/5 border-white/10 hover:bg-white/10">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
高级筛选
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-blue-500 to-purple-500">
|
||||
<Target className="w-4 h-4 mr-2" />
|
||||
创建用户群
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
||||
<div className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">用户画像</h1>
|
||||
<p className="text-sm text-muted-foreground">管理与分群</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">共 {total} 人</div>
|
||||
<Tabs defaultValue="segments" className="space-y-6">
|
||||
<TabsList className="bg-white/5 border border-white/10">
|
||||
<TabsTrigger value="segments">用户群体</TabsTrigger>
|
||||
<TabsTrigger value="tags">标签体系</TabsTrigger>
|
||||
<TabsTrigger value="users">用户列表</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 用户群体分布 */}
|
||||
<TabsContent value="segments" className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{userSegments.map((segment, index) => (
|
||||
<Card key={index} className={`bg-gradient-to-br ${getSegmentColor(segment.color)}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white text-lg">{segment.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl font-bold text-white">{formatNumber(segment.count)}</div>
|
||||
<div className="text-sm text-gray-300">占比 {segment.percentage}%</div>
|
||||
<div className="pt-2 border-t border-white/20">
|
||||
<div className="text-xs text-gray-300 mb-1">人均价值</div>
|
||||
<div className="text-lg font-semibold text-white">¥{segment.avgValue}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="users" className="data-[state=active]:bg-white">用户管理</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-white">标签管理</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* 用户增长趋势 */}
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
用户增长趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64 flex items-center justify-center text-gray-400">
|
||||
用户增长趋势图表区域
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input className="pl-8" placeholder="搜索用户…" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
||||
{/* 标签体系 */}
|
||||
<TabsContent value="tags" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{tagCategories.map((category, index) => (
|
||||
<Card key={index} className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
|
||||
<Tag className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{category.name}</h3>
|
||||
<p className="text-sm text-gray-400">{category.count} 个标签</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-blue-500/50 text-blue-400">
|
||||
覆盖率 {category.coverage}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setFilterOpen(true)}>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
筛选
|
||||
</Button>
|
||||
<Button onClick={() => setIsAddingUser(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">标签数量</div>
|
||||
<div className="text-xl font-bold text-white">{category.count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">使用用户</div>
|
||||
<div className="text-xl font-bold text-white">{formatNumber(category.usage)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">覆盖率</div>
|
||||
<div className="text-xl font-bold text-green-400">{category.coverage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id} className="border bg-white/70 backdrop-blur-md shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-12 gap-3 items-center">
|
||||
<div className="col-span-5">
|
||||
<Link href={`/user-portrait/${u.id}`} className="font-medium hover:underline">
|
||||
{u.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
最后活跃 {new Date(u.lastActivity).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
{/* 用户列表 */}
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索用户手机号、姓名、标签..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white/5 border-white/10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="bg-white/5 border-white/10 hover:bg-white/10">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 用户卡片列表 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{recentUsers.map((user) => (
|
||||
<Card key={user.id} className="bg-white/5 border-white/10 hover:bg-white/10 transition-colors cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-500">
|
||||
{user.name[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-lg font-semibold text-white">{user.name}</span>
|
||||
<Badge variant="outline" className={getLevelColor(user.level)}>
|
||||
{user.level}级
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
{user.phone}
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="text-sm">{u.phone}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.tags.slice(0, 2).map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-xs">{t}</Badge>
|
||||
))}
|
||||
{u.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{u.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-sm font-semibold">{u.rfmScore}</div>
|
||||
<div className="col-span-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
u.status === "活跃" ? "bg-green-100 text-green-700" :
|
||||
u.status === "沉睡" ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{u.status}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
{user.lastActive}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<div className="text-sm text-muted-foreground py-6 text-center">标签管理将在接入数据字典后提供配置与统计</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-400 mb-1">用户价值</div>
|
||||
<div className="text-2xl font-bold text-green-400">¥{user.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{user.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="border-blue-500/30 text-blue-400">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
|
||||
{/* 筛选抽屉 */}
|
||||
<FilterDrawer
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
allTags={allTags}
|
||||
value={filters}
|
||||
onApply={(v) => setFilters(v)}
|
||||
/>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={isAddingUser} onOpenChange={setIsAddingUser}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>添加新用户</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input id="name" value={newUser.name} onChange={(e) => setNewUser((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input id="phone" value={newUser.phone} onChange={(e) => setNewUser((p) => ({ ...p, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" type="email" value={newUser.email} onChange={(e) => setNewUser((p) => ({ ...p, email: e.target.value }))} />
|
||||
</div>
|
||||
{!!allTags.length && (
|
||||
<div className="space-y-2">
|
||||
<Label>用户标签</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-auto">
|
||||
{allTags.map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={newUser.tags.includes(t)}
|
||||
onCheckedChange={(ck) =>
|
||||
setNewUser((p) => ({ ...p, tags: ck ? [...p.tags, t] : p.tags.filter((x) => x !== t) }))
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddingUser(false)}>取消</Button>
|
||||
<Button onClick={handleAddUser}>添加用户</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user