fix: resolve homepage 404 and add mock data for user portrait
Add landing page, fix type errors, mark dynamic API route, and refactor user portrait page. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse, NextRequest } from "next/server"
|
||||
import type { TrafficUser } from "@/types/traffic"
|
||||
import { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus } from "@/lib/mock-users"
|
||||
import { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus, MOCK_USERS } from "@/lib/mock-users"
|
||||
|
||||
// 中文名字生成器数据
|
||||
const familyNames = [
|
||||
@@ -197,30 +197,26 @@ function parseArrayParam(v: string | null) {
|
||||
return v.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
// 详情优先
|
||||
const id = searchParams.get('id')
|
||||
if (id) {
|
||||
const detail = getUserById(id)
|
||||
return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const keyword = (searchParams.get("q") || "").trim()
|
||||
|
||||
// 列表
|
||||
const q = searchParams.get('q') ?? undefined
|
||||
const tagsStr = searchParams.get('tags') ?? ''
|
||||
const statusStr = searchParams.get('status') ?? ''
|
||||
const rfmMin = Number(searchParams.get('rfmMin') ?? 0)
|
||||
const rfmMax = Number(searchParams.get('rfmMax') ?? 100)
|
||||
const page = Number(searchParams.get('page') ?? 1)
|
||||
const pageSize = Number(searchParams.get('pageSize') ?? 20)
|
||||
const filtered = keyword
|
||||
? MOCK_USERS.filter(
|
||||
(u) =>
|
||||
u.name.includes(keyword) ||
|
||||
(u.nickname && u.nickname.includes(keyword)) ||
|
||||
u.tags.some((t) => t.includes(keyword)),
|
||||
)
|
||||
: MOCK_USERS
|
||||
|
||||
const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
|
||||
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' } })
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: filtered.length,
|
||||
items: filtered,
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
||||
99
app/page.tsx
99
app/page.tsx
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe } from 'lucide-react'
|
||||
import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe, Smartphone, Brain } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import UserSearch from '@/components/home/user-search'
|
||||
import UserList from '@/components/home/user-list'
|
||||
import Link from "next/link"
|
||||
|
||||
interface SystemStats {
|
||||
userCount: number
|
||||
@@ -36,6 +37,8 @@ function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string
|
||||
)
|
||||
}
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export default function OverviewPage() {
|
||||
const router = useRouter()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
@@ -113,6 +116,33 @@ export default function OverviewPage() {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
href: "/user-portrait",
|
||||
title: "用户画像",
|
||||
desc: "管理与分群、标签与画像洞察",
|
||||
icon: <Users className="h-5 w-5 text-purple-500" />,
|
||||
},
|
||||
{
|
||||
href: "/devices",
|
||||
title: "设备管理",
|
||||
desc: "查看设备状态与执行任务",
|
||||
icon: <Smartphone className="h-5 w-5 text-emerald-500" />,
|
||||
},
|
||||
{
|
||||
href: "/data-platform",
|
||||
title: "数据中台",
|
||||
desc: "数据接入、质量监控与查询",
|
||||
icon: <Database className="h-5 w-5 text-indigo-500" />,
|
||||
},
|
||||
{
|
||||
href: "/ai-assistant",
|
||||
title: "AI 助手",
|
||||
desc: "智能问答与自动化报表",
|
||||
icon: <Brain className="h-5 w-5 text-rose-500" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -302,58 +332,21 @@ export default function OverviewPage() {
|
||||
</div>
|
||||
|
||||
{/* 快速访问入口 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-blue-100 hover:border-blue-300"
|
||||
onClick={() => router.push("/data-platform")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-blue-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">数据中台</h3>
|
||||
<p className="text-sm text-gray-600">数据源管理与AI模型</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-green-100 hover:border-green-300"
|
||||
onClick={() => router.push("/user-portrait")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-green-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Users className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">用户画像</h3>
|
||||
<p className="text-sm text-gray-600">用户管理与标签体系</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-purple-100 hover:border-purple-300"
|
||||
onClick={() => router.push("/ai-assistant")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-purple-100 rounded-full w-fit mx-auto mb-3">
|
||||
<BarChart3 className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">AI智能助手</h3>
|
||||
<p className="text-sm text-gray-600">数据分析与报告生成</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-orange-100 hover:border-orange-300"
|
||||
onClick={() => router.push("/intelligent-search")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-orange-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Globe className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">智能搜索</h3>
|
||||
<p className="text-sm text-gray-600">全局搜索与AI分析</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{tiles.map((t) => (
|
||||
<Card key={t.href} className="transition hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg">{t.title}</CardTitle>
|
||||
{t.icon}
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{t.desc}</p>
|
||||
<Button asChild size="sm" className="ml-3">
|
||||
<Link href={t.href}>进入</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 快速指标示例(可后续接入真实数据) */}
|
||||
|
||||
@@ -1,239 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import MobileHeader from "@/app/components/MobileHeader"
|
||||
import BottomNav from "@/app/components/BottomNav"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Suspense } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Search, Filter, Plus } from 'lucide-react'
|
||||
import FilterDrawer, { type FilterValues } from "@/components/user-portrait/filter-drawer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
tags: string[]
|
||||
rfmScore: number
|
||||
lastActivity: string
|
||||
status: "活跃" | "沉睡" | "已封禁"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
async function fetchUsers(q: string) {
|
||||
const url = q ? `/api/users?q=${encodeURIComponent(q)}` : "/api/users"
|
||||
const res = await fetch(url, { cache: "no-store" })
|
||||
if (!res.ok) return { items: [], total: 0 }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } }
|
||||
|
||||
export default function UserPortraitPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isAddingUser, setIsAddingUser] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ name: "", phone: "", email: "", tags: [] as string[] })
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [allTags, setAllTags] = useState<string[]>([])
|
||||
const [filters, setFilters] = useState<FilterValues>({ tags: [], status: [], rfm: [0, 100] })
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
const p = new URLSearchParams()
|
||||
if (searchQuery) p.set("q", searchQuery)
|
||||
if (filters.tags.length) p.set("tags", filters.tags.join(","))
|
||||
if (filters.status.length) p.set("status", filters.status.join(","))
|
||||
p.set("rfmMin", String(filters.rfm[0]))
|
||||
p.set("rfmMax", String(filters.rfm[1]))
|
||||
p.set("page", "1")
|
||||
p.set("pageSize", "50")
|
||||
return p.toString()
|
||||
}, [searchQuery, filters])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [queryString])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users?meta=tags")
|
||||
.then((r) => r.json())
|
||||
.then((res: any) => setAllTags(res?.data?.tags ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAddUser = async () => {
|
||||
const resp = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newUser),
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (data?.success) {
|
||||
setIsAddingUser(false)
|
||||
setNewUser({ name: "", phone: "", email: "", tags: [] })
|
||||
// 触发刷新
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function Tabs({ active = "users" }: { active?: "users" | "tags" }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户画像" />
|
||||
|
||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
||||
<div className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">用户画像</h1>
|
||||
<p className="text-sm text-muted-foreground">管理与分群</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">共 {total} 人</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="users" className="data-[state=active]:bg-white">用户管理</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-white">标签管理</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input className="pl-8" placeholder="搜索用户…" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setFilterOpen(true)}>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
筛选
|
||||
</Button>
|
||||
<Button onClick={() => setIsAddingUser(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id} className="border bg-white/70 backdrop-blur-md shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-12 gap-3 items-center">
|
||||
<div className="col-span-5">
|
||||
<Link href={`/user-portrait/${u.id}`} className="font-medium hover:underline">
|
||||
{u.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
最后活跃 {new Date(u.lastActivity).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="text-sm">{u.phone}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.tags.slice(0, 2).map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-xs">{t}</Badge>
|
||||
))}
|
||||
{u.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{u.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-sm font-semibold">{u.rfmScore}</div>
|
||||
<div className="col-span-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
u.status === "活跃" ? "bg-green-100 text-green-700" :
|
||||
u.status === "沉睡" ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{u.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<div className="text-sm text-muted-foreground py-6 text-center">标签管理将在接入数据字典后提供配置与统计</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
|
||||
{/* 筛选抽屉 */}
|
||||
<FilterDrawer
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
allTags={allTags}
|
||||
value={filters}
|
||||
onApply={(v) => setFilters(v)}
|
||||
/>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={isAddingUser} onOpenChange={setIsAddingUser}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>添加新用户</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input id="name" value={newUser.name} onChange={(e) => setNewUser((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input id="phone" value={newUser.phone} onChange={(e) => setNewUser((p) => ({ ...p, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" type="email" value={newUser.email} onChange={(e) => setNewUser((p) => ({ ...p, email: e.target.value }))} />
|
||||
</div>
|
||||
{!!allTags.length && (
|
||||
<div className="space-y-2">
|
||||
<Label>用户标签</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-auto">
|
||||
{allTags.map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={newUser.tags.includes(t)}
|
||||
onCheckedChange={(ck) =>
|
||||
setNewUser((p) => ({ ...p, tags: ck ? [...p.tags, t] : p.tags.filter((x) => x !== t) }))
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddingUser(false)}>取消</Button>
|
||||
<Button onClick={handleAddUser}>添加用户</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="grid grid-cols-2 rounded-md border bg-muted/30 text-sm">
|
||||
<div className={`px-4 py-2 text-center ${active === "users" ? "bg-background font-medium" : "text-muted-foreground"}`}>用户管理</div>
|
||||
<Link href="/user-portrait/tags" className={`px-4 py-2 text-center rounded-r-md ${active === "tags" ? "bg-background font-medium" : "text-muted-foreground"}`}>标签管理</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function UsersList({ q }: { q: string }) {
|
||||
const { items } = await fetchUsers(q)
|
||||
return (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((u: any) => (
|
||||
<Card key={u.id} className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{u.name}{u.nickname ? ` · ${u.nickname}` : ""}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">最后活跃:{new Date(u.lastActive).toLocaleString()}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={u.avatar || "/generic-user-avatar.png"}
|
||||
alt="avatar"
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.tags?.slice(0, 3).map((t: string) => (
|
||||
<span key={t} className="rounded bg-muted px-2 py-0.5 text-xs">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/user-portrait/${u.id}`}>查看</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="col-span-full py-8 text-center text-sm text-muted-foreground">暂无用户数据</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function UserPortraitPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { q?: string }
|
||||
}) {
|
||||
const q = (searchParams?.q || "").trim()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl p-4 md:p-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl md:text-2xl">用户画像</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">管理与分群</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Tabs />
|
||||
<div className="flex gap-2">
|
||||
<form action="/user-portrait" className="flex gap-2">
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="搜索用户、昵称或标签..."
|
||||
className="w-64"
|
||||
/>
|
||||
<Button type="submit" variant="secondary">筛选</Button>
|
||||
</form>
|
||||
<Button>+ 添加用户</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense key={q} fallback={<p className="mt-4 text-sm text-muted-foreground">加载中...</p>}>
|
||||
{/* @ts-expect-error Async Server Component */}
|
||||
<UsersList q={q} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { X } from 'lucide-react'
|
||||
import { useMemo, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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<"活跃" | "沉睡" | "已封禁">
|
||||
status: string[]
|
||||
rfm: [number, number]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
allTags: string[]
|
||||
@@ -21,131 +21,122 @@ interface Props {
|
||||
onApply: (v: FilterValues) => void
|
||||
}
|
||||
|
||||
export default function FilterDrawer({ open, onOpenChange, allTags, value, onApply }: Props) {
|
||||
const STATUS_OPTIONS = ["活跃", "沉睡", "已封禁"] as const
|
||||
|
||||
export default function FilterDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
allTags,
|
||||
value,
|
||||
onApply,
|
||||
}: Props) {
|
||||
const [local, setLocal] = useState<FilterValues>(value)
|
||||
|
||||
useEffect(() => setLocal(value), [value, open])
|
||||
// 同步外部变更
|
||||
useMemo(() => setLocal(value), [value])
|
||||
|
||||
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)
|
||||
}
|
||||
const toggleArrayVal = (arr: string[], val: string, checked: boolean) =>
|
||||
checked ? Array.from(new Set([...arr, val])) : arr.filter((x) => x !== val)
|
||||
|
||||
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" />
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>筛选</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-2">
|
||||
<section className="space-y-2">
|
||||
<Label>用户状态</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{STATUS_OPTIONS.map((s) => {
|
||||
const checked = local.status.includes(s)
|
||||
return (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(ck) =>
|
||||
setLocal((p) => ({ ...p, status: toggleArrayVal(p.status, s, !!ck) }))
|
||||
}
|
||||
/>
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label>RFM 分数范围</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[0]}
|
||||
aria-label="RFM最小值"
|
||||
onChange={(e) =>
|
||||
setLocal((p) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value)))
|
||||
return { ...p, rfm: [v, Math.max(v, p.rfm[1])] }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-muted-foreground">{'—'}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[1]}
|
||||
aria-label="RFM最大值"
|
||||
onChange={(e) =>
|
||||
setLocal((p) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value)))
|
||||
return { ...p, rfm: [Math.min(p.rfm[0], v), v] }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label>标签</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-auto pr-1">
|
||||
{allTags.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground col-span-2">暂无标签</div>
|
||||
)}
|
||||
{allTags.map((t) => {
|
||||
const checked = local.tags.includes(t)
|
||||
return (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(ck) =>
|
||||
setLocal((p) => ({ ...p, tags: toggleArrayVal(p.tags, t, !!ck) }))
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onApply(local)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
应用筛选
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,49 @@ export interface User {
|
||||
lastActiveAt: string
|
||||
}
|
||||
|
||||
export interface MockUser {
|
||||
id: string
|
||||
name: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
tags: string[]
|
||||
lastActive: string
|
||||
city?: string
|
||||
valueScore?: number
|
||||
}
|
||||
|
||||
export const MOCK_USERS: MockUser[] = [
|
||||
{
|
||||
id: "u_1001",
|
||||
name: "张三",
|
||||
nickname: "技术控",
|
||||
avatar: "/user-avatar-zhangsan.png",
|
||||
tags: ["技术爱好者", "夜猫子"],
|
||||
lastActive: "2025-08-01T10:20:00Z",
|
||||
city: "深圳",
|
||||
valueScore: 86,
|
||||
},
|
||||
{
|
||||
id: "u_1002",
|
||||
name: "李四",
|
||||
nickname: "内容创作者",
|
||||
avatar: "/user-avatar-lisi.png",
|
||||
tags: ["短视频", "自动化工具"],
|
||||
lastActive: "2025-08-06T14:30:00Z",
|
||||
city: "广州",
|
||||
valueScore: 72,
|
||||
},
|
||||
{
|
||||
id: "u_1003",
|
||||
name: "王雷",
|
||||
avatar: "/avatar-wanglei.png",
|
||||
tags: ["效率提升"],
|
||||
lastActive: "2025-08-07T08:05:00Z",
|
||||
city: "成都",
|
||||
valueScore: 64,
|
||||
},
|
||||
]
|
||||
|
||||
const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗']
|
||||
const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰']
|
||||
const tagPool = ['高价值','近7日活跃','新客','回流','社群达人','潜在复购','高互动','低客单','私域粉','公众号粉']
|
||||
|
||||
58
types/device.ts
Normal file
58
types/device.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type DeviceStatus = "online" | "offline" | "unknown"
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
name: string
|
||||
wechatId?: string
|
||||
group?: string
|
||||
tags?: string[]
|
||||
status: DeviceStatus
|
||||
lastSeen?: string
|
||||
}
|
||||
|
||||
export interface CreateDeviceParams {
|
||||
name: string
|
||||
wechatId?: string
|
||||
group?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateDeviceParams extends Partial<CreateDeviceParams> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface QueryDeviceParams {
|
||||
keyword?: string
|
||||
tags?: string[]
|
||||
dateRange?: { start: string; end: string }
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface DeviceStats {
|
||||
id: string
|
||||
tasksToday: number
|
||||
uptimePercent: number
|
||||
}
|
||||
|
||||
export interface DeviceTaskRecord {
|
||||
id: string
|
||||
deviceId: string
|
||||
type: string
|
||||
status: "success" | "failed"
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T | null
|
||||
}
|
||||
60
types/scenario.ts
Normal file
60
types/scenario.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type ScenarioStatus = "draft" | "running" | "paused" | "completed"
|
||||
|
||||
export interface ScenarioBase {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
status: ScenarioStatus
|
||||
creator: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CreateScenarioParams {
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UpdateScenarioParams extends Partial<CreateScenarioParams> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface QueryScenarioParams {
|
||||
type?: string
|
||||
status?: ScenarioStatus
|
||||
keyword?: string
|
||||
dateRange?: { start: string; end: string }
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface ScenarioStats {
|
||||
id: string
|
||||
impressions: number
|
||||
clicks: number
|
||||
conversions: number
|
||||
}
|
||||
|
||||
export interface AcquisitionRecord {
|
||||
id: string
|
||||
scenarioId: string
|
||||
userId: string
|
||||
time: string
|
||||
channel: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T | null
|
||||
}
|
||||
Reference in New Issue
Block a user