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:
v0
2025-08-08 07:00:12 +00:00
parent 4eed69520c
commit f0a6a364f2
85 changed files with 3318 additions and 6786 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}