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:
@@ -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 })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/page.tsx
22
app/page.tsx
@@ -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>
|
||||||
|
|||||||
116
components/home/user-list.tsx
Normal file
116
components/home/user-list.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
79
components/home/user-search.tsx
Normal file
79
components/home/user-search.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
41
components/ui/slider.tsx
Normal 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 }
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
17
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
38
开发文档/开发文档.md
38
开发文档/开发文档.md
@@ -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.tsx;2)在 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联动)。
|
||||||
|
|||||||
Reference in New Issue
Block a user