Optimize user detail page for asset assessment and tag info. #VERCEL_SKIP Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
"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 { 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"
|
||
|
||
type ListItem = {
|
||
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
|
||
}
|
||
|
||
type Meta = {
|
||
tags: string[]
|
||
cities: string[]
|
||
personas: string[]
|
||
sources: string[]
|
||
}
|
||
|
||
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)
|
||
|
||
useEffect(() => {
|
||
fetch("/api/users?meta=tags")
|
||
.then((r) => r.json())
|
||
.then((res) => setMeta(res.data))
|
||
}, [])
|
||
|
||
const queryString = useMemo(() => {
|
||
const p = new URLSearchParams()
|
||
if (q) p.set("q", q)
|
||
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("page", "1")
|
||
p.set("pageSize", "50")
|
||
return p.toString()
|
||
}, [q, filters, rfmRange])
|
||
|
||
useEffect(() => {
|
||
fetch(`/api/users?${queryString}`)
|
||
.then((r) => r.json())
|
||
.then((res) => {
|
||
setItems(res.data.items)
|
||
setTotalValue(res.data.totalValue)
|
||
})
|
||
}, [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],
|
||
}))
|
||
|
||
const handleUserClick = (userId: string) => {
|
||
router.push(`/user-portrait/${userId}`)
|
||
}
|
||
|
||
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>
|
||
|
||
{/* 筛选区:清晰多维度 */}
|
||
<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)}
|
||
/>
|
||
</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>
|
||
|
||
{/* 移动端卡片 */}
|
||
<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>
|
||
<div className="mt-1 flex flex-wrap gap-1">
|
||
{u.persona.map((p) => (
|
||
<Badge key={p} variant="secondary" className="text-[10px]">
|
||
{p}
|
||
</Badge>
|
||
))}
|
||
</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>
|
||
</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>
|
||
)
|
||
}
|