Files
users/app/user-portrait/page.tsx
v0 afc77439bb feat: enhance user profile with detailed tags and asset evaluation
Optimize user detail page for asset assessment and tag info.

#VERCEL_SKIP

Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
2025-08-21 05:32:37 +00:00

304 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
import 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>
)
}