Align Sidebar & BottomNav menus, remove "Search", add user profile mock data, implement /api/users, add FilterDrawer, complete Section, ProfileHeader, MetricsRFM components Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
240 lines
9.8 KiB
TypeScript
240 lines
9.8 KiB
TypeScript
"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 { 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)
|
|
|
|
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 (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])
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
fetch("/api/users?meta=tags")
|
|
.then((r) => r.json())
|
|
.then((res: any) => setAllTags(res?.data?.tags ?? []))
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
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="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">
|
|
<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>
|
|
</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>
|
|
|
|
<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="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>
|
|
</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>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setIsAddingUser(false)}>取消</Button>
|
|
<Button onClick={handleAddUser}>添加用户</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|