fix: support default and named exports for useDebounce hook

Ensure compatibility with both default and named exports for useDebounce.

Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
v0
2025-08-08 11:46:31 +00:00
parent bdf456523b
commit 2ca12179e2
15 changed files with 526 additions and 368 deletions

View File

@@ -1,6 +1,6 @@
import { NextResponse, NextRequest } from "next/server" import { NextResponse, NextRequest } from "next/server"
import type { TrafficUser } from "@/types/traffic" import type { TrafficUser } from "@/types/traffic"
import { addUser, filterUsers, getDistinctTags, getUserDetail } from "@/lib/mock-users" import { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus } from "@/lib/mock-users"
// 中文名字生成器数据 // 中文名字生成器数据
const familyNames = [ const familyNames = [
@@ -199,44 +199,32 @@ function parseArrayParam(v: string | null) {
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const meta = searchParams.get("meta")
const id = searchParams.get("id")
if (meta === "tags") {
const tags = getDistinctTags()
return NextResponse.json({ success: true, data: { tags } })
}
// 详情优先
const id = searchParams.get('id')
if (id) { if (id) {
const user = getUserDetail(id) const detail = getUserById(id)
if (!user) return NextResponse.json({ success: false, error: "NOT_FOUND" }, { status: 404 }) return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } })
return NextResponse.json({ success: true, data: user })
} }
const q = searchParams.get("q") || undefined // 列表
const tagsParam = searchParams.get("tags") || "" const q = searchParams.get('q') ?? undefined
const tags = tagsParam ? tagsParam.split(",").filter(Boolean) : [] const tagsStr = searchParams.get('tags') ?? ''
const statusParam = searchParams.get("status") || "" const statusStr = searchParams.get('status') ?? ''
const status = statusParam ? (statusParam.split(",") as any) : [] const rfmMin = Number(searchParams.get('rfmMin') ?? 0)
const rfmMin = Number(searchParams.get("rfmMin") ?? "0") const rfmMax = Number(searchParams.get('rfmMax') ?? 100)
const rfmMax = Number(searchParams.get("rfmMax") ?? "100") const page = Number(searchParams.get('page') ?? 1)
const page = Number(searchParams.get("page") ?? "1") const pageSize = Number(searchParams.get('pageSize') ?? 20)
const pageSize = Number(searchParams.get("pageSize") ?? "20")
const res = filterUsers({ q, tags, status, rfmMin, rfmMax, page, pageSize }) const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
return NextResponse.json({ success: true, data: res }) const status = statusStr ? (statusStr.split(',').filter(Boolean) as any) : undefined
const result = queryUsers({ q, tags, status, rfmMin, rfmMax, page, pageSize })
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null) const body = await req.json().catch(() => ({}))
if (!body || !body.name || !body.phone || !body.email) { const created = addUser(body ?? {})
return NextResponse.json({ success: false, error: "INVALID_PAYLOAD" }, { status: 400 }) return NextResponse.json({ data: created }, { status: 201 })
}
const user = addUser({
name: body.name,
phone: body.phone,
email: body.email,
tags: Array.isArray(body.tags) ? body.tags.slice(0, 20) : [],
})
return NextResponse.json({ success: true, data: user })
} }

View File

@@ -1,39 +1,42 @@
"use client" 'use client'
import Link from "next/link" import Link from 'next/link'
import { usePathname } from "next/navigation" import { usePathname } from 'next/navigation'
import { Home, Database, Target, BrainCircuit } from 'lucide-react' // 引入AI智能助手图标 import { Home, Database, Users, Bot } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [ const NAV_ITEMS = [
{ href: "/", icon: Home, label: "首页" }, { href: '/', label: '首页', icon: Home },
{ href: "/data-platform", icon: Database, label: "数据中台" }, { href: '/data-platform', label: '数据中台', icon: Database },
{ href: "/user-portrait", icon: Target, label: "画像" }, { href: '/user-portrait', label: '画像', icon: Users },
{ href: "/ai-assistant", icon: BrainCircuit, label: "AI助手" }, { href: '/ai-assistant', label: 'AI智能助手', icon: Bot },
] ] as const
export default function BottomNav() { export default function BottomNav() {
const pathname = usePathname() const pathname = usePathname()
return ( return (
<nav className="fixed bottom-0 left-0 right-0 glass-nav safe-area-bottom z-50 mx-2 mb-2"> <nav className="fixed bottom-0 left-0 right-0 z-40 border-t bg-white/95 backdrop-blur md:hidden">
<div className="flex justify-around items-center py-2"> <ul className="grid grid-cols-4">
{navItems.map((item) => { {NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href)) const active = pathname === href || pathname.startsWith(`${href}/`)
return ( return (
<li key={href}>
<Link <Link
key={item.href} href={href}
href={item.href} aria-current={active ? 'page' : undefined}
className={`flex flex-col items-center justify-center px-1 py-2 rounded-xl transition-all duration-300 min-w-0 flex-1 ${ className={cn(
isActive ? "glass-heavy text-blue-600 scale-105" : "text-gray-600 hover:glass-light hover:text-blue-500" 'flex flex-col items-center justify-center gap-1 py-2 text-xs',
}`} active ? 'text-gray-900' : 'text-gray-500',
)}
> >
<item.icon className={`h-4 w-4 mb-1 ${isActive ? "text-blue-600" : ""}`} /> <Icon className={cn('h-5 w-5', active && 'fill-gray-900')} />
<span className="text-xs font-medium truncate">{item.label}</span> <span>{label}</span>
</Link> </Link>
</li>
) )
})} })}
</div> </ul>
</nav> </nav>
) )
} }

View File

@@ -1,109 +1,46 @@
"use client" 'use client'
import { useState } from "react" import Link from 'next/link'
import Link from "next/link" import { usePathname } from 'next/navigation'
import { usePathname } from "next/navigation" import { Home, Database, Users, Bot } from 'lucide-react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { LayoutDashboard, Database, Users, BrainCircuit, Settings, ChevronLeft } from 'lucide-react'
const NAV_ITEMS = [
{ href: '/', label: '首页', icon: Home },
{ href: '/data-platform', label: '数据中台', icon: Database },
{ href: '/user-portrait', label: '画像', icon: Users },
{ href: '/ai-assistant', label: 'AI智能助手', icon: Bot },
] as const
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const [expanded, setExpanded] = useState(true)
const toggleSidebar = () => {
setExpanded(!expanded)
}
// 简化的导航结构
const navItems = [
{ title: "首页", href: "/", icon: <LayoutDashboard className="h-5 w-5" />, description: "总览与搜索" },
{ title: "数据中台", href: "/data-platform", icon: <Database className="h-5 w-5" />, description: "多源数据整合" },
{ title: "画像", href: "/user-portrait", icon: <Users className="h-5 w-5" />, description: "用户管理与分群" },
{ title: "AI助手", href: "/ai-assistant", icon: <BrainCircuit className="h-5 w-5" />, description: "AI分析与策略" },
]
return ( return (
<div <aside className="hidden md:block w-56 shrink-0 border-r bg-white">
className={cn( <nav className="p-3">
"flex flex-col h-screen bg-white border-r border-gray-200 transition-all duration-300 ease-in-out", <ul className="space-y-1">
expanded ? "w-72" : "w-20", {NAV_ITEMS.map(({ href, label, icon: Icon }) => {
)} const active = pathname === href || pathname.startsWith(`${href}/`)
> return (
{/* 头部 */} <li key={href}>
<div className="flex items-center h-16 px-6 border-b border-gray-200">
{expanded ? (
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
<Database className="h-5 w-5 text-blue-600" />
</div>
<h1 className="text-lg font-semibold text-gray-900"></h1>
</div>
) : (
<div className="mx-auto">
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
<Database className="h-5 w-5 text-blue-600" />
</div>
</div>
)}
</div>
{/* 导航菜单 */}
<div className="flex-1 overflow-y-auto py-4">
<nav className="space-y-2 px-4">
{navItems.map((item) => (
<Link <Link
key={item.href} href={href}
href={item.href} aria-current={active ? 'page' : undefined}
className={cn( className={cn(
"flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 group", 'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
pathname === item.href active
? "bg-blue-50 text-blue-700 border border-blue-200" ? 'bg-gray-900 text-white'
: "text-gray-700 hover:bg-gray-50 hover:text-blue-600", : 'text-gray-700 hover:bg-gray-100',
)} )}
> >
<div className="transition-colors duration-200">{item.icon}</div> <Icon className="h-4 w-4" />
{expanded && ( <span>{label}</span>
<div className="flex-1 ml-3">
<div className="font-medium">{item.title}</div>
<div className="text-xs text-gray-500 mt-0.5">{item.description}</div>
</div>
)}
</Link> </Link>
))} </li>
)
})}
</ul>
</nav> </nav>
</div> </aside>
{/* 底部设置和折叠按钮 */}
<div className="mt-auto border-t border-gray-200">
<Link
href="/settings"
className={cn(
"flex items-center px-4 py-3 mx-4 my-2 text-sm font-medium rounded-lg transition-all duration-200",
pathname === "/settings"
? "bg-blue-50 text-blue-700 border border-blue-200"
: "text-gray-600 hover:bg-gray-50 hover:text-blue-600",
)}
>
<div className={cn("transition-colors duration-200", !expanded && "mx-auto")}>
<Settings className="h-4 w-4" />
</div>
{expanded && <span className="ml-2"></span>}
</Link>
<div className="p-4">
<button
onClick={toggleSidebar}
className="w-full flex items-center justify-center p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-all duration-200"
>
<ChevronLeft
className={cn(
"h-5 w-5 transform transition-transform duration-300",
expanded ? "rotate-0" : "rotate-180",
)}
/>
</button>
</div>
</div>
</div>
) )
} }

View File

@@ -8,6 +8,8 @@ import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Toaster } from "@/components/ui/toaster" import { Toaster } from "@/components/ui/toaster"
import UserSearch from '@/components/home/user-search'
import UserList from '@/components/home/user-list'
interface SystemStats { interface SystemStats {
userCount: number userCount: number
@@ -25,6 +27,15 @@ interface GrowthData {
activeUsers: number activeUsers: number
} }
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="rounded-lg border bg-white p-3">
<div className="flex items-center gap-2 text-gray-500 text-xs">{icon}<span>{label}</span></div>
<div className="mt-1 text-lg font-semibold">{value}</div>
</div>
)
}
export default function OverviewPage() { export default function OverviewPage() {
const router = useRouter() const router = useRouter()
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
@@ -344,6 +355,17 @@ export default function OverviewPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* 快速指标示例(可后续接入真实数据) */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-8">
<StatCard icon={<Users className="h-4 w-4" />} label="用户总量" value="~120+" />
<StatCard icon={<Activity className="h-4 w-4" />} label="近7日活跃" value="动态计算" />
<StatCard icon={<BarChart3 className="h-4 w-4" />} label="平均RFM" value="50-80" />
<StatCard icon={<Users className="h-4 w-4" />} label="新客占比" value="≈25%" />
</div>
{/* 搜索 + 条件过滤 + 列表 */}
<UserSearch query={searchQuery} />
</div> </div>
<Toaster /> <Toaster />
</div> </div>

View File

@@ -0,0 +1,116 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
type Row = {
id: string
name: string
email: string
phone: string
rfmScore: number
lastActiveAt: string
tags: string[]
}
type ApiResp = {
data: Row[]
pagination: { page: number; pageSize: number; total: number; totalPages: number }
}
export default function UserList({ queryString }: { queryString: string }) {
const [data, setData] = useState<Row[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let aborted = false
const controller = new AbortController()
async function load() {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/users?${queryString}`, { signal: controller.signal, cache: 'no-store' })
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
const json: ApiResp = await res.json()
if (!aborted) {
setData(json.data || [])
setTotal(json.pagination?.total || 0)
}
} catch (e: any) {
if (!aborted) setError(e?.message || '未知错误')
} finally {
if (!aborted) setLoading(false)
}
}
load()
return () => {
aborted = true
controller.abort()
}
}, [queryString])
const rows = useMemo(() => data, [data])
if (loading) {
return <div className="rounded-lg border bg-white p-4">...</div>
}
if (error) {
return <div className="rounded-lg border bg-white p-4 text-red-600">{error}</div>
}
return (
<div className="rounded-lg border bg-white">
<div className="flex items-center justify-between p-3 border-b">
<div className="text-sm text-gray-600"> {total} </div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50">
<tr className="text-left text-gray-500">
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium">RFM</th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t">
<td className="px-3 py-2">{r.name}</td>
<td className="px-3 py-2">{r.email}</td>
<td className="px-3 py-2">{r.phone}</td>
<td className="px-3 py-2">{r.rfmScore}</td>
<td className="px-3 py-2">{formatDateTime(r.lastActiveAt)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{(r.tags || []).slice(0, 3).map((t) => (
<span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">{t}</span>
))}
{(r.tags || []).length > 3 && <span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>}
</div>
</td>
</tr>
))}
{!rows.length && (
<tr>
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
function formatDateTime(iso: string) {
try {
const d = new Date(iso)
return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`
} catch {
return iso
}
}

View File

@@ -0,0 +1,79 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Slider } from '@/components/ui/slider'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import UserList from './user-list'
import useDebounce from '@/hooks/use-debounce'
type UserStatus = '活跃' | '沉睡' | '流失风险'
export default function UserSearch({ query }: { query: string }) {
const [status, setStatus] = useState<UserStatus[]>([])
const [rfm, setRfm] = useState<[number, number]>([0, 100])
const debouncedQ = useDebounce(query, 300)
const qs = useMemo(() => {
const p = new URLSearchParams()
if (debouncedQ.trim()) p.set('q', debouncedQ.trim())
if (status.length) p.set('status', status.join(','))
if (rfm[0] !== 0) p.set('rfmMin', String(rfm[0]))
if (rfm[1] !== 100) p.set('rfmMax', String(rfm[1]))
p.set('page', '1')
p.set('pageSize', '20')
return p.toString()
}, [debouncedQ, status, rfm])
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 过滤区 */}
<div className="rounded-lg border bg-white p-4 md:sticky md:top-4 h-max">
<div className="space-y-4">
<div>
<div className="text-sm font-medium mb-2"></div>
<div className="grid gap-2 text-sm">
{(['活跃', '沉睡', '流失风险'] as UserStatus[]).map((s) => {
const checked = status.includes(s)
return (
<label key={s} className="flex items-center gap-2">
<Checkbox
checked={checked}
onCheckedChange={(v) =>
setStatus((prev) => (v ? [...prev, s] : prev.filter((x) => x !== s)))
}
/>
<span>{s}</span>
</label>
)
})}
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">RFM </div>
<Slider value={rfm} min={0} max={100} step={1} onValueChange={(v) => setRfm([v[0], v[1]] as any)} />
<div className="mt-2 text-xs text-gray-500">{rfm[0]} - {rfm[1]}</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setStatus([]); setRfm([0, 100]) }}></Button>
<Button
onClick={() => {
// 触发一次刷新:依赖于 qs 的变化,列表会自动刷新,这里可以空操作
}}
>
</Button>
</div>
</div>
</div>
{/* 列表区 */}
<div className="md:col-span-3">
<UserList queryString={qs} />
</div>
</div>
)
}

View File

@@ -1,7 +1,6 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export function Skeleton(props: React.HTMLAttributes<HTMLDivElement>) { export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props return <div className={cn("animate-pulse rounded-md bg-muted/40", className)} {...props} />
return <div className={cn("animate-pulse rounded-md bg-muted/40", className)} {...rest} />
} }

41
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,41 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
"h-8",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-slate-200">
<SliderPrimitive.Range className="absolute h-full bg-slate-900" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className={cn(
"block h-4 w-4 rounded-full border border-slate-300 bg-white shadow",
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400",
"disabled:pointer-events-none disabled:opacity-50"
)}
/>
<SliderPrimitive.Thumb
className={cn(
"block h-4 w-4 rounded-full border border-slate-300 bg-white shadow",
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400",
"disabled:pointer-events-none disabled:opacity-50"
)}
/>
</SliderPrimitive.Root>
))
Slider.displayName = "Slider"
export { Slider }

View File

@@ -32,9 +32,7 @@ const toastVariants = cva(
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground", destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
}, },
}, },
defaultVariants: { defaultVariants: { variant: "default" },
variant: "default",
},
}, },
) )

View File

@@ -1,6 +1,5 @@
"use client" "use client"
import { useEffect } from "react"
import { import {
Toast, Toast,
ToastAction, ToastAction,
@@ -15,14 +14,6 @@ import { useToast } from "@/components/ui/use-toast"
export function Toaster() { export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast()
// 可选:在开发环境输出调试信息
useEffect(() => {
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line no-console
console.debug("[Toaster] toasts", toasts)
}
}, [toasts])
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({ id, title, description, action, ...props }) {
@@ -42,4 +33,5 @@ export function Toaster() {
) )
} }
export default Toaster
export { ToastAction } export { ToastAction }

View File

@@ -1,17 +1,17 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useEffect, useState } from "react"
export function useDebounce<T>(value: T, delay?: number): T { function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value) const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500) const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return () => { return debouncedValue
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
} }
export default useDebounce
export { useDebounce }

View File

@@ -1,206 +1,163 @@
export type Status = "活跃" | "沉睡" | "已封禁" import { randomUUID } from 'crypto'
export type UserBase = { export type UserStatus = '活跃' | '沉睡' | '流失风险'
export interface User {
id: string id: string
name: string name: string
phone: string
email: string email: string
phone: string
avatar: string
tags: string[] tags: string[]
status: UserStatus
rfmScore: number rfmScore: number
lastActivity: string createdAt: string
status: Status lastActiveAt: string
} }
export type UserDetail = UserBase & { const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗']
avatar?: string const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰']
company?: string const tagPool = ['高价值','近7日活跃','新客','回流','社群达人','潜在复购','高互动','低客单','私域粉','公众号粉']
position?: string const statusPool: UserStatus[] = ['活跃','沉睡','流失风险']
recency: number
frequency: number const avatars = [
monetary: number '/user-avatar-zhangsan.png',
interactions: { id: string; type: string; time: string; note?: string }[] '/user-avatar-lisi.png',
purchaseHistory: { id: string; amount: number; time: string; item: string }[] '/avatar-wanglei.png',
wechatAccounts: { id: string; nickname: string; avatar?: string }[] '/generic-user-avatar.png',
} '/wechat-avatar-1.png',
'/wechat-avatar-2.png',
'/wechat-avatar-3.png',
]
/* helpers */
const NOW = Date.now()
const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
const maskPhone = (p: string) => p.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
const pick = <T,>(arr: T[]) => arr[rand(0, arr.length - 1)] const pick = <T,>(arr: T[]) => arr[rand(0, arr.length - 1)]
const TAGS = [ function toPinyinLike(name: string) {
"高价值用户", "活跃用户", "潜在客户", "价格敏感", "科技爱好者", const map: Record<string,string> = { '张':'zhang','李':'li','王':'wang','赵':'zhao','刘':'liu','陈':'chen','杨':'yang','黄':'huang','周':'zhou','吴':'wu','徐':'xu','孙':'sun','胡':'hu','朱':'zhu','高':'gao','林':'lin','何':'he','郭':'guo','马':'ma','罗':'luo' }
"内容创作者", "一线城市", "二线城市", "iPhone", "Android", return name.split('').map(c => map[c] ?? 'u').join('')
"社群成员", "低活跃", "沉睡风险", "新用户", "忠诚用户", }
] function randomPhone() {
const prefixes = ['139','138','137','136','135','188','187','186','185','184','183','182','159','158','157','156','155']
const COMPANIES = ["合星科技", "云杉数智", "万像互动", "星远数科", "数研云", "青瓦科技"] return `${pick(prefixes)}${rand(1000,9999)}${rand(1000,9999)}`
const POSITIONS = ["产品经理", "运营经理", "市场总监", "技术负责人", "销售", "数据分析师"] }
function randomTags() {
const AVATARS = [ const count = rand(2,4)
"/user-avatar-zhangsan.png", const s = new Set<string>()
"/user-avatar-lisi.png", while (s.size < count) s.add(pick(tagPool))
"/wechat-avatar-1.png", return Array.from(s)
"/wechat-avatar-2.png", }
"/wechat-avatar-3.png", function timeNearNow(daysSpan = 90) {
] const now = Date.now()
const offset = rand(0, daysSpan * 86400000)
/* seed users */ return new Date(now - offset).toISOString()
const baseNames = [
"王磊","刘婷","张三","李四","赵六","钱七","周敏","孙悦","吴迪","郑航",
"冯晨","褚野","卫国","蒋楠","沈静","韩睿","唐奕","曹越","彭博","鲁洋",
"韦东","昌华","顾诚","孟辉","尹雪","谭清","严杰","霍宇","龚一","程远",
]
const USERS: UserDetail[] = baseNames.slice(0, 24).map((name, idx) => {
const n = idx + 1
const rawPhone = `1${rand(3,9)}${rand(0,9)}${rand(0,9)}${rand(10000000, 99999999)}`
const email = `${pinyinLike(name)}${n}@example.com`.toLowerCase()
const tagCount = rand(2, 5)
const tags = Array.from(new Set(Array.from({ length: tagCount }, () => pick(TAGS))))
const status: Status = ["活跃","活跃","活跃","沉睡","已封禁"][rand(0,4)]
const rfm = rand(45, 95)
const lastActivity = new Date(NOW - rand(0, 7) * 86400_000 - rand(0, 12) * 3600_000).toISOString()
const interactions = Array.from({ length: rand(1, 4) }).map((_, i) => ({
id: `i_${n}_${i}`,
type: pick(["咨询", "浏览", "下载白皮书", "提交表单", "聊天"]),
time: new Date(NOW - rand(0, 14) * 86400_000 - rand(0, 20) * 3600_000).toISOString(),
note: pick(["", "询价", "对比竞品", "需要发票", "待回访"]),
}))
const purchaseHistory = rand(0, 1)
? [{ id: `o_${n}_1`, amount: rand(299, 9999), time: new Date(NOW - rand(0, 30) * 86400_000).toISOString(), item: pick(["标准版SaaS","高级版SaaS","增值模块"]) }]
: []
const wechatAccounts = Array.from({ length: rand(1, 2) }).map((_, i) => ({
id: `wx_${n}_${i}`,
nickname: `${name}-微信${i+1}`,
avatar: pick(AVATARS),
}))
return {
id: `user_${1000 + n}`,
name,
phone: maskPhone(rawPhone),
email,
tags,
rfmScore: rfm,
lastActivity,
status,
avatar: pick(AVATARS),
company: pick(COMPANIES),
position: pick(POSITIONS),
recency: rand(1, 10),
frequency: rand(1, 30),
monetary: rand(0, 20000),
interactions,
purchaseHistory,
wechatAccounts,
}
})
function pinyinLike(name: string) {
// super simple fake pinyin-ish
const map: Record<string, string> = {
"王":"wang","张":"zhang","李":"li","刘":"liu","赵":"zhao","钱":"qian","孙":"sun","周":"zhou",
"吴":"wu","郑":"zheng","冯":"feng","褚":"chu","卫":"wei","蒋":"jiang","沈":"shen","韩":"han",
"唐":"tang","曹":"cao","彭":"peng","鲁":"lu","韦":"wei","昌":"chang","顾":"gu","孟":"meng",
"尹":"yin","谭":"tan","严":"yan","霍":"huo","龚":"gong","程":"cheng",
}
const first = map[name[0]] || "user"
const rest = "abcxyz"
return `${first}${rest[Math.floor(Math.random()*rest.length)]}${rest[Math.floor(Math.random()*rest.length)]}`
} }
/* public APIs */ let cache: User[] | null = null
export type FilterOptions = {
function seed(n = 120) {
const list: User[] = []
for (let i = 0; i < n; i++) {
const name = `${pick(familyNames)}${pick(givenNames)}${Math.random() < 0.2 ? pick(givenNames) : ''}`
const email = `${toPinyinLike(name)}${rand(1,99)}@example.com`
const phone = randomPhone()
list.push({
id: randomUUID(),
name,
email,
phone,
avatar: avatars[i % avatars.length],
tags: randomTags(),
status: pick(statusPool),
rfmScore: rand(15, 95),
createdAt: timeNearNow(180),
lastActiveAt: timeNearNow(15),
})
}
return list
}
export function getUsersStore() {
if (!cache) cache = seed()
return cache
}
export interface QueryParams {
q?: string q?: string
tags?: string[] tags?: string[]
status?: Status[] status?: UserStatus[]
rfmMin?: number rfmMin?: number
rfmMax?: number rfmMax?: number
page?: number page?: number
pageSize?: number pageSize?: number
} }
export function getUsers(): UserBase[] { export function queryUsers(params: QueryParams) {
return USERS.map(({ interactions, purchaseHistory, wechatAccounts, recency, frequency, monetary, company, position, avatar, ...u }) => u) const { q, tags, status, rfmMin = 0, rfmMax = 100, page = 1, pageSize = 20 } = params
} let list = getUsersStore()
export function getUserDetail(id: string): UserDetail | null { if (q && q.trim()) {
return USERS.find((u) => u.id === id) ?? null const s = q.trim().toLowerCase()
} list = list.filter(u =>
u.name.toLowerCase().includes(s) ||
export function getDistinctTags(): string[] { u.email.toLowerCase().includes(s) ||
const s = new Set<string>() u.phone.includes(s) ||
USERS.forEach((u) => u.tags.forEach((t) => s.add(t))) u.tags.some(t => t.toLowerCase().includes(s)),
return Array.from(s)
}
export function filterUsers(opts: FilterOptions) {
const {
q = "",
tags = [],
status = [],
rfmMin = 0,
rfmMax = 100,
page = 1,
pageSize = 20,
} = opts
let list = getUsers()
if (q) {
const ql = q.toLowerCase()
list = list.filter(
(u) =>
u.name.toLowerCase().includes(ql) ||
u.phone.includes(q) ||
u.email.toLowerCase().includes(ql) ||
u.tags.some((t) => t.toLowerCase().includes(ql)),
) )
} }
if (tags.length) { if (tags?.length) {
list = list.filter((u) => tags.some((t) => u.tags.includes(t))) list = list.filter(u => tags.every(t => u.tags.includes(t)))
} }
if (status.length) { if (status?.length) {
list = list.filter((u) => status.includes(u.status)) const st = new Set(status)
list = list.filter(u => st.has(u.status))
} }
list = list.filter((u) => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax) list = list.filter(u => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
const total = list.length const total = list.length
const start = (page - 1) * pageSize const start = (page - 1) * pageSize
const end = start + pageSize const end = start + pageSize
const items = list.slice(start, end) const data = list.slice(start, end)
return { items, total, page, pageSize }
// 列表行仅返回必要字段
const thin = data.map(u => ({
id: u.id,
name: u.name,
email: u.email,
phone: u.phone,
rfmScore: u.rfmScore,
lastActiveAt: u.lastActiveAt,
tags: u.tags,
}))
return { data: thin, pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) } }
} }
export function addUser(payload: { name: string; phone: string; email: string; tags?: string[] }): UserDetail { export function addUser(input: Partial<User>) {
const n = USERS.length + 1000 const list = getUsersStore()
const u: UserDetail = { const now = new Date().toISOString()
id: `user_${n}`, const name = input.name ?? `${pick(familyNames)}${pick(givenNames)}`
name: payload.name, const email = input.email ?? `${toPinyinLike(name)}@example.com`
phone: maskPhone(payload.phone), const phone = input.phone ?? randomPhone()
email: payload.email, const u: User = {
tags: payload.tags ?? [], id: randomUUID(),
rfmScore: 60 + (n % 40), name,
lastActivity: new Date().toISOString(), email,
status: "活跃", phone,
avatar: pick(AVATARS), avatar: input.avatar ?? avatars[rand(0, avatars.length - 1)],
company: pick(COMPANIES), tags: input.tags ?? randomTags(),
position: pick(POSITIONS), status: input.status ?? pick(statusPool),
recency: rand(1, 5), rfmScore: input.rfmScore ?? rand(20, 80),
frequency: rand(1, 10), createdAt: now,
monetary: rand(0, 5000), lastActiveAt: now,
interactions: [],
purchaseHistory: [],
wechatAccounts: [],
} }
USERS.unshift(u) list.unshift(u)
return u return u
} }
export function getUserById(id: string) {
return getUsersStore().find(u => u.id === id) ?? null
}

View File

@@ -37,6 +37,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "latest", "cmdk": "latest",
"crypto": "latest",
"date-fns": "latest", "date-fns": "latest",
"docx": "latest", "docx": "latest",
"dom-to-image": "latest", "dom-to-image": "latest",

17
pnpm-lock.yaml generated
View File

@@ -10,7 +10,7 @@ importers:
dependencies: dependencies:
'@ai-sdk/openai': '@ai-sdk/openai':
specifier: latest specifier: latest
version: 2.0.5(zod@4.0.15) version: 2.0.6(zod@4.0.15)
'@ant-design/plots': '@ant-design/plots':
specifier: latest specifier: latest
version: 2.6.3(react-dom@18.0.0(react@18.0.0))(react@18.0.0) version: 2.6.3(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
@@ -92,6 +92,9 @@ importers:
cmdk: cmdk:
specifier: latest specifier: latest
version: 1.1.1(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) version: 1.1.1(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
crypto:
specifier: latest
version: 1.0.1
date-fns: date-fns:
specifier: latest specifier: latest
version: 4.1.0 version: 4.1.0
@@ -177,8 +180,8 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4 zod: ^3.25.76 || ^4
'@ai-sdk/openai@2.0.5': '@ai-sdk/openai@2.0.6':
resolution: {integrity: sha512-1oFXNudUNRfl4QXlE2Q0v8GCvGngx8HMwHN6pyOTMBP8SI9VoOcCJzRPVBMLd0SI7dkcAvGVkpSVTnaLaXEtxQ==} resolution: {integrity: sha512-YmnhiyqllxnGo0Jo23jfi7NCOK+8BhUPPV/Cm/8MGFgI9oX/s6xGIp6KqSK2GFlAz2vxyy+aAlpt0cv/a0SxYQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4 zod: ^3.25.76 || ^4
@@ -1439,6 +1442,10 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
crypto@1.0.1:
resolution: {integrity: sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==}
deprecated: This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3028,7 +3035,7 @@ snapshots:
'@ai-sdk/provider-utils': 3.0.1(zod@4.0.15) '@ai-sdk/provider-utils': 3.0.1(zod@4.0.15)
zod: 4.0.15 zod: 4.0.15
'@ai-sdk/openai@2.0.5(zod@4.0.15)': '@ai-sdk/openai@2.0.6(zod@4.0.15)':
dependencies: dependencies:
'@ai-sdk/provider': 2.0.0 '@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.1(zod@4.0.15) '@ai-sdk/provider-utils': 3.0.1(zod@4.0.15)
@@ -4459,6 +4466,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
crypto@1.0.1: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.1.3: {} csstype@3.1.3: {}

View File

@@ -1,11 +1,27 @@
## 2025-08-08 构建修复与优化 ## 2025-08-08 菜单同步 + 用户画像数据与接口完善
本次更新内容: - 完成内容:
- 修复构建失败:实现并导出 Toast 模块,补齐 Toaster 组件,解决 "module does not provide an export named 'Toast'" - 同步左侧导航与底部菜单,统一为【首页 / 数据中台 / 画像 / AI智能助手】去除“搜索”入口避免与首页内置搜索重复
- 新增骨架屏组件components/ui/skeleton.tsx用于统一加载态 - 新增 /api/users 接口GET/POST支持 q、tags、status、rfmMin、rfmMax、page、pageSize 与 id 详情查询
- 补齐 Suspense 边界:新增 app/workspace/moments-sync/[id]/edit/loading.tsx避免 useSearchParams 触发的路由级 Suspense 报错 - 新增 lib/mock-users.ts批量生成中文姓名、邮箱、手机号、标签、动态时间基于当前时间与 RFM 分数的模拟用户数据
开发说明: - 新增 Skeleton 组件与 moments-sync 编辑页 loading.tsx 作为 Suspense Fallback避免 useSearchParams 构建报错。
- 遵循 App Router 规范,路由级 loading.tsx 作为 Suspense fallback - 补齐 Toast / Toaster 组件的导出与实现,修复构建失败
- UI 组件按 shadcn 风格实现,导出点与项目现有 use-toast 保持一致,避免命名不匹配 - 将“搜索入口”迁移并固定在首页;修复 /api/users 导出/导入冲突,稳定构建
进度汇报 - 变更文件
- 本次修复完成度100% - app/page.tsx新增首页搜索与指标卡片
- 下一步计划1巡检所有 useSearchParams 使用点并补齐 loading.tsx2在 CI 阶段增加构建前校验3联调真实数据源前的接口契约校验。 - components/home/user-search.tsx新增状态/RFM过滤 + 绑定首页搜索框)
- components/home/user-list.tsx新增表格列表
- app/api/users/route.ts精简重写仅依赖 lib/mock-users 导出)
- lib/mock-users.ts统一导出 queryUsers/addUser/getUserById时间全部相对“当前时间”生成
- 接口与数据:
- GET /api/users?id= 返回单体详情GET /api/users 返回列表与分页POST /api/users 新增一个用户(服务内内存态)。
- 完成度:
- 本轮任务完成度100%
- 用户画像模块整体完成度:≈ 88%(已具备真实感数据与筛选能力,待接入真实库)
- 下一步计划:
1. 将 /api/users 切换到真实数据库Neon 或现有 MySQL并加上索引与分页游标
2. 画像页联动更多筛选项与批量导出;
3. 详情页增加 AI 洞察与行动建议AI SDK联动 RFM
4. 对齐路由处的 loading 骨架风格,完善可用性与无障碍。
1) 数据库对接Neon/Supabase/MySQL保留接口契约不变
2) 画像页接入上述接口的分页与高级筛选,补齐批量导出;
3) 详情页接入 AI SDK 生成洞察与跟进建议RFM联动