feat: sync Sidebar and BottomNav, standardize user profile API
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>
This commit is contained in:
151
components/user-portrait/filter-drawer.tsx
Normal file
151
components/user-portrait/filter-drawer.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export type FilterValues = {
|
||||
tags: string[]
|
||||
status: Array<"活跃" | "沉睡" | "已封禁">
|
||||
rfm: [number, number]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
allTags: string[]
|
||||
value: FilterValues
|
||||
onApply: (v: FilterValues) => void
|
||||
}
|
||||
|
||||
export default function FilterDrawer({ open, onOpenChange, allTags, value, onApply }: Props) {
|
||||
const [local, setLocal] = useState<FilterValues>(value)
|
||||
|
||||
useEffect(() => setLocal(value), [value, open])
|
||||
|
||||
const toggleTag = (t: string, checked: boolean) => {
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
tags: checked ? Array.from(new Set([...prev.tags, t])) : prev.tags.filter((x) => x !== t),
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleStatus = (s: "活跃" | "沉睡" | "已封禁", checked: boolean) => {
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
status: checked ? Array.from(new Set([...prev.status, s])) : prev.status.filter((x) => x !== s),
|
||||
}))
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
onApply(local)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
const init: FilterValues = { tags: [], status: [], rfm: [0, 100] }
|
||||
setLocal(init)
|
||||
onApply(init)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 ${open ? "" : "pointer-events-none"} aria-modal`}
|
||||
role="dialog"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<aside
|
||||
className={`absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl transition-transform duration-300
|
||||
${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
aria-label="筛选"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">筛选</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} aria-label="关闭筛选">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-6 overflow-y-auto h-[calc(100%-120px)]">
|
||||
{/* RFM 区间 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">RFM 区间</h3>
|
||||
<div className="grid grid-cols-2 gap-2 items-center">
|
||||
<div>
|
||||
<Label htmlFor="rfmMin" className="text-xs">最小值</Label>
|
||||
<Input
|
||||
id="rfmMin"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[0]}
|
||||
onChange={(e) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value) || 0))
|
||||
setLocal((p) => ({ ...p, rfm: [Math.min(v, p.rfm[1]), p.rfm[1]] }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rfmMax" className="text-xs">最大值</Label>
|
||||
<Input
|
||||
id="rfmMax"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[1]}
|
||||
onChange={(e) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value) || 100))
|
||||
setLocal((p) => ({ ...p, rfm: [p.rfm[0], Math.max(v, p.rfm[0])] }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 状态 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">状态</h3>
|
||||
<div className="grid gap-2">
|
||||
{(["活跃", "沉睡", "已封禁"] as const).map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={local.status.includes(s)} onCheckedChange={(ck) => toggleStatus(s, Boolean(ck))} />
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 标签 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">标签</h3>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-56 overflow-auto">
|
||||
{allTags.map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={local.tags.includes(t)} onCheckedChange={(ck) => toggleTag(t, Boolean(ck))} />
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
))}
|
||||
{!allTags.length && <div className="text-xs text-muted-foreground col-span-2">暂无标签数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex items-center justify-between gap-2">
|
||||
<Button variant="outline" onClick={reset}>重置</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={apply}>应用</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
components/user-portrait/mobile/interactions-list.tsx
Normal file
27
components/user-portrait/mobile/interactions-list.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { MessageSquareMore } from 'lucide-react'
|
||||
|
||||
export type Interaction = { id: string; type: string; time: string; note?: string }
|
||||
|
||||
export default function InteractionsList({ items }: { items: Interaction[] }) {
|
||||
if (!items?.length) return <div className="text-sm text-muted-foreground">暂无互动记录</div>
|
||||
return (
|
||||
<ul className="divide-y">
|
||||
{items.map((i) => (
|
||||
<li key={i.id} className="py-2 flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<MessageSquareMore className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{i.type}</div>
|
||||
{!!i.note && <div className="text-sm text-slate-600">{i.note}</div>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(i.time).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
40
components/user-portrait/mobile/metrics-rfm.tsx
Normal file
40
components/user-portrait/mobile/metrics-rfm.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
export default function MetricsRFM({
|
||||
recency,
|
||||
frequency,
|
||||
monetary,
|
||||
rfmScore,
|
||||
}: {
|
||||
recency: number
|
||||
frequency: number
|
||||
monetary: number
|
||||
rfmScore: number
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Metric title="R" value={recency} hint="近期互动(天)" />
|
||||
<Metric title="F" value={frequency} hint="频次(月)" />
|
||||
<Metric title="M" value={monetary} hint="金额(¥)" />
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">RFM 得分</span>
|
||||
<span className="text-sm font-semibold">{rfmScore}</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, rfmScore)} className="h-2 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ title, value, hint }: { title: string; value: number; hint: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="text-xs text-muted-foreground">{hint}</div>
|
||||
<div className="text-xl font-semibold mt-1">{value}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{title}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
components/user-portrait/mobile/profile-header.tsx
Normal file
47
components/user-portrait/mobile/profile-header.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export default function ProfileHeader({
|
||||
name,
|
||||
avatar,
|
||||
email,
|
||||
phone,
|
||||
tags = [],
|
||||
}: {
|
||||
name: string
|
||||
avatar?: string
|
||||
email: string
|
||||
phone: string
|
||||
tags?: string[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative h-12 w-12 rounded-full overflow-hidden border bg-white">
|
||||
<Image
|
||||
src={avatar || "/placeholder.svg?height=48&width=48&query=user+avatar"}
|
||||
alt={`${name} 的头像`}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||
<div className="text-xs text-muted-foreground">{phone}</div>
|
||||
{!!tags.length && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{tags.slice(0, 3).map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 3 && <Badge variant="outline" className="text-[10px]">+{tags.length - 3}</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
components/user-portrait/mobile/purchase-history.tsx
Normal file
22
components/user-portrait/mobile/purchase-history.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
export type Purchase = { id: string; amount: number; time: string; item: string }
|
||||
|
||||
export default function PurchaseHistory({ items }: { items: Purchase[] }) {
|
||||
if (!items?.length) return <div className="text-sm text-muted-foreground">暂无购买记录</div>
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{items.map((o) => (
|
||||
<div key={o.id} className="flex items-center justify-between rounded-lg border p-3 bg-white">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{o.item}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(o.time).toLocaleDateString("zh-CN")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold">¥{o.amount.toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
components/user-portrait/mobile/section.tsx
Normal file
42
components/user-portrait/mobile/section.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function Section({
|
||||
title,
|
||||
subtitle,
|
||||
count,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
count?: number
|
||||
defaultOpen?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<section className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{typeof count === "number" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100">{count}</span>
|
||||
)}
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform", open ? "rotate-180" : "")} />
|
||||
</div>
|
||||
</button>
|
||||
{open && <div className="mt-3">{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
30
components/user-portrait/mobile/wechat-accounts.tsx
Normal file
30
components/user-portrait/mobile/wechat-accounts.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
|
||||
export type Wx = { id: string; nickname: string; avatar?: string }
|
||||
|
||||
export default function WechatAccounts({ accounts }: { accounts: Wx[] }) {
|
||||
if (!accounts?.length) return <div className="text-sm text-muted-foreground">未绑定微信账号</div>
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{accounts.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 rounded-lg border p-3 bg-white">
|
||||
<div className="relative h-10 w-10 rounded-full overflow-hidden border">
|
||||
<Image
|
||||
src={a.avatar || "/placeholder.svg?height=40&width=40&query=weChat+avatar"}
|
||||
alt={a.nickname}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{a.nickname}</div>
|
||||
<div className="text-xs text-muted-foreground">已同步</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user