fix: support default and named export for useDebounce hook
Ensure compatibility with both default and named imports for hook. #VERCEL_SKIP Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
@@ -1,311 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft, User, Tag, TrendingUp, DollarSign, Calendar, Phone, Mail, MapPin } from "lucide-react"
|
||||
import { useParams } from "next/navigation"
|
||||
import MobileHeader from "@/app/components/MobileHeader"
|
||||
import BottomNav from "@/app/components/BottomNav"
|
||||
import Section from "@/components/user-portrait/mobile/section"
|
||||
import ProfileHeader from "@/components/user-portrait/mobile/profile-header"
|
||||
import MetricsRFM from "@/components/user-portrait/mobile/metrics-rfm"
|
||||
import InteractionsList from "@/components/user-portrait/mobile/interactions-list"
|
||||
import PurchaseHistory from "@/components/user-portrait/mobile/purchase-history"
|
||||
import WechatAccounts from "@/components/user-portrait/mobile/wechat-accounts"
|
||||
|
||||
type UserDetail = {
|
||||
type Detail = {
|
||||
id: string
|
||||
name: string
|
||||
avatar?: string
|
||||
email: string
|
||||
phone: string
|
||||
city: string
|
||||
store: string
|
||||
project: string
|
||||
team: string
|
||||
tags: string[]
|
||||
persona: string[]
|
||||
source: string
|
||||
recency: number
|
||||
frequency: number
|
||||
monetary: number
|
||||
rfmScore: number
|
||||
assetValue: number
|
||||
riskLevel: "低" | "中" | "高"
|
||||
lastActivity: string
|
||||
interactions: { id: string; type: string; time: string; note?: string }[]
|
||||
purchaseHistory: { id: string; amount: number; time: string; item: string }[]
|
||||
wechatAccounts: { id: string; nickname: string; avatar?: string }[]
|
||||
status: "活跃" | "沉睡" | "流失风险"
|
||||
detailedTags: {
|
||||
category: string
|
||||
tags: { name: string; confidence: number; source: string }[]
|
||||
}[]
|
||||
assetBreakdown: {
|
||||
category: string
|
||||
value: number
|
||||
percentage: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<UserDetail | null>(null)
|
||||
const [data, setData] = useState<Detail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) return
|
||||
setLoading(true)
|
||||
setTimeout(() => {
|
||||
setData({
|
||||
id: params.id,
|
||||
name: "张三",
|
||||
email: "zhangsan@example.com",
|
||||
phone: "138****8888",
|
||||
city: "厦门",
|
||||
store: "思明店",
|
||||
project: "贷款业务",
|
||||
team: "A组",
|
||||
tags: ["高价值客户", "活跃用户", "信用良好"],
|
||||
persona: ["高净值人群", "投资偏好保守"],
|
||||
source: "微信推广",
|
||||
recency: 85,
|
||||
frequency: 92,
|
||||
monetary: 88,
|
||||
rfmScore: 88,
|
||||
assetValue: 1250000,
|
||||
riskLevel: "低",
|
||||
lastActivity: "2024-01-15 14:30:00",
|
||||
status: "活跃",
|
||||
detailedTags: [
|
||||
{
|
||||
category: "行为标签",
|
||||
tags: [
|
||||
{ name: "高频访问", confidence: 95, source: "系统分析" },
|
||||
{ name: "深度浏览", confidence: 88, source: "行为追踪" },
|
||||
{ name: "主动咨询", confidence: 92, source: "客服记录" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "偏好标签",
|
||||
tags: [
|
||||
{ name: "理财产品", confidence: 90, source: "购买记录" },
|
||||
{ name: "保险产品", confidence: 75, source: "浏览记录" },
|
||||
{ name: "投资咨询", confidence: 85, source: "咨询记录" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "风险标签",
|
||||
tags: [
|
||||
{ name: "信用优良", confidence: 98, source: "征信报告" },
|
||||
{ name: "收入稳定", confidence: 92, source: "银行流水" },
|
||||
{ name: "资产充足", confidence: 88, source: "资产证明" },
|
||||
],
|
||||
},
|
||||
],
|
||||
assetBreakdown: [
|
||||
{ category: "客户生命周期价值", value: 450000, percentage: 36 },
|
||||
{ category: "交叉销售潜力", value: 320000, percentage: 26 },
|
||||
{ category: "推荐价值", value: 280000, percentage: 22 },
|
||||
{ category: "品牌忠诚度价值", value: 200000, percentage: 16 },
|
||||
],
|
||||
})
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
fetch(`/api/users?id=${params.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((res) => setData(res?.data ?? null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [params?.id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-4">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-12 bg-slate-200 rounded"></div>
|
||||
<div className="h-32 bg-slate-200 rounded"></div>
|
||||
<div className="h-48 bg-slate-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-4 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-slate-600 mb-4">未找到该用户</p>
|
||||
<Button onClick={() => router.back()}>返回</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="bg-white border-b px-4 py-3 flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="font-semibold">用户详细信息</h1>
|
||||
</div>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户详情" />
|
||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<ProfileHeader
|
||||
name={data.name}
|
||||
avatar={data.avatar}
|
||||
email={data.email}
|
||||
phone={data.phone}
|
||||
tags={data.tags}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto px-4 py-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
基本信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{data.name}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="h-3 w-3" />
|
||||
{data.phone}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
{data.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{data.city} · {data.store}
|
||||
</span>
|
||||
<span>
|
||||
{data.project} · {data.team}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={data.status === "活跃" ? "default" : "secondary"}>{data.status}</Badge>
|
||||
</div>
|
||||
<Section title="RFM 指标">
|
||||
<MetricsRFM
|
||||
recency={data.recency}
|
||||
frequency={data.frequency}
|
||||
monetary={data.monetary}
|
||||
rfmScore={data.rfmScore}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.persona.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Section title="互动记录">
|
||||
<InteractionsList items={data.interactions} />
|
||||
</Section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
用户资产评估
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">¥{data.assetValue.toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-600">总资产价值</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{data.rfmScore}</div>
|
||||
<div className="text-xs text-slate-600">RFM评分</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-2xl font-bold ${data.riskLevel === "低" ? "text-green-600" : data.riskLevel === "中" ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{data.riskLevel}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">风险等级</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{data.source}</div>
|
||||
<div className="text-xs text-slate-600">获客来源</div>
|
||||
</div>
|
||||
</div>
|
||||
<Section title="购买历史">
|
||||
<PurchaseHistory items={data.purchaseHistory} />
|
||||
</Section>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">资产价值构成</h4>
|
||||
{data.assetBreakdown.map((item) => (
|
||||
<div key={item.category} className="flex items-center justify-between">
|
||||
<span className="text-sm">{item.category}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-slate-200 rounded-full h-2">
|
||||
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${item.percentage}%` }}></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium">¥{item.value.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tag className="h-5 w-5" />
|
||||
详细标签信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data.detailedTags.map((category) => (
|
||||
<div key={category.category} className="space-y-2">
|
||||
<h4 className="font-medium text-sm text-slate-700">{category.category}</h4>
|
||||
<div className="grid gap-2">
|
||||
{category.tags.map((tag) => (
|
||||
<div key={tag.name} className="flex items-center justify-between p-2 bg-slate-50 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-600">来源: {tag.source}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-12 bg-slate-200 rounded-full h-1">
|
||||
<div className="bg-green-500 h-1 rounded-full" style={{ width: `${tag.confidence}%` }}></div>
|
||||
</div>
|
||||
<span className="text-xs font-medium">{tag.confidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
RFM详细指标
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold">{data.recency}</div>
|
||||
<div className="text-xs text-slate-600">最近性 (R)</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${data.recency}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold">{data.frequency}</div>
|
||||
<div className="text-xs text-slate-600">频率 (F)</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div className="bg-green-500 h-2 rounded-full" style={{ width: `${data.frequency}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold">{data.monetary}</div>
|
||||
<div className="text-xs text-slate-600">货币价值 (M)</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div className="bg-purple-500 h-2 rounded-full" style={{ width: `${data.monetary}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<div className="text-sm text-slate-600">最后活跃时间</div>
|
||||
<div className="flex items-center justify-center gap-1 mt-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span className="text-sm">{data.lastActivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Section title="绑定微信账号">
|
||||
<WechatAccounts accounts={data.wechatAccounts.map((w) => ({ id: w.id, nickname: w.nickname, avatar: w.avatar }))} />
|
||||
</Section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-red-600">
|
||||
未找到该用户
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,303 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Search, Filter, Users, TrendingUp } from "lucide-react"
|
||||
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 ListItem = {
|
||||
type User = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
tags: string[]
|
||||
rfmScore: number
|
||||
lastActiveAt: string
|
||||
city: string
|
||||
store: string
|
||||
project: string
|
||||
team: string
|
||||
persona: string[]
|
||||
source: string
|
||||
lastActivity: string
|
||||
status: "活跃" | "沉睡" | "已封禁"
|
||||
}
|
||||
|
||||
type Meta = {
|
||||
tags: string[]
|
||||
cities: string[]
|
||||
personas: string[]
|
||||
sources: string[]
|
||||
}
|
||||
type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } }
|
||||
|
||||
export default function UserPoolPage() {
|
||||
const router = useRouter()
|
||||
const [q, setQ] = useState("")
|
||||
const [rfmRange, setRfmRange] = useState<[number, number]>([0, 100])
|
||||
const [items, setItems] = useState<ListItem[]>([])
|
||||
const [meta, setMeta] = useState<Meta>({ tags: [], cities: [], personas: [], sources: [] })
|
||||
const [filters, setFilters] = useState({
|
||||
tags: [] as string[],
|
||||
status: [] as string[],
|
||||
city: [] as string[],
|
||||
persona: [] as string[],
|
||||
source: [] as string[],
|
||||
})
|
||||
const [totalValue, setTotalValue] = useState(0)
|
||||
export default function UserPortraitPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users?meta=tags")
|
||||
.then((r) => r.json())
|
||||
.then((res) => setMeta(res.data))
|
||||
}, [])
|
||||
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 queryString = useMemo(() => {
|
||||
const p = new URLSearchParams()
|
||||
if (q) p.set("q", q)
|
||||
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(","))
|
||||
if (filters.city.length) p.set("city", filters.city.join(","))
|
||||
if (filters.persona.length) p.set("persona", filters.persona.join(","))
|
||||
if (filters.source.length) p.set("source", filters.source.join(","))
|
||||
p.set("rfmMin", String(rfmRange[0]))
|
||||
p.set("rfmMax", String(rfmRange[1]))
|
||||
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()
|
||||
}, [q, filters, rfmRange])
|
||||
}, [searchQuery, filters])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res) => {
|
||||
setItems(res.data.items)
|
||||
setTotalValue(res.data.totalValue)
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [queryString])
|
||||
|
||||
const toggle = (key: keyof typeof filters, v: string) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[key]: prev[key].includes(v) ? prev[key].filter((x) => x !== v) : [...prev[key], v],
|
||||
}))
|
||||
useEffect(() => {
|
||||
fetch("/api/users?meta=tags")
|
||||
.then((r) => r.json())
|
||||
.then((res: any) => setAllTags(res?.data?.tags ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleUserClick = (userId: string) => {
|
||||
router.push(`/user-portrait/${userId}`)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-5 space-y-4">
|
||||
{/* 核心板块:用户估值概览 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
用户池 · 资产评估为核心
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">通过 RFM 和画像维度评估用户价值与分布,支持用户导入导出</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<Stat label="当前筛选用户数" value={`${items.length} 人`} />
|
||||
<Stat label="估值总额(≈)" value={`¥ ${totalValue.toLocaleString("zh-CN")}`} />
|
||||
<Stat label="RFM 范围" value={`${rfmRange[0]} - ${rfmRange[1]}`} />
|
||||
<Stat label="已选标签数" value={`${filters.tags.length}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户画像" />
|
||||
|
||||
{/* 筛选区:清晰多维度 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" /> 筛选条件
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索姓名/手机/邮箱/标签/画像标签"
|
||||
className="pl-8"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
<FilterRow title="RFM" right={<span className="text-xs text-muted-foreground">0~100</span>}>
|
||||
<Slider
|
||||
value={rfmRange}
|
||||
onValueChange={(v) => setRfmRange(v as [number, number])}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
</FilterRow>
|
||||
<FilterRow title="城市">
|
||||
<Chips options={meta.cities} actives={filters.city} onToggle={(v) => toggle("city", v)} />
|
||||
</FilterRow>
|
||||
<FilterRow title="标签">
|
||||
<Chips options={meta.tags} actives={filters.tags} onToggle={(v) => toggle("tags", v)} />
|
||||
</FilterRow>
|
||||
<FilterRow title="画像( Persona )">
|
||||
<Chips options={meta.personas} actives={filters.persona} onToggle={(v) => toggle("persona", v)} />
|
||||
</FilterRow>
|
||||
<FilterRow title="来源">
|
||||
<Chips options={meta.sources} actives={filters.source} onToggle={(v) => toggle("source", v)} />
|
||||
</FilterRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 列表 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span>用户池列表</span>
|
||||
<div className="flex gap-2">
|
||||
<button className="text-xs px-3 py-1 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100">
|
||||
导入用户
|
||||
</button>
|
||||
<button className="text-xs px-3 py-1 bg-green-50 text-green-600 rounded-md hover:bg-green-100">
|
||||
导出用户词
|
||||
</button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="hidden md:block rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>姓名</TableHead>
|
||||
<TableHead>城市/门店</TableHead>
|
||||
<TableHead>项目/团队</TableHead>
|
||||
<TableHead>Persona</TableHead>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead className="text-center">RFM</TableHead>
|
||||
<TableHead className="text-center">资产评估</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((u) => (
|
||||
<TableRow
|
||||
key={u.id}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => handleUserClick(u.id)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{u.name}
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.city} / {u.store}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.project} / {u.team}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.persona.map((p) => (
|
||||
<Badge key={p} variant="secondary" className="text-[10px]">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.tags.slice(0, 3).map((t) => (
|
||||
<Badge key={t} className="text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{u.rfmScore}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-500" />
|
||||
<span className="text-xs text-green-600">高价值</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="text-sm text-muted-foreground">共 {total} 人</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端卡片 */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{items.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="border rounded-md p-3 cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => handleUserClick(u.id)}
|
||||
>
|
||||
<div className="font-medium">{u.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{u.city} · {u.store} · RFM {u.rfmScore}
|
||||
<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>
|
||||
|
||||
<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)} />
|
||||
</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="mt-1 flex flex-wrap gap-1">
|
||||
{u.persona.map((p) => (
|
||||
<Badge key={p} variant="secondary" className="text-[10px]">
|
||||
{p}
|
||||
</Badge>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-500" />
|
||||
<span className="text-xs text-green-600">高价值用户</span>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<div className="text-sm text-muted-foreground py-6 text-center">标签管理将在接入数据字典后提供配置与统计</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ title, children, right }: { title: string; children: React.ReactNode; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">{title}</div>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chips({
|
||||
options,
|
||||
actives,
|
||||
onToggle,
|
||||
}: { options: string[]; actives: string[]; onToggle: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
onClick={() => onToggle(o)}
|
||||
className={`px-2 py-1 rounded-full text-xs border ${actives.includes(o) ? "bg-slate-900 text-white" : "bg-white hover:bg-slate-50"}`}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddingUser(false)}>取消</Button>
|
||||
<Button onClick={handleAddUser}>添加用户</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user