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:
v0
2025-08-09 03:04:39 +00:00
parent c94bde6f89
commit 9bb0ee2758
9 changed files with 462 additions and 430 deletions

27
.gitignore vendored Normal file
View 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

View File

@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic"
import { NextResponse } from "next/server"
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径

View File

@@ -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) {

View File

@@ -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>
{/* 快速指标示例(可后续接入真实数据) */}

View File

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

View File

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

View File

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