fix: support default and named export for useDebounce hook

Ensure compatibility with both default and named imports for hook.

#VERCEL_SKIP

Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
v0
2025-09-23 07:09:47 +00:00
parent 7bb91b447e
commit ce0a716d02
30 changed files with 1682 additions and 2474 deletions

View File

@@ -5,10 +5,9 @@ import "./globals.css"
import { Inter } from "next/font/google"
import { useState, useEffect } from "react"
import Sidebar from "./components/Sidebar"
import MobileHeader from "./components/MobileHeader"
import MobileSidebar from "./components/MobileSidebar"
import BottomTabs from "@/components/nav/bottom-tabs"
import { usePathname } from "next/navigation"
import { Toaster } from "@/components/ui/toaster"
import BottomNav from "./components/BottomNav"
const inter = Inter({ subsets: ["latin"] })
@@ -19,7 +18,6 @@ export default function ClientLayout({
}) {
const [isMobile, setIsMobile] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false)
const pathname = usePathname()
useEffect(() => {
const checkMobile = () => {
@@ -35,7 +33,7 @@ export default function ClientLayout({
return (
<html lang="zh-CN">
<head>
<title></title>
<title></title>
<meta name="description" content="基于苹果毛玻璃设计的用户数据资产中台" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
@@ -49,7 +47,7 @@ export default function ClientLayout({
</div>
<div className="flex min-h-screen">
{/* 桌面端侧边栏 - PC端不显示底部导航 */}
{/* 桌面端侧边栏 */}
{!isMobile && <Sidebar />}
{/* 移动端侧边栏 */}
@@ -57,21 +55,16 @@ export default function ClientLayout({
{/* 主内容区域 */}
<main className={`flex-1 ${isMobile ? "pb-20" : "p-6"}`}>
{/* 移动端头部 - 添加卡若数据资产中台标题 */}
{isMobile && (
<div className="sticky top-0 z-30 bg-white/90 backdrop-blur border-b px-4 py-3 mb-4">
<h1 className="text-lg font-semibold text-center"></h1>
</div>
)}
{/* 移动端头部 */}
{isMobile && <MobileHeader onMenuToggle={() => setSidebarOpen(true)} />}
{/* 内容区域 */}
<div className={`glass-card min-h-full ${isMobile ? "mx-2 mb-4" : ""}`}>{children}</div>
</main>
</div>
{/* 移动端底部导航 - 使用新的BottomTabs组件 */}
{isMobile && <BottomTabs />}
<Toaster />
{/* 移动端底部导航 */}
{isMobile && <BottomNav />}
</body>
</html>
)

View File

@@ -1,105 +1,71 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { TrendingUp, FileText, Sparkles } from "lucide-react"
import { BarChart3, Database, FileText } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import TaskList from "@/components/ai-assistant/task-list"
import CreateTaskDialog from "@/components/ai-assistant/create-task-dialog"
import ReportCards from "@/components/ai-assistant/report-cards"
import { sanitizeText } from "@/lib/text-sanitize"
import type { AnalysisTask, ReportItem, ReportTemplate } from "@/types/ai-assistant"
import type { AnalysisTask, DatabaseInfo, ReportItem, ReportTemplate } from "@/types/ai-assistant"
const initialTasks: AnalysisTask[] = [
{
id: "task_001",
name: "用户资产价值评估报告",
database: "用户数据",
name: "用户行为分析报告",
database: "微信用户数据",
status: "completed",
progress: 100,
createdAt: "2025-01-15T09:00:00Z",
completedAt: "2025-01-15T09:30:00Z",
reportUrl: "/reports/user-asset-evaluation.pdf",
description: "基于RFM模型和行为数据的用户资产价值评估",
reportUrl: "/reports/user-behavior-analysis.pdf",
description: "基于近30天行为的聚合与序列分析",
},
{
id: "task_002",
name: "高价值用户画像分析",
database: "用户池数据",
name: "流量关键词趋势分析",
database: "流量关键词库",
status: "running",
progress: 65,
createdAt: "2025-01-15T10:00:00Z",
description: "识别高价值用户特征和行为模式",
description: "关注核心流量词与曝光、点击、转化等指标",
},
{
id: "task_003",
name: "用户生命周期价值预测",
database: "用户数据",
name: "用户价值分层报告",
database: "微信用户数据",
status: "pending",
progress: 0,
createdAt: "2025-01-15T10:30:00Z",
description: "预测用户未来价值和流失风险",
description: "RFM 分层、价值区间分布与运营建议",
},
]
const databases: DatabaseInfo[] = [
{ id: "db_001", name: "微信用户数据库", type: "MySQL", tables: 25, records: 4000000000, lastUpdated: "2025-01-15T11:00:00Z" },
{ id: "db_002", name: "流量关键词库", type: "PostgreSQL", tables: 8, records: 150000, lastUpdated: "2025-01-15T10:45:00Z" },
{ id: "db_003", name: "用户行为日志", type: "MongoDB", tables: 12, records: 1500000000, lastUpdated: "2025-01-15T11:15:00Z" },
]
const templates: ReportTemplate[] = [
{
id: "template_001",
name: "用户资产评估报告",
description: "全面评估用户资产价值和投资回报",
category: "资产评估",
fields: ["资产总值", "RFM分析", "价值分层", "投资回报", "增值建议"],
isEditable: true,
},
{
id: "template_002",
name: "用户价值分析报告",
description: "深度分析用户价值构成和增长潜力",
category: "价值分析",
fields: ["价值构成", "增长趋势", "潜力评估", "风险分析", "优化策略"],
isEditable: true,
},
{
id: "template_003",
name: "用户生命周期报告",
description: "分析用户生命周期各阶段的价值贡献",
category: "生命周期",
fields: ["阶段划分", "价值贡献", "转化率", "留存分析", "提升方案"],
isEditable: true,
},
{ id: "template_001", name: "用户画像分析报告", description: "深度分析用户特征、行为模式和价值分层", category: "用户分析", fields: ["用户基本信息", "RFM分析", "行为轨迹", "价值评估", "推荐策略"] },
{ id: "template_002", name: "流量趋势分析报告", description: "分析关键词搜索趋势和流量变化", category: "流量分析", fields: ["关键词热度", "搜索趋势", "竞争分析", "机会识别", "优化建议"] },
{ id: "template_003", name: "业务运营报告", description: "综合业务数据分析和运营建议", category: "运营分析", fields: ["核心指标", "增长分析", "用户留存", "转化漏斗", "运营建议"] },
{ id: "template_004", name: "数据质量报告", description: "评估数据完整性、准确性和一致性", category: "数据质量", fields: ["数据完整性", "准确性检查", "一致性验证", "异常检测", "改进建议"] },
]
const reportItems: ReportItem[] = [
{
id: "rpt-1",
title: "用户资产评估报告",
source: "数据源:用户池",
description: "基于用户行为和交易数据,评估用户资产价值和投资回报率。",
updatedAt: new Date().toISOString(),
},
{
id: "rpt-2",
title: "高价值用户分析",
source: "数据源:用户池",
description: "识别和分析高价值用户的特征、行为模式和价值贡献。",
updatedAt: new Date().toISOString(),
},
{
id: "rpt-3",
title: "用户价值预测模型",
source: "数据源:用户池",
description: "基于机器学习预测用户未来价值和生命周期价值。",
updatedAt: new Date().toISOString(),
},
{ id: "rpt-1", title: "用户行为分析报告", source: "数据源:微信用户数据库", description: '本报告基于近30天互动行为输出用户行为模式与高频路径。\\n"]]}', updatedAt: new Date().toISOString() },
{ id: "rpt-2", title: "流量关键词趋势分析", source: "数据源:流量关键词库", description: '追踪 000000 类流量词及其曝光、点击、转化趋势,适配日/周/月视角。\\n" ]]}', updatedAt: new Date().toISOString() },
{ id: "rpt-3", title: "用户价值分层报告", source: "数据源:微信用户数据库", description: '结合 RFM 得分与标签,给出 S/A/B/C/D 分层与经营建议。\\n" ]] }', updatedAt: new Date().toISOString() },
]
export default function AIAssistantPage() {
const [tasks, setTasks] = useState<AnalysisTask[]>(initialTasks)
const [editingTemplate, setEditingTemplate] = useState<string | null>(null)
const [templateContent, setTemplateContent] = useState<string>("")
// 进度推进(仅演示用)
const tick = useCallback(() => {
setTasks((prev) =>
prev.map((t) => {
@@ -129,36 +95,28 @@ export default function AIAssistantPage() {
}, 800)
}
const handleEditTemplate = (templateId: string) => {
setEditingTemplate(templateId)
const template = templates.find((t) => t.id === templateId)
setTemplateContent(template?.fields.join("\n") || "")
}
const handleSaveTemplate = () => {
// 保存模板逻辑
setEditingTemplate(null)
setTemplateContent("")
}
const sanitizedItems = useMemo(() => reportItems.map((i) => ({ ...i, description: sanitizeText(i.description) })), [])
const sanitizedItems = useMemo(
() => reportItems.map((i) => ({ ...i, description: sanitizeText(i.description) })),
[],
)
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50">
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold flex items-center gap-2">
<Sparkles className="h-8 w-8 text-blue-500" />
AI智能助手
</h1>
<p className="text-gray-600"></p>
<h1 className="text-3xl font-bold">AI智能助手</h1>
<p className="text-gray-600"></p>
</div>
<Tabs defaultValue="analysis" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="analysis" className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
<BarChart3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="databases" className="flex items-center gap-2">
<Database className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="templates" className="flex items-center gap-2">
<FileText className="w-4 h-4" />
@@ -168,8 +126,8 @@ export default function AIAssistantPage() {
<TabsContent value="analysis" className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold"></h2>
<CreateTaskDialog databases={[]} templates={templates} onCreate={onCreate} />
<h2 className="text-xl font-semibold"></h2>
<CreateTaskDialog databases={databases} templates={templates} onCreate={onCreate} />
</div>
<TaskList tasks={tasks} onTick={tick} />
@@ -177,12 +135,38 @@ export default function AIAssistantPage() {
<ReportCards items={sanitizedItems} />
</TabsContent>
<TabsContent value="databases">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{databases.map((db) => (
<Card key={db.id} className="border">
<CardContent className="p-4 space-y-1">
<div className="font-medium">{db.name}</div>
<div className="text-sm text-gray-600">
{db.type} · {db.tables}
</div>
<div className="text-xs text-gray-500">
{new Date(db.lastUpdated).toLocaleString("zh-CN")}
</div>
</CardContent>
</Card>
))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="templates">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-4 h-4" />
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -191,29 +175,7 @@ export default function AIAssistantPage() {
<CardContent className="p-4 space-y-2">
<div className="font-medium">{t.name}</div>
<div className="text-sm text-gray-600">{t.category}</div>
<div className="text-xs text-gray-500">{t.description}</div>
{editingTemplate === t.id ? (
<div className="space-y-2">
<Textarea
value={templateContent}
onChange={(e) => setTemplateContent(e.target.value)}
placeholder="编辑模板字段..."
className="text-xs"
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSaveTemplate}>
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingTemplate(null)}>
</Button>
</div>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => handleEditTemplate(t.id)} className="w-full">
</Button>
)}
<div className="text-xs text-gray-500">: {t.fields.join(" / ")}</div>
</CardContent>
</Card>
))}

View File

@@ -1,31 +1,25 @@
import { NextResponse } from "next/server"
import { getDatabases, getDatabaseStructure, getTableStructure } from "@/lib/db-connector"
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径
export const dynamic = "force-dynamic"
export async function GET(req: Request) {
export async function GET(request: Request) {
try {
const url = new URL(req.url)
const database = url.searchParams.get("database")
const table = url.searchParams.get("table")
const { searchParams } = new URL(request.url)
const database = searchParams.get("database")
// 无查询参数:返回数据库列表([{ Database: string }]
if (!database) {
const dbs = await getDatabases()
return NextResponse.json({ success: true, data: dbs })
if (database) {
// 获取指定数据库的结构
const structure = await getDatabaseStructure(database)
return NextResponse.json({ success: true, data: structure })
} else {
// 获取所有数据库列表
const databases = await getDatabases()
return NextResponse.json({ success: true, data: databases })
}
// 有 database + table返回表结构
if (database && table) {
const fields = await getTableStructure(database, table)
return NextResponse.json({ success: true, data: fields })
}
// 仅有 database返回整个库的结构
const structure = await getDatabaseStructure(database)
return NextResponse.json({ success: true, data: structure })
} catch (error) {
console.error("数据库结构API错误:", error)
return NextResponse.json({ success: false, message: "获取数据库结构失败" }, { status: 500 })
console.error("数据库结构查询失败:", error)
return NextResponse.json(
{ success: false, message: "数据库结构查询失败", error: (error as Error).message },
{ status: 500 },
)
}
}

View File

@@ -1,98 +0,0 @@
import { NextResponse } from "next/server"
// 生成最小可用的 OpenAPI 3.1 规范,覆盖当前已实现的关键接口
export async function GET(req: Request) {
const url = new URL(req.url)
const download = url.searchParams.get("download") === "1"
const spec = {
openapi: "3.1.0",
info: {
title: "用户数据资产中台 API",
version: "1.0.0",
description:
"统一用户数据接入与治理接口。包含数据接入、数据库结构浏览等端点。模型结构参考 v1.4 文档中的统一用户画像定义。",
},
paths: {
"/api/ingest": {
get: {
summary: "获取数据接入状态",
responses: {
"200": {
description: "成功",
},
},
},
post: {
summary: "提交单条数据进行接入处理",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
source: { type: "string" },
sourceUserId: { type: "string" },
sourceRecordId: { type: "string" },
originalData: { type: "object" },
timestamp: { type: "string", format: "date-time" },
},
required: ["source", "originalData"],
},
},
},
},
responses: { "200": { description: "成功" }, "400": { description: "参数错误" } },
},
put: {
summary: "批量接入处理",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
requests: {
type: "array",
items: {
type: "object",
properties: {
source: { type: "string" },
sourceUserId: { type: "string" },
sourceRecordId: { type: "string" },
originalData: { type: "object" },
timestamp: { type: "string", format: "date-time" },
},
required: ["source", "originalData"],
},
},
},
required: ["requests"],
},
},
},
},
responses: { "200": { description: "成功" }, "400": { description: "参数错误" } },
},
},
"/api/database-structure": {
get: {
summary: "获取数据库列表或结构",
parameters: [
{ name: "database", in: "query", required: false, schema: { type: "string" } },
{ name: "table", in: "query", required: false, schema: { type: "string" } },
],
responses: { "200": { description: "成功" } },
},
},
},
}
const res = NextResponse.json(spec)
if (download) {
res.headers.set("Content-Disposition", 'attachment; filename="openapi.json"')
}
return res
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
import { NextResponse, NextRequest } from "next/server"
import type { TrafficUser } from "@/types/traffic"
import { addUser, getUsersStore, queryUsers, type User } from "@/lib/mock-users"
import { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus } from "@/lib/mock-users"
// 中文名字生成器数据
const familyNames = [
@@ -127,9 +127,6 @@ const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [],
city: "",
persona: "",
rfmScore: Math.floor(Math.random() * 101),
}
})
@@ -188,9 +185,6 @@ const generateWechatFriends = (wechatId: string, count: number) => {
assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [],
city: "",
persona: "",
rfmScore: Math.floor(Math.random() * 101),
}
})
}
@@ -200,57 +194,37 @@ const wechatFriendsCache = new Map<string, TrafficUser[]>()
function parseArrayParam(v: string | null) {
if (!v) return []
return v
.split(",")
.map((s) => s.trim())
.filter(Boolean)
return v.split(",").map((s) => s.trim()).filter(Boolean)
}
export const dynamic = "force-dynamic"
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
export async function GET(req: Request) {
const url = new URL(req.url)
const meta = url.searchParams.get("meta")
if (meta === "tags") {
const tags = Array.from(new Set(getUsersStore().flatMap((u) => u.tags))).sort()
const cities = Array.from(new Set(getUsersStore().map((u) => u.city))).sort()
const personas = Array.from(new Set(getUsersStore().flatMap((u) => u.persona))).sort()
const sources = Array.from(new Set(getUsersStore().map((u) => u.source))).sort()
return NextResponse.json({ success: true, data: { tags, cities, personas, sources } })
// 详情优先
const id = searchParams.get('id')
if (id) {
const detail = getUserById(id)
return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } })
}
const q = url.searchParams.get("q") ?? undefined
const tags = url.searchParams.get("tags")?.split(",").filter(Boolean)
const status = url.searchParams.get("status")?.split(",").filter(Boolean) as any
const city = url.searchParams.get("city")?.split(",").filter(Boolean)
const persona = url.searchParams.get("persona")?.split(",").filter(Boolean)
const source = url.searchParams.get("source")?.split(",").filter(Boolean)
const rfmMin = Number(url.searchParams.get("rfmMin") ?? 0)
const rfmMax = Number(url.searchParams.get("rfmMax") ?? 100)
const page = Number(url.searchParams.get("page") ?? 1)
const pageSize = Number(url.searchParams.get("pageSize") ?? 20)
// 列表
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 { data, pagination } = queryUsers({
q,
tags,
status,
city,
persona,
source,
rfmMin,
rfmMax,
page,
pageSize,
})
const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
const status = statusStr ? (statusStr.split(',').filter(Boolean) as any) : undefined
// 用户估值:简单以 rfmScore * 100 作为估值
const totalValue = data.reduce((sum, u) => sum + u.rfmScore * 100, 0)
return NextResponse.json({ success: true, data: { items: data, pagination, totalValue } })
const result = queryUsers({ q, tags, status, rfmMin, rfmMax, page, pageSize })
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
}
export async function POST(req: Request) {
const body = (await req.json()) as Partial<User>
const u = addUser(body)
return NextResponse.json({ success: true, data: u })
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}))
const created = addUser(body ?? {})
return NextResponse.json({ data: created }, { status: 201 })
}

View File

@@ -1,246 +1,340 @@
"use client"
import { useState } from "react"
import { Database, Plus, Settings, Play, Pause, RotateCcw, Brain, Zap, CheckCircle, AlertCircle } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Database, Plus, RefreshCw, Settings, Brain, Play } from "lucide-react"
import BottomTabs from "@/components/nav/bottom-tabs"
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
interface DataSource {
id: string
name: string
description: string
type: string
records: string
lastSync: string
status: "connected" | "disconnected" | "syncing"
lastSync: string
recordCount: number
description: string
}
interface AIModel {
id: string
name: string
type: string
accuracy: string
algorithm: string
features: number
status: "training" | "ready" | "error"
accuracy: number
lastTrained: string
status: "ready" | "training" | "error"
parameters: Record<string, any>
}
const mockDataSources: DataSource[] = [
{
id: "wechat-db",
name: "微信用户数据库",
description: "存储微信用户基础信息和行为数据",
type: "MySQL",
records: "2.5B",
lastSync: "2024/1/15 18:30:00",
status: "connected",
},
{
id: "traffic-keywords",
name: "流量关键词库",
description: "搜索引擎关键词和流量数据",
type: "PostgreSQL",
records: "150.0K",
lastSync: "2024/1/15 17:45:00",
status: "connected",
},
{
id: "user-behavior",
name: "用户行为日志",
description: "用户操作行为和交互记录",
type: "MongoDB",
records: "1.5B",
lastSync: "2024/1/15 19:00:00",
status: "syncing",
},
]
const mockAIModels: AIModel[] = [
{
id: "user-value-prediction",
name: "用户价值预测模型",
type: "Classification",
accuracy: "92.0%",
algorithm: "RandomForest",
features: 25,
lastTrained: "2024/1/14 23:30:00",
status: "ready",
},
{
id: "traffic-trend-analysis",
name: "流量趋势分析模型",
type: "Regression",
accuracy: "87.0%",
algorithm: "LSTM",
features: 15,
lastTrained: "2024/1/15 16:00:00",
status: "training",
},
{
id: "user-clustering",
name: "用户聚类模型",
type: "Clustering",
accuracy: "89.0%",
algorithm: "KMeans",
features: 20,
lastTrained: "2024/1/13 20:00:00",
status: "ready",
},
]
export default function DataPlatformPage() {
const [activeTab, setActiveTab] = useState("datasource")
const [dataSources, setDataSources] = useState<DataSource[]>(mockDataSources)
const [aiModels, setAIModels] = useState<AIModel[]>(mockAIModels)
const [syncing, setSyncing] = useState<string | null>(null)
const [training, setTraining] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [dataSources, setDataSources] = useState<DataSource[]>([
{
id: "ds_001",
name: "微信用户数据库",
type: "MySQL",
status: "connected",
lastSync: "2024-01-15T10:30:00Z",
recordCount: 2500000000,
description: "存储微信用户基础信息和行为数据",
},
{
id: "ds_002",
name: "流量关键词库",
type: "PostgreSQL",
status: "connected",
lastSync: "2024-01-15T09:45:00Z",
recordCount: 150000,
description: "搜索引擎关键词和流量数据",
},
{
id: "ds_003",
name: "用户行为日志",
type: "MongoDB",
status: "syncing",
lastSync: "2024-01-15T11:00:00Z",
recordCount: 1500000000,
description: "用户操作行为和交互记录",
},
])
const [aiModels, setAiModels] = useState<AIModel[]>([
{
id: "model_001",
name: "用户价值预测模型",
type: "Classification",
status: "ready",
accuracy: 0.92,
lastTrained: "2024-01-14T15:30:00Z",
parameters: {
algorithm: "RandomForest",
features: 25,
epochs: 100,
learningRate: 0.01,
},
},
{
id: "model_002",
name: "流量趋势分析模型",
type: "Regression",
status: "training",
accuracy: 0.87,
lastTrained: "2024-01-15T08:00:00Z",
parameters: {
algorithm: "LSTM",
features: 15,
epochs: 200,
learningRate: 0.001,
},
},
{
id: "model_003",
name: "用户聚类模型",
type: "Clustering",
status: "ready",
accuracy: 0.89,
lastTrained: "2024-01-13T12:00:00Z",
parameters: {
algorithm: "KMeans",
clusters: 8,
features: 20,
iterations: 300,
},
},
])
const [isAddingDataSource, setIsAddingDataSource] = useState(false)
const [isTrainingModel, setIsTrainingModel] = useState(false)
const [newDataSource, setNewDataSource] = useState({
name: "",
description: "",
type: "MySQL",
host: "",
port: "",
database: "",
username: "",
password: "",
description: "",
})
const handleSync = async (sourceId: string) => {
setSyncing(sourceId)
setTimeout(() => {
setSyncing(null)
setDataSources((prev) =>
prev.map((source) =>
source.id === sourceId ? { ...source, lastSync: new Date().toLocaleString("zh-CN") } : source,
),
)
}, 2000)
const [modelTrainingConfig, setModelTrainingConfig] = useState({
modelId: "",
algorithm: "RandomForest",
features: 25,
epochs: 100,
learningRate: 0.01,
validationSplit: 0.2,
autoTune: true,
})
// 添加数据源
const handleAddDataSource = async () => {
try {
const newSource: DataSource = {
id: `ds_${Date.now()}`,
name: newDataSource.name,
type: newDataSource.type,
status: "connected",
lastSync: new Date().toISOString(),
recordCount: 0,
description: newDataSource.description,
}
setDataSources((prev) => [...prev, newSource])
setIsAddingDataSource(false)
setNewDataSource({
name: "",
type: "MySQL",
host: "",
port: "",
database: "",
username: "",
password: "",
description: "",
})
// 模拟数据导入
setTimeout(() => {
setDataSources((prev) =>
prev.map((ds) =>
ds.id === newSource.id
? { ...ds, recordCount: Math.floor(Math.random() * 1000000) + 10000, status: "connected" as const }
: ds,
),
)
}, 2000)
} catch (error) {
console.error("添加数据源失败:", error)
}
}
const handleRetrain = async (modelId: string) => {
setTraining(modelId)
// 同步数据源
const handleSyncDataSource = (id: string) => {
setDataSources((prev) =>
prev.map((ds) => (ds.id === id ? { ...ds, status: "syncing" as const, lastSync: new Date().toISOString() } : ds)),
)
// 模拟同步完成
setTimeout(() => {
setTraining(null)
setAIModels((prev) =>
prev.map((model) =>
model.id === modelId ? { ...model, lastTrained: new Date().toLocaleString("zh-CN") } : model,
setDataSources((prev) =>
prev.map((ds) =>
ds.id === id
? {
...ds,
status: "connected" as const,
recordCount: ds.recordCount + Math.floor(Math.random() * 10000),
lastSync: new Date().toISOString(),
}
: ds,
),
)
}, 3000)
}
const handleAddDataSource = () => {
const newSource: DataSource = {
id: `datasource-${Date.now()}`,
name: newDataSource.name,
description: newDataSource.description,
type: newDataSource.type,
records: "0",
lastSync: "从未同步",
status: "disconnected",
}
setDataSources((prev) => [...prev, newSource])
setNewDataSource({
name: "",
description: "",
type: "MySQL",
host: "",
port: "",
database: "",
username: "",
password: "",
})
setShowAddDialog(false)
// 训练AI模型
const handleTrainModel = async () => {
if (!modelTrainingConfig.modelId) return
setIsTrainingModel(true)
// 更新模型状态为训练中
setAiModels((prev) =>
prev.map((model) =>
model.id === modelTrainingConfig.modelId ? { ...model, status: "training" as const } : model,
),
)
// 模拟训练过程
setTimeout(() => {
setAiModels((prev) =>
prev.map((model) =>
model.id === modelTrainingConfig.modelId
? {
...model,
status: "ready" as const,
accuracy: Math.random() * 0.1 + 0.85,
lastTrained: new Date().toISOString(),
parameters: {
algorithm: modelTrainingConfig.algorithm,
features: modelTrainingConfig.features,
epochs: modelTrainingConfig.epochs,
learningRate: modelTrainingConfig.learningRate,
},
}
: model,
),
)
setIsTrainingModel(false)
}, 5000)
}
const getStatusBadge = (status: DataSource["status"]) => {
// 格式化数字
const formatNumber = (num: number): string => {
if (num >= 1000000000) {
return `${(num / 1000000000).toFixed(1)}B`
}
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`
}
return num.toString()
}
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case "connected":
return <Badge className="bg-green-100 text-green-800 border-green-200">Connected</Badge>
case "disconnected":
return <Badge variant="destructive">Disconnected</Badge>
case "ready":
return "text-green-600 bg-green-50 border-green-200"
case "syncing":
return <Badge className="bg-blue-100 text-blue-800 border-blue-200">Syncing</Badge>
case "training":
return "text-yellow-600 bg-yellow-50 border-yellow-200"
case "disconnected":
case "error":
return "text-red-600 bg-red-50 border-red-200"
default:
return <Badge variant="secondary">Unknown</Badge>
return "text-gray-600 bg-gray-50 border-gray-200"
}
}
const getModelStatusBadge = (status: AIModel["status"]) => {
// 获取状态图标
const getStatusIcon = (status: string) => {
switch (status) {
case "connected":
case "ready":
return <Badge className="bg-green-100 text-green-800 border-green-200">Ready</Badge>
return <CheckCircle className="w-4 h-4" />
case "syncing":
case "training":
return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">Training</Badge>
return <Zap className="w-4 h-4 animate-pulse" />
case "disconnected":
case "error":
return <Badge variant="destructive">Error</Badge>
return <AlertCircle className="w-4 h-4" />
default:
return <Badge variant="secondary">Unknown</Badge>
return <Database className="w-4 h-4" />
}
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b">
<div className="container mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1">AI模型训练平台</p>
</div>
{activeTab === "datasource" ? (
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50">
<div className="container mx-auto px-4 py-8">
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600">AI模型训练平</p>
</div>
<Tabs defaultValue="datasources" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="datasources" className="flex items-center gap-2">
<Database className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="aimodels" className="flex items-center gap-2">
<Brain className="w-4 h-4" />
AI模型
</TabsTrigger>
</TabsList>
{/* 数据源管理 */}
<TabsContent value="datasources" className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold"></h2>
<Dialog open={isAddingDataSource} onOpenChange={setIsAddingDataSource}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
</Label>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={newDataSource.name}
onChange={(e) => setNewDataSource({ ...newDataSource, name: e.target.value })}
className="col-span-3"
onChange={(e) => setNewDataSource((prev) => ({ ...prev, name: e.target.value }))}
placeholder="输入数据源名称"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
</Label>
<Input
id="description"
value={newDataSource.description}
onChange={(e) => setNewDataSource({ ...newDataSource, description: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">
</Label>
<div className="space-y-2">
<Label htmlFor="type"></Label>
<Select
value={newDataSource.type}
onValueChange={(value) => setNewDataSource({ ...newDataSource, type: value })}
onValueChange={(value) => setNewDataSource((prev) => ({ ...prev, type: value }))}
>
<SelectTrigger className="col-span-3">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -248,114 +342,130 @@ export default function DataPlatformPage() {
<SelectItem value="PostgreSQL">PostgreSQL</SelectItem>
<SelectItem value="MongoDB">MongoDB</SelectItem>
<SelectItem value="Redis">Redis</SelectItem>
<SelectItem value="ClickHouse">ClickHouse</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="host" className="text-right">
</Label>
<div className="space-y-2">
<Label htmlFor="host"></Label>
<Input
id="host"
value={newDataSource.host}
onChange={(e) => setNewDataSource({ ...newDataSource, host: e.target.value })}
className="col-span-3"
onChange={(e) => setNewDataSource((prev) => ({ ...prev, host: e.target.value }))}
placeholder="localhost"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="port" className="text-right">
</Label>
<div className="space-y-2">
<Label htmlFor="port"></Label>
<Input
id="port"
value={newDataSource.port}
onChange={(e) => setNewDataSource({ ...newDataSource, port: e.target.value })}
className="col-span-3"
onChange={(e) => setNewDataSource((prev) => ({ ...prev, port: e.target.value }))}
placeholder="3306"
/>
</div>
<div className="space-y-2">
<Label htmlFor="database"></Label>
<Input
id="database"
value={newDataSource.database}
onChange={(e) => setNewDataSource((prev) => ({ ...prev, database: e.target.value }))}
placeholder="database_name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={newDataSource.username}
onChange={(e) => setNewDataSource((prev) => ({ ...prev, username: e.target.value }))}
placeholder="username"
/>
</div>
<div className="col-span-2 space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={newDataSource.password}
onChange={(e) => setNewDataSource((prev) => ({ ...prev, password: e.target.value }))}
placeholder="password"
/>
</div>
<div className="col-span-2 space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={newDataSource.description}
onChange={(e) => setNewDataSource((prev) => ({ ...prev, description: e.target.value }))}
placeholder="数据源描述信息"
rows={3}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
<Button variant="outline" onClick={() => setIsAddingDataSource(false)}>
</Button>
<Button onClick={handleAddDataSource} disabled={!newDataSource.name || !newDataSource.type}>
</Button>
<Button onClick={handleAddDataSource}></Button>
</div>
</DialogContent>
</Dialog>
) : (
<Button className="flex items-center gap-2" onClick={() => handleRetrain("all")}>
<RefreshCw className="w-4 h-4" />
</Button>
)}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="datasource" className="flex items-center gap-2">
<Database className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="aimodel" className="flex items-center gap-2">
<Brain className="w-4 h-4" />
AI模型
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<div className="container mx-auto px-4 py-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsContent value="datasource" className="space-y-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold"></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{dataSources.map((source) => (
<Card key={source.id} className="bg-white">
<CardHeader className="pb-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg font-semibold mb-2">{source.name}</CardTitle>
<p className="text-gray-600 text-sm mb-3">{source.description}</p>
<div className="space-y-1 text-sm text-gray-500">
<div>
<span className="font-medium">{source.type}</span>
</div>
<div>
<span className="font-medium">{source.records}</span>
</div>
</div>
<Card key={source.id} className="border-2 hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{source.name}</CardTitle>
<Badge className={`${getStatusColor(source.status)} border`}>
{getStatusIcon(source.status)}
<span className="ml-1 capitalize">{source.status}</span>
</Badge>
</div>
<p className="text-sm text-gray-600">{source.description}</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">:</span>
<span className="ml-2 font-medium">{source.type}</span>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(syncing === source.id ? "syncing" : source.status)}
<div>
<span className="text-gray-500">:</span>
<span className="ml-2 font-medium">{formatNumber(source.recordCount)}</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
<div className="text-sm text-gray-500">{source.lastSync}</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSync(source.id)}
disabled={syncing === source.id}
className="flex items-center gap-2 flex-1"
>
<RefreshCw className={`w-4 h-4 ${syncing === source.id ? "animate-spin" : ""}`} />
{syncing === source.id ? "同步中" : "同步数据"}
</Button>
<Button variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
</div>
<div className="text-sm">
<span className="text-gray-500">:</span>
<span className="ml-2">{new Date(source.lastSync).toLocaleString()}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleSyncDataSource(source.id)}
disabled={source.status === "syncing"}
className="flex-1"
>
{source.status === "syncing" ? (
<>
<RotateCcw className="w-3 h-3 mr-1 animate-spin" />
</>
) : (
<>
<RotateCcw className="w-3 h-3 mr-1" />
</>
)}
</Button>
<Button size="sm" variant="outline">
<Settings className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
@@ -363,79 +473,201 @@ export default function DataPlatformPage() {
</div>
</TabsContent>
<TabsContent value="aimodel" className="space-y-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">AI模型管理</h2>
{/* AI模型管理 */}
<TabsContent value="aimodels" className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold">AI模型管理</h2>
<Dialog open={isTrainingModel} onOpenChange={setIsTrainingModel}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Brain className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="col-span-2 space-y-2">
<Label htmlFor="model"></Label>
<Select
value={modelTrainingConfig.modelId}
onValueChange={(value) => setModelTrainingConfig((prev) => ({ ...prev, modelId: value }))}
>
<SelectTrigger>
<SelectValue placeholder="选择要训练的模型" />
</SelectTrigger>
<SelectContent>
{aiModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="algorithm"></Label>
<Select
value={modelTrainingConfig.algorithm}
onValueChange={(value) => setModelTrainingConfig((prev) => ({ ...prev, algorithm: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="RandomForest">Random Forest</SelectItem>
<SelectItem value="XGBoost">XGBoost</SelectItem>
<SelectItem value="LSTM">LSTM</SelectItem>
<SelectItem value="KMeans">K-Means</SelectItem>
<SelectItem value="SVM">SVM</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="features"></Label>
<Input
id="features"
type="number"
value={modelTrainingConfig.features}
onChange={(e) =>
setModelTrainingConfig((prev) => ({ ...prev, features: Number.parseInt(e.target.value) }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="epochs"></Label>
<Input
id="epochs"
type="number"
value={modelTrainingConfig.epochs}
onChange={(e) =>
setModelTrainingConfig((prev) => ({ ...prev, epochs: Number.parseInt(e.target.value) }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="learningRate"></Label>
<Input
id="learningRate"
type="number"
step="0.001"
value={modelTrainingConfig.learningRate}
onChange={(e) =>
setModelTrainingConfig((prev) => ({
...prev,
learningRate: Number.parseFloat(e.target.value),
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="validationSplit"></Label>
<Input
id="validationSplit"
type="number"
step="0.1"
min="0.1"
max="0.5"
value={modelTrainingConfig.validationSplit}
onChange={(e) =>
setModelTrainingConfig((prev) => ({
...prev,
validationSplit: Number.parseFloat(e.target.value),
}))
}
/>
</div>
<div className="col-span-2 flex items-center space-x-2">
<Switch
id="autoTune"
checked={modelTrainingConfig.autoTune}
onCheckedChange={(checked) =>
setModelTrainingConfig((prev) => ({ ...prev, autoTune: checked }))
}
/>
<Label htmlFor="autoTune"></Label>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsTrainingModel(false)}>
</Button>
<Button onClick={handleTrainModel} disabled={!modelTrainingConfig.modelId}>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{aiModels.map((model) => (
<Card key={model.id} className="bg-white">
<CardHeader className="pb-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg font-semibold mb-2">{model.name}</CardTitle>
<p className="text-gray-600 text-sm mb-3">{model.type} </p>
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span></span>
<span className="font-medium">{model.accuracy}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-gray-900 h-2 rounded-full" style={{ width: model.accuracy }}></div>
</div>
</div>
<div className="space-y-1 text-sm text-gray-500">
<div>
<span className="font-medium">{model.algorithm}</span>
</div>
<div>
<span className="font-medium">{model.features}</span>
</div>
</div>
<Card key={model.id} className="border-2 hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{model.name}</CardTitle>
<Badge className={`${getStatusColor(model.status)} border`}>
{getStatusIcon(model.status)}
<span className="ml-1 capitalize">{model.status}</span>
</Badge>
</div>
<p className="text-sm text-gray-600">{model.type} </p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">{(model.accuracy * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
{getModelStatusBadge(training === model.id ? "training" : model.status)}
<Progress value={model.accuracy * 100} className="h-2" />
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">:</span>
<span className="ml-2 font-medium">{model.parameters.algorithm}</span>
</div>
<div>
<span className="text-gray-500">:</span>
<span className="ml-2 font-medium">{model.parameters.features}</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
<div className="text-sm text-gray-500">{model.lastTrained}</div>
<div className="flex items-center gap-2">
{model.status === "ready" ? (
<Button variant="outline" size="sm" className="flex items-center gap-2 flex-1 bg-transparent">
<Play className="w-4 h-4" />
</Button>
) : model.status === "training" ? (
<Button
variant="outline"
size="sm"
disabled
className="flex items-center gap-2 flex-1 bg-transparent"
>
<RefreshCw className="w-4 h-4 animate-spin" />
<div className="text-sm">
<span className="text-gray-500">:</span>
<span className="ml-2">{new Date(model.lastTrained).toLocaleString()}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={model.status === "training"}
className="flex-1 bg-transparent"
>
{model.status === "training" ? (
<>
<Pause className="w-3 h-3 mr-1" />
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleRetrain(model.id)}
disabled={training === model.id}
className="flex items-center gap-2 flex-1"
>
<RefreshCw className={`w-4 h-4 ${training === model.id ? "animate-spin" : ""}`} />
</Button>
<>
<Play className="w-3 h-3 mr-1" />
</>
)}
<Button variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
</div>
</Button>
<Button size="sm" variant="outline">
<Settings className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
@@ -444,8 +676,6 @@ export default function DataPlatformPage() {
</TabsContent>
</Tabs>
</div>
<BottomTabs />
</div>
)
}

View File

@@ -1,7 +1,6 @@
import type React from "react"
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
import ClientLayout from "./ClientLayout"
const inter = Inter({ subsets: ["latin"] })
@@ -19,3 +18,6 @@ export default function RootLayout({
}) {
return <ClientLayout>{children}</ClientLayout>
}
import './globals.css'

View File

@@ -1,70 +1,102 @@
"use client"
import { useState } from "react"
import { Users, Database, Brain, BarChart3, Activity, MessageSquare, Zap, Send } from "lucide-react"
import { useState, useEffect } from "react"
import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useRouter } from "next/navigation"
import BottomTabs from "@/components/nav/bottom-tabs"
import { Toaster } from "@/components/ui/toaster"
import UserSearch from '@/components/home/user-search'
import UserList from '@/components/home/user-list'
interface SystemStats {
userCount: number
userAssets: number
dataVolume: string
aiQueries: number
keywordCount: number
versionCount: number
avgResponseTime: number
cacheSize: number
connected: boolean
}
interface ChatMessage {
id: string
type: "user" | "ai"
content: string
timestamp: Date
interface GrowthData {
period: string
userGrowth: number
dataGrowth: number
activeUsers: number
}
export default function HomePage() {
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() {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState("")
const [selectedModel, setSelectedModel] = useState("gpt-5")
const [showChat, setShowChat] = useState(false)
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [stats, setStats] = useState<SystemStats>({
const [systemStats, setSystemStats] = useState<SystemStats>({
userCount: 4000000000,
userAssets: 4000000000,
dataVolume: "2.5TB",
aiQueries: 156789,
keywordCount: 150000,
versionCount: 25,
avgResponseTime: 120,
cacheSize: 0,
connected: true,
})
const [growthData, setGrowthData] = useState<GrowthData[]>([
{ period: "今日", userGrowth: 2.3, dataGrowth: 1.8, activeUsers: 85600000 },
{ period: "本周", userGrowth: 12.5, dataGrowth: 8.9, activeUsers: 520000000 },
{ period: "本月", userGrowth: 45.2, dataGrowth: 32.1, activeUsers: 1200000000 },
])
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastUpdate, setLastUpdate] = useState(new Date())
const handleAIChat = async () => {
if (!searchQuery.trim()) return
// 自动刷新数据
useEffect(() => {
const interval = setInterval(() => {
refreshData()
}, 30000) // 30秒刷新一次
const userMessage: ChatMessage = {
id: Date.now().toString(),
type: "user",
content: searchQuery,
timestamp: new Date(),
return () => clearInterval(interval)
}, [])
// 刷新数据
const refreshData = async () => {
setIsRefreshing(true)
try {
// 模拟数据更新
setSystemStats((prev) => ({
...prev,
userCount: prev.userCount + Math.floor(Math.random() * 1000),
avgResponseTime: Math.floor(Math.random() * 50) + 100,
}))
setGrowthData((prev) =>
prev.map((item) => ({
...item,
userGrowth: item.userGrowth + (Math.random() - 0.5) * 0.5,
dataGrowth: item.dataGrowth + (Math.random() - 0.5) * 0.3,
activeUsers: Math.floor(item.activeUsers * (1 + (Math.random() - 0.5) * 0.01)),
})),
)
setLastUpdate(new Date())
} catch (error) {
console.error("刷新数据失败:", error)
} finally {
setIsRefreshing(false)
}
}
setChatMessages((prev) => [...prev, userMessage])
setIsLoading(true)
setShowChat(true)
// 模拟AI响应
setTimeout(() => {
const aiMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
type: "ai",
content: `基于${selectedModel}模型分析:${searchQuery}的相关数据显示用户增长趋势良好,建议进一步优化用户体验。`,
timestamp: new Date(),
}
setChatMessages((prev) => [...prev, aiMessage])
setIsLoading(false)
}, 1500)
setSearchQuery("")
// 处理搜索
const handleSearch = () => {
if (searchQuery.trim()) {
router.push(`/intelligent-search?q=${encodeURIComponent(searchQuery)}`)
}
}
// 格式化数字显示
@@ -81,229 +113,261 @@ export default function HomePage() {
return num.toString()
}
// 大模型选项
const modelOptions = [
{ value: "gpt-5", label: "GPT-5", provider: "OpenAI" },
{ value: "gpt-4", label: "GPT-4", provider: "OpenAI" },
{ value: "claude-3", label: "Claude-3", provider: "Anthropic" },
{ value: "gemini-pro", label: "Gemini Pro", provider: "Google" },
{ value: "qwen-max", label: "通义千问", provider: "阿里云" },
{ value: "baichuan", label: "百川大模型", provider: "百川智能" },
]
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-6">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600">AI助手平台</p>
</div>
{/* 核心统计数据 - 带图标的图表 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card className="border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-blue-600">{formatNumber(stats.userCount)}</p>
</div>
<div className="p-3 bg-blue-500 rounded-full">
<Users className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-purple-100">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-purple-600">{formatNumber(stats.userAssets)}</p>
</div>
<div className="p-3 bg-purple-500 rounded-full">
<BarChart3 className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-2 border-green-200 bg-gradient-to-r from-green-50 to-green-100">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-green-600">{stats.dataVolume}</p>
</div>
<div className="p-3 bg-green-500 rounded-full">
<Database className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-2 border-orange-200 bg-gradient-to-r from-orange-50 to-orange-100">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">AI查询</p>
<p className="text-2xl font-bold text-orange-600">{formatNumber(stats.aiQueries)}</p>
</div>
<div className="p-3 bg-orange-500 rounded-full">
<Brain className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 整合AI对话与数据搜索功能 */}
<Card className="mb-6 border-2 border-indigo-200 bg-gradient-to-r from-indigo-50 to-blue-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-indigo-600" />
AI智能助手
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 大模型选择 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">:</span>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelOptions.map((model) => (
<SelectItem key={model.value} value={model.value}>
<div className="flex items-center gap-2">
<Brain className="w-4 h-4" />
<span>{model.label}</span>
<Badge variant="secondary" className="text-xs">
{model.provider}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="container mx-auto px-4 py-8">
{/* 页面标题和搜索 */}
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
{/* 搜索输入框 */}
<div className="relative">
<MessageSquare className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<Input
placeholder="向AI提问例如分析用户增长趋势、搜索数据库信息..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleAIChat()}
className="pl-12 pr-24 h-12 text-base border-2 border-gray-200 focus:border-indigo-500 rounded-lg"
/>
<div className="flex items-center gap-4">
<Badge variant="outline" className="text-green-600 border-green-200">
<Activity className="w-3 h-3 mr-1" />
</Badge>
<Button
onClick={handleAIChat}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-md"
variant="outline"
size="sm"
onClick={refreshData}
disabled={isRefreshing}
className="flex items-center gap-2 bg-transparent"
>
<Send className="w-4 h-4 mr-2" />
{isLoading ? "思考中..." : "提问"}
<RefreshCw className={`w-4 h-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{showChat && chatMessages.length > 0 && (
<div className="mt-4 p-4 bg-white rounded-lg border max-h-64 overflow-y-auto">
<h4 className="font-medium mb-3 text-gray-700"></h4>
<div className="space-y-3">
{chatMessages.map((message) => (
<div
key={message.id}
className={`flex ${message.type === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
message.type === "user" ? "bg-indigo-500 text-white" : "bg-gray-100 text-gray-800"
}`}
>
<p className="text-sm">{message.content}</p>
<p className="text-xs opacity-70 mt-1">{message.timestamp.toLocaleTimeString()}</p>
</div>
{/* 搜索框 */}
<div className="relative max-w-2xl">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<Input
placeholder="搜索用户或流量关键词..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
className="pl-12 pr-24 h-14 text-lg border-2 border-gray-200 focus:border-blue-500 rounded-xl shadow-sm"
/>
<Button
onClick={handleSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-lg"
>
</Button>
</div>
</div>
{/* 核心数据展示 - 40亿用户为中心 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* 用户总数 - 主要指标 */}
<Card className="lg:col-span-2 border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-2xl">
<div className="p-3 bg-blue-500 rounded-xl">
<Users className="w-8 h-8 text-white" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="text-center">
<div className="text-6xl font-bold text-blue-600 mb-2">{formatNumber(systemStats.userCount)}</div>
<div className="text-lg text-gray-600"></div>
</div>
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
{growthData.map((data, index) => (
<div key={index} className="text-center">
<div className="text-2xl font-bold text-green-600">+{data.userGrowth.toFixed(1)}%</div>
<div className="text-sm text-gray-500">{data.period}</div>
<div className="text-xs text-gray-400 mt-1">: {formatNumber(data.activeUsers)}</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 text-gray-800 px-4 py-2 rounded-lg">
<p className="text-sm">AI正在思考...</p>
</div>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* 功能图表展示 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => router.push("/user-pool")}>
<CardContent className="p-6 text-center">
<div className="p-4 bg-purple-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<Users className="w-8 h-8 text-purple-600" />
</div>
<h3 className="font-semibold mb-2"></h3>
<p className="text-sm text-gray-500"></p>
<div className="mt-4 flex justify-center">
<Badge variant="secondary">
<Activity className="w-3 h-3 mr-1" />
</Badge>
</div>
</CardContent>
</Card>
{/* 系统状态 */}
<Card className="border-2 border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3">
<div className="p-2 bg-green-500 rounded-lg">
<Activity className="w-6 h-6 text-white" />
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Badge variant="secondary">{systemStats.avgResponseTime}ms</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Badge variant="secondary">{systemStats.versionCount}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Badge variant="secondary">{systemStats.cacheSize}MB</Badge>
</div>
<div className="text-xs text-gray-500 pt-2 border-t">: {lastUpdate.toLocaleTimeString()}</div>
</CardContent>
</Card>
</div>
{/* 数据增长趋势 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="p-2 bg-purple-500 rounded-lg">
<TrendingUp className="w-6 h-6 text-white" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{growthData.map((data, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold">{data.period}</div>
<div className="text-sm text-gray-500">: {formatNumber(data.activeUsers)}</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-purple-600">+{data.userGrowth.toFixed(1)}%</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-2 border-orange-200 bg-gradient-to-r from-orange-50 to-yellow-50">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="p-2 bg-orange-500 rounded-lg">
<Database className="w-6 h-6 text-white" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-500"> {formatNumber(systemStats.keywordCount)} </div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-orange-600">+{growthData[0].dataGrowth.toFixed(1)}%</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-orange-600">
{formatNumber(Math.floor(systemStats.userCount * 0.001))}
</div>
<div className="text-xs text-gray-500">/</div>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-orange-600">
{Math.floor(systemStats.userCount / 1000000)}TB
</div>
<div className="text-xs text-gray-500">使</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 快速访问入口 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card
className="hover:shadow-md transition-shadow cursor-pointer"
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-4 bg-blue-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-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 mb-2"></h3>
<p className="text-sm text-gray-500">AI模型</p>
<div className="mt-4 flex justify-center">
<Badge variant="secondary">
<Zap className="w-3 h-3 mr-1" />
</Badge>
</div>
<h3 className="font-semibold text-lg mb-2"></h3>
<p className="text-sm text-gray-600">AI模型</p>
</CardContent>
</Card>
<Card
className="hover:shadow-md transition-shadow cursor-pointer"
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-4 bg-green-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<Brain className="w-8 h-8 text-green-600" />
<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 mb-2">AI助手</h3>
<p className="text-sm text-gray-500"></p>
<div className="mt-4 flex justify-center">
<Badge variant="secondary">
<MessageSquare className="w-3 h-3 mr-1" />
</Badge>
<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>
{/* 快速指标示例(可后续接入真实数据) */}
<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>
{/* PC端隐藏底部导航栏 */}
<div className="block md:hidden">
<BottomTabs />
</div>
<Toaster />
</div>
)
}

View File

@@ -1,311 +1,96 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ArrowLeft, User, Tag, TrendingUp, DollarSign, Calendar, Phone, Mail, MapPin } from "lucide-react"
import { useParams } from "next/navigation"
import MobileHeader from "@/app/components/MobileHeader"
import BottomNav from "@/app/components/BottomNav"
import Section from "@/components/user-portrait/mobile/section"
import ProfileHeader from "@/components/user-portrait/mobile/profile-header"
import MetricsRFM from "@/components/user-portrait/mobile/metrics-rfm"
import InteractionsList from "@/components/user-portrait/mobile/interactions-list"
import PurchaseHistory from "@/components/user-portrait/mobile/purchase-history"
import WechatAccounts from "@/components/user-portrait/mobile/wechat-accounts"
type UserDetail = {
type Detail = {
id: string
name: string
avatar?: string
email: string
phone: string
city: string
store: string
project: string
team: string
tags: string[]
persona: string[]
source: string
recency: number
frequency: number
monetary: number
rfmScore: number
assetValue: number
riskLevel: "低" | "中" | "高"
lastActivity: string
interactions: { id: string; type: string; time: string; note?: string }[]
purchaseHistory: { id: string; amount: number; time: string; item: string }[]
wechatAccounts: { id: string; nickname: string; avatar?: string }[]
status: "活跃" | "沉睡" | "流失风险"
detailedTags: {
category: string
tags: { name: string; confidence: number; source: string }[]
}[]
assetBreakdown: {
category: string
value: number
percentage: number
}[]
}
export default function UserDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const [data, setData] = useState<UserDetail | null>(null)
const [data, setData] = useState<Detail | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!params?.id) return
setLoading(true)
setTimeout(() => {
setData({
id: params.id,
name: "张三",
email: "zhangsan@example.com",
phone: "138****8888",
city: "厦门",
store: "思明店",
project: "贷款业务",
team: "A组",
tags: ["高价值客户", "活跃用户", "信用良好"],
persona: ["高净值人群", "投资偏好保守"],
source: "微信推广",
recency: 85,
frequency: 92,
monetary: 88,
rfmScore: 88,
assetValue: 1250000,
riskLevel: "低",
lastActivity: "2024-01-15 14:30:00",
status: "活跃",
detailedTags: [
{
category: "行为标签",
tags: [
{ name: "高频访问", confidence: 95, source: "系统分析" },
{ name: "深度浏览", confidence: 88, source: "行为追踪" },
{ name: "主动咨询", confidence: 92, source: "客服记录" },
],
},
{
category: "偏好标签",
tags: [
{ name: "理财产品", confidence: 90, source: "购买记录" },
{ name: "保险产品", confidence: 75, source: "浏览记录" },
{ name: "投资咨询", confidence: 85, source: "咨询记录" },
],
},
{
category: "风险标签",
tags: [
{ name: "信用优良", confidence: 98, source: "征信报告" },
{ name: "收入稳定", confidence: 92, source: "银行流水" },
{ name: "资产充足", confidence: 88, source: "资产证明" },
],
},
],
assetBreakdown: [
{ category: "客户生命周期价值", value: 450000, percentage: 36 },
{ category: "交叉销售潜力", value: 320000, percentage: 26 },
{ category: "推荐价值", value: 280000, percentage: 22 },
{ category: "品牌忠诚度价值", value: 200000, percentage: 16 },
],
})
setLoading(false)
}, 500)
fetch(`/api/users?id=${params.id}`)
.then((r) => r.json())
.then((res) => setData(res?.data ?? null))
.finally(() => setLoading(false))
}, [params?.id])
if (loading) {
return (
<div className="min-h-screen bg-slate-50 p-4">
<div className="animate-pulse space-y-4">
<div className="h-12 bg-slate-200 rounded"></div>
<div className="h-32 bg-slate-200 rounded"></div>
<div className="h-48 bg-slate-200 rounded"></div>
</div>
</div>
)
}
if (!data) {
return (
<div className="min-h-screen bg-slate-50 p-4 flex items-center justify-center">
<div className="text-center">
<p className="text-slate-600 mb-4"></p>
<Button onClick={() => router.back()}></Button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<div className="bg-white border-b px-4 py-3 flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="font-semibold"></h1>
</div>
<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">
{loading ? (
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-muted-foreground">
</div>
) : data ? (
<div className="space-y-4">
<section className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
<ProfileHeader
name={data.name}
avatar={data.avatar}
email={data.email}
phone={data.phone}
tags={data.tags}
/>
</section>
<div className="container mx-auto px-4 py-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{data.name}</h2>
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
<span className="flex items-center gap-1">
<Phone className="h-3 w-3" />
{data.phone}
</span>
<span className="flex items-center gap-1">
<Mail className="h-3 w-3" />
{data.email}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{data.city} · {data.store}
</span>
<span>
{data.project} · {data.team}
</span>
</div>
</div>
<Badge variant={data.status === "活跃" ? "default" : "secondary"}>{data.status}</Badge>
</div>
<Section title="RFM 指标">
<MetricsRFM
recency={data.recency}
frequency={data.frequency}
monetary={data.monetary}
rfmScore={data.rfmScore}
/>
</Section>
<div className="flex flex-wrap gap-2">
{data.persona.map((p) => (
<Badge key={p} variant="outline" className="text-xs">
{p}
</Badge>
))}
</div>
</CardContent>
</Card>
<Section title="互动记录">
<InteractionsList items={data.interactions} />
</Section>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">¥{data.assetValue.toLocaleString()}</div>
<div className="text-xs text-slate-600"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{data.rfmScore}</div>
<div className="text-xs text-slate-600">RFM评分</div>
</div>
<div className="text-center">
<div
className={`text-2xl font-bold ${data.riskLevel === "低" ? "text-green-600" : data.riskLevel === "中" ? "text-yellow-600" : "text-red-600"}`}
>
{data.riskLevel}
</div>
<div className="text-xs text-slate-600"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{data.source}</div>
<div className="text-xs text-slate-600"></div>
</div>
</div>
<Section title="购买历史">
<PurchaseHistory items={data.purchaseHistory} />
</Section>
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
{data.assetBreakdown.map((item) => (
<div key={item.category} className="flex items-center justify-between">
<span className="text-sm">{item.category}</span>
<div className="flex items-center gap-2">
<div className="w-20 bg-slate-200 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${item.percentage}%` }}></div>
</div>
<span className="text-sm font-medium">¥{item.value.toLocaleString()}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{data.detailedTags.map((category) => (
<div key={category.category} className="space-y-2">
<h4 className="font-medium text-sm text-slate-700">{category.category}</h4>
<div className="grid gap-2">
{category.tags.map((tag) => (
<div key={tag.name} className="flex items-center justify-between p-2 bg-slate-50 rounded-md">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{tag.name}
</Badge>
<span className="text-xs text-slate-600">: {tag.source}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-12 bg-slate-200 rounded-full h-1">
<div className="bg-green-500 h-1 rounded-full" style={{ width: `${tag.confidence}%` }}></div>
</div>
<span className="text-xs font-medium">{tag.confidence}%</span>
</div>
</div>
))}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
RFM详细指标
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-xl font-bold">{data.recency}</div>
<div className="text-xs text-slate-600"> (R)</div>
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${data.recency}%` }}></div>
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold">{data.frequency}</div>
<div className="text-xs text-slate-600"> (F)</div>
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
<div className="bg-green-500 h-2 rounded-full" style={{ width: `${data.frequency}%` }}></div>
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold">{data.monetary}</div>
<div className="text-xs text-slate-600"> (M)</div>
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
<div className="bg-purple-500 h-2 rounded-full" style={{ width: `${data.monetary}%` }}></div>
</div>
</div>
</div>
<div className="mt-4 text-center">
<div className="text-sm text-slate-600"></div>
<div className="flex items-center justify-center gap-1 mt-1">
<Calendar className="h-3 w-3" />
<span className="text-sm">{data.lastActivity}</span>
</div>
</div>
</CardContent>
</Card>
</div>
<Section title="绑定微信账号">
<WechatAccounts accounts={data.wechatAccounts.map((w) => ({ id: w.id, nickname: w.nickname, avatar: w.avatar }))} />
</Section>
</div>
) : (
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-red-600">
</div>
)}
</main>
<BottomNav />
</div>
)
}

View File

@@ -1,303 +1,239 @@
"use client"
import type React from "react"
import { useRouter } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
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 { Input } from "@/components/ui/input"
import { Slider } from "@/components/ui/slider"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Search, Filter, Users, TrendingUp } from "lucide-react"
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"
type ListItem = {
type User = {
id: string
name: string
phone: string
email: string
tags: string[]
rfmScore: number
lastActiveAt: string
city: string
store: string
project: string
team: string
persona: string[]
source: string
lastActivity: string
status: "活跃" | "沉睡" | "已封禁"
}
type Meta = {
tags: string[]
cities: string[]
personas: string[]
sources: string[]
}
type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } }
export default function UserPoolPage() {
const router = useRouter()
const [q, setQ] = useState("")
const [rfmRange, setRfmRange] = useState<[number, number]>([0, 100])
const [items, setItems] = useState<ListItem[]>([])
const [meta, setMeta] = useState<Meta>({ tags: [], cities: [], personas: [], sources: [] })
const [filters, setFilters] = useState({
tags: [] as string[],
status: [] as string[],
city: [] as string[],
persona: [] as string[],
source: [] as string[],
})
const [totalValue, setTotalValue] = useState(0)
export default function UserPortraitPage() {
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
useEffect(() => {
fetch("/api/users?meta=tags")
.then((r) => r.json())
.then((res) => setMeta(res.data))
}, [])
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 (q) p.set("q", q)
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(","))
if (filters.city.length) p.set("city", filters.city.join(","))
if (filters.persona.length) p.set("persona", filters.persona.join(","))
if (filters.source.length) p.set("source", filters.source.join(","))
p.set("rfmMin", String(rfmRange[0]))
p.set("rfmMax", String(rfmRange[1]))
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()
}, [q, filters, rfmRange])
}, [searchQuery, filters])
useEffect(() => {
fetch(`/api/users?${queryString}`)
.then((r) => r.json())
.then((res) => {
setItems(res.data.items)
setTotalValue(res.data.totalValue)
.then((res: UsersResponse) => {
if (res?.success) {
setUsers(res.data.items)
setTotal(res.data.total)
}
})
.catch(() => {})
}, [queryString])
const toggle = (key: keyof typeof filters, v: string) =>
setFilters((prev) => ({
...prev,
[key]: prev[key].includes(v) ? prev[key].filter((x) => x !== v) : [...prev[key], v],
}))
useEffect(() => {
fetch("/api/users?meta=tags")
.then((r) => r.json())
.then((res: any) => setAllTags(res?.data?.tags ?? []))
.catch(() => {})
}, [])
const handleUserClick = (userId: string) => {
router.push(`/user-portrait/${userId}`)
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)
}
})
}
}
return (
<div className="container mx-auto px-4 py-5 space-y-4">
{/* 核心板块:用户估值概览 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-xl flex items-center gap-2">
<Users className="h-5 w-5" />
·
</CardTitle>
<p className="text-sm text-muted-foreground"> RFM </p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<Stat label="当前筛选用户数" value={`${items.length}`} />
<Stat label="估值总额(≈)" value={`¥ ${totalValue.toLocaleString("zh-CN")}`} />
<Stat label="RFM 范围" value={`${rfmRange[0]} - ${rfmRange[1]}`} />
<Stat label="已选标签数" value={`${filters.tags.length}`} />
</div>
</CardContent>
</Card>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
<MobileHeader onMenuToggle={() => {}} title="用户画像" />
{/* 筛选区:清晰多维度 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-col md:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索姓名/手机/邮箱/标签/画像标签"
className="pl-8"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<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>
<FilterRow title="RFM" right={<span className="text-xs text-muted-foreground">0~100</span>}>
<Slider
value={rfmRange}
onValueChange={(v) => setRfmRange(v as [number, number])}
min={0}
max={100}
step={1}
/>
</FilterRow>
<FilterRow title="城市">
<Chips options={meta.cities} actives={filters.city} onToggle={(v) => toggle("city", v)} />
</FilterRow>
<FilterRow title="标签">
<Chips options={meta.tags} actives={filters.tags} onToggle={(v) => toggle("tags", v)} />
</FilterRow>
<FilterRow title="画像( Persona )">
<Chips options={meta.personas} actives={filters.persona} onToggle={(v) => toggle("persona", v)} />
</FilterRow>
<FilterRow title="来源">
<Chips options={meta.sources} actives={filters.source} onToggle={(v) => toggle("source", v)} />
</FilterRow>
</CardContent>
</Card>
{/* 列表 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center justify-between">
<span></span>
<div className="flex gap-2">
<button className="text-xs px-3 py-1 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100">
</button>
<button className="text-xs px-3 py-1 bg-green-50 text-green-600 rounded-md hover:bg-green-100">
</button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="hidden md:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead>/</TableHead>
<TableHead>Persona</TableHead>
<TableHead></TableHead>
<TableHead className="text-center">RFM</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((u) => (
<TableRow
key={u.id}
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleUserClick(u.id)}
>
<TableCell className="font-medium">
{u.name}
<div className="text-xs text-muted-foreground">{u.email}</div>
</TableCell>
<TableCell>
{u.city} / {u.store}
</TableCell>
<TableCell>
{u.project} / {u.team}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{u.persona.map((p) => (
<Badge key={p} variant="secondary" className="text-[10px]">
{p}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{u.tags.slice(0, 3).map((t) => (
<Badge key={t} className="text-[10px]">
{t}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-center">{u.rfmScore}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3 text-green-500" />
<span className="text-xs text-green-600"></span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="text-sm text-muted-foreground"> {total} </div>
</div>
{/* 移动端卡片 */}
<div className="md:hidden space-y-2">
{items.map((u) => (
<div
key={u.id}
className="border rounded-md p-3 cursor-pointer hover:bg-slate-50"
onClick={() => handleUserClick(u.id)}
>
<div className="font-medium">{u.name}</div>
<div className="text-xs text-muted-foreground">
{u.city} · {u.store} · RFM {u.rfmScore}
<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="mt-1 flex flex-wrap gap-1">
{u.persona.map((p) => (
<Badge key={p} variant="secondary" className="text-[10px]">
{p}
</Badge>
<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>
<div className="mt-1 flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-green-500" />
<span className="text-xs text-green-600"></span>
</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>
</CardContent>
</Card>
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
)
}
function FilterRow({ title, children, right }: { title: string; children: React.ReactNode; right?: React.ReactNode }) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">{title}</div>
{right}
</div>
{children}
</div>
)
}
function Chips({
options,
actives,
onToggle,
}: { options: string[]; actives: string[]; onToggle: (v: string) => void }) {
return (
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<button
key={o}
onClick={() => onToggle(o)}
className={`px-2 py-1 rounded-full text-xs border ${actives.includes(o) ? "bg-slate-900 text-white" : "bg-white hover:bg-slate-50"}`}
>
{o}
</button>
))}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsAddingUser(false)}></Button>
<Button onClick={handleAddUser}></Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,11 +1,8 @@
"use client"
import { CardDescription } from "@/components/ui/card"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -30,18 +27,6 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
type U = {
id: string
city: string
store: string
privateDomain: string
project: string
team: string
rfmScore: number
}
type R = { items: (U & { valueScore: number })[] }
// 使用通用公司名称和当天时间线的用户数据
const mockUsers = [
{
@@ -177,64 +162,6 @@ export default function UserValuationPage() {
analysis: true,
upgradePaths: false,
})
const [data, setData] = useState<R | null>(null)
const city = useMemo(() => {
const m = new Map<string, { count: number; value: number }>()
mockUsers.forEach((i) => {
const k = String(i.city)
const cur = m.get(k) ?? { count: 0, value: 0 }
cur.count += 1
cur.value += i.rfmScore * 100
m.set(k, cur)
})
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
}, [])
const store = useMemo(() => {
const m = new Map<string, { count: number; value: number }>()
mockUsers.forEach((i) => {
const k = String(i.store)
const cur = m.get(k) ?? { count: 0, value: 0 }
cur.count += 1
cur.value += i.rfmScore * 100
m.set(k, cur)
})
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
}, [])
const project = useMemo(() => {
const m = new Map<string, { count: number; value: number }>()
mockUsers.forEach((i) => {
const k = String(i.project)
const cur = m.get(k) ?? { count: 0, value: 0 }
cur.count += 1
cur.value += i.rfmScore * 100
m.set(k, cur)
})
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
}, [])
const team = useMemo(() => {
const m = new Map<string, { count: number; value: number }>()
mockUsers.forEach((i) => {
const k = String(i.team)
const cur = m.get(k) ?? { count: 0, value: 0 }
cur.count += 1
cur.value += i.rfmScore * 100
m.set(k, cur)
})
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
}, [])
useEffect(() => {
fetch("/api/users?pageSize=500")
.then((r) => r.json())
.then((res) => {
const items = res.data.items.map((x: any) => ({ ...x, valueScore: x.rfmScore * 100 }))
setData({ items })
})
}, [])
const filteredUsers = mockUsers.filter(
(user) =>
@@ -298,20 +225,6 @@ export default function UserValuationPage() {
</div>
</div>
{/* 用户资产估值 */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-xl"></CardTitle>
<p className="text-sm text-muted-foreground"></p>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Section title="按城市分布" items={city} />
<Section title="按门店分布" items={store} />
<Section title="按项目分布" items={project} />
<Section title="按团队分布" items={team} />
</CardContent>
</Card>
{/* 用户估值概览 */}
<Collapsible
open={expandedSections.overview}
@@ -956,23 +869,3 @@ export default function UserValuationPage() {
</div>
)
}
function Section({ title, items }: { title: string; items: [string, { count: number; value: number }][] }) {
return (
<div className="rounded-md border">
<div className="px-3 py-2 border-b text-sm font-medium">{title}</div>
<div className="p-3 space-y-2">
{items.slice(0, 8).map(([k, v]) => (
<div key={k} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span>{k}</span>
<Badge variant="secondary">{v.count} </Badge>
</div>
<div className="font-semibold">¥ {Math.round(v.value).toLocaleString("zh-CN")}</div>
</div>
))}
{items.length === 0 && <div className="text-xs text-muted-foreground"></div>}
</div>
</div>
)
}

View File

@@ -1,61 +0,0 @@
export default function Loading() {
return (
<div className="min-h-screen bg-[#F8F9FA] pb-20">
<header className="sticky top-0 z-10 bg-white">
<div className="flex items-center h-14 px-4">
<div className="w-6 h-6 bg-gray-200 rounded animate-pulse" />
<div className="ml-2 w-32 h-5 bg-gray-200 rounded animate-pulse" />
</div>
</header>
<div className="mt-8">
{/* Step Indicator Skeleton */}
<div className="flex justify-center items-center space-x-4 px-6">
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
<div className="w-16 h-1 bg-gray-200 rounded animate-pulse" />
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
<div className="w-16 h-1 bg-gray-200 rounded animate-pulse" />
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
</div>
{/* Content Skeleton */}
<div className="mt-8 px-6">
<div className="bg-white rounded-lg p-6 space-y-6">
<div className="space-y-2">
<div className="w-20 h-4 bg-gray-200 rounded animate-pulse" />
<div className="w-full h-10 bg-gray-200 rounded animate-pulse" />
</div>
<div className="space-y-2">
<div className="w-32 h-4 bg-gray-200 rounded animate-pulse" />
<div className="flex space-x-2">
<div className="w-24 h-10 bg-gray-200 rounded animate-pulse" />
<div className="w-8 h-4 bg-gray-200 rounded animate-pulse" />
<div className="w-24 h-10 bg-gray-200 rounded animate-pulse" />
</div>
</div>
<div className="space-y-2">
<div className="w-24 h-4 bg-gray-200 rounded animate-pulse" />
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
<div className="w-8 h-4 bg-gray-200 rounded animate-pulse" />
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
<div className="w-16 h-4 bg-gray-200 rounded animate-pulse" />
</div>
</div>
<div className="w-full h-10 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
{/* Bottom Navigation Skeleton */}
<nav className="fixed bottom-0 left-0 right-0 h-16 bg-white border-t flex items-center justify-around px-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col items-center space-y-1">
<div className="w-6 h-6 bg-gray-200 rounded animate-pulse" />
<div className="w-12 h-3 bg-gray-200 rounded animate-pulse" />
</div>
))}
</nav>
</div>
)
}

View File

@@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}

View File

@@ -33,14 +33,6 @@ interface ApiEndpoint {
authentication: "API Key" | "OAuth 2.0" | "None"
}
function openInNewTab(url: string) {
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (e) {
console.error(e)
}
}
export function ApiDocumentation() {
const { toast } = useToast()
const [activeTab, setActiveTab] = useState("user-data")
@@ -624,19 +616,15 @@ export function ApiDocumentation() {
</div>
<div className="flex justify-between items-center mt-4">
<Button
variant="outline"
className="gap-2 bg-transparent"
onClick={() => openInNewTab("/api/openapi?download=1")}
>
<Button variant="outline" className="gap-2">
<FileJson className="h-4 w-4" />
OpenAPI规范
</Button>
<Button variant="outline" className="gap-2 bg-transparent" onClick={() => openInNewTab("/api/openapi")}>
<Button variant="outline" className="gap-2">
<Code className="h-4 w-4" />
SDK
</Button>
<Button className="gap-2" onClick={() => openInNewTab("/api/ingest")}>
<Button className="gap-2">
<Play className="h-4 w-4" />
API测试工具
</Button>

View File

@@ -1,6 +1,6 @@
"use client"
'use client'
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from 'react'
type Row = {
id: string
@@ -12,38 +12,9 @@ type Row = {
tags: string[]
}
type Pagination = { page: number; pageSize: number; total: number; totalPages: number }
// The API may return different shapes; normalize them here.
function normalizeResponse(json: any): { items: Row[]; total: number; pagination?: Pagination } {
// v2 shape: { success, data: { items, pagination, totalValue } }
if (json && json.data && Array.isArray(json.data.items)) {
const items = json.data.items as Row[]
const total = json.data?.pagination?.total ?? json.total ?? items.length
return { items, total, pagination: json.data.pagination }
}
// v1 shape: { data: Row[], pagination }
if (json && Array.isArray(json.data)) {
const items = json.data as Row[]
const total = json.pagination?.total ?? json.total ?? items.length
return { items, total, pagination: json.pagination }
}
// shape: { items: Row[], pagination }
if (json && Array.isArray(json.items)) {
const items = json.items as Row[]
const total = json.pagination?.total ?? json.total ?? items.length
return { items, total, pagination: json.pagination }
}
// raw array
if (Array.isArray(json)) {
return { items: json as Row[], total: (json as Row[]).length }
}
// unknown shape
return { items: [], total: 0 }
type ApiResp = {
data: Row[]
pagination: { page: number; pageSize: number; total: number; totalPages: number }
}
export default function UserList({ queryString }: { queryString: string }) {
@@ -55,26 +26,23 @@ export default function UserList({ queryString }: { queryString: string }) {
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" })
const res = await fetch(`/api/users?${queryString}`, { signal: controller.signal, cache: 'no-store' })
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
const json = await res.json()
const { items, total } = normalizeResponse(json)
const json: ApiResp = await res.json()
if (!aborted) {
setData(Array.isArray(items) ? items : [])
setTotal(typeof total === "number" ? total : 0)
setData(json.data || [])
setTotal(json.pagination?.total || 0)
}
} catch (e: any) {
if (!aborted) setError(e?.message || "未知错误")
if (!aborted) setError(e?.message || '未知错误')
} finally {
if (!aborted) setLoading(false)
}
}
load()
return () => {
aborted = true
@@ -82,7 +50,7 @@ export default function UserList({ queryString }: { queryString: string }) {
}
}, [queryString])
const rows = useMemo(() => (Array.isArray(data) ? data : []), [data])
const rows = useMemo(() => data, [data])
if (loading) {
return <div className="rounded-lg border bg-white p-4">...</div>
@@ -119,22 +87,16 @@ export default function UserList({ queryString }: { queryString: string }) {
<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>
<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>
)}
{(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>
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}></td>
</tr>
)}
</tbody>

View File

@@ -1,43 +0,0 @@
"use client"
import Link from "next/link"
import { Home, Database, Users, TrendingUp, Bot } from "lucide-react"
import { cn } from "@/lib/utils"
import { usePathname } from "next/navigation"
export default function BottomTabs() {
const pathname = usePathname()
const tabs = [
{ href: "/", label: "首页", icon: Home },
{ href: "/data-platform", label: "数据中台", icon: Database },
{ href: "/user-portrait", label: "用户池", icon: Users }, // 改名为用户池
{ href: "/user-assets", label: "用户资产", icon: TrendingUp },
{ href: "/ai-assistant", label: "AI助手", icon: Bot },
]
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-40 border-t bg-white/90 backdrop-blur">
<ul className="mx-auto grid max-w-full grid-cols-5">
{tabs.map((t) => {
const active = pathname === t.href || (t.href !== "/" && pathname.startsWith(t.href))
const Icon = t.icon
return (
<li key={t.href}>
<Link
href={t.href}
className={cn(
"flex flex-col items-center justify-center py-2 text-xs",
active ? "text-slate-900" : "text-slate-600",
)}
>
<Icon className="h-4 w-4" />
<span className="mt-0.5 text-[10px]">{t.label}</span>
</Link>
</li>
)
})}
</ul>
</nav>
)
}

View File

@@ -1,44 +0,0 @@
"use client"
import Link from "next/link"
import { useMemo } from "react"
import { cn } from "@/lib/utils"
export default function TopNav({ activePath }: { activePath?: string }) {
const items = useMemo(
() => [
{ href: "/", label: "首页" },
{ href: "/data-platform", label: "数据中台" },
{ href: "/user-portrait", label: "用户画像" },
{ href: "/ai-assistant", label: "AI 助手" },
{ href: "/user-valuation", label: "用户资产估值" },
],
[],
)
return (
<nav className="flex h-14 items-center gap-2">
<div className="mr-4 text-lg font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
</div>
<ul className="flex items-center gap-1">
{items.map((it) => {
const active = activePath === it.href || (it.href !== "/" && activePath?.startsWith(it.href))
return (
<li key={it.href}>
<Link
href={it.href}
className={cn(
"px-3 py-1.5 rounded-md text-sm hover:bg-slate-100",
active && "bg-slate-900 text-white hover:bg-slate-900",
)}
>
{it.label}
</Link>
</li>
)
})}
</ul>
</nav>
)
}

View File

@@ -1,12 +1,12 @@
"use client"
'use client'
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from '@/components/ui/dialog'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@@ -15,8 +15,8 @@ const Command = React.forwardRef<
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
@@ -44,8 +44,8 @@ const CommandInput = React.forwardRef<
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
@@ -60,7 +60,7 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
))
@@ -87,8 +87,8 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
@@ -102,7 +102,7 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
@@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
className,
)}
{...props}
/>
@@ -131,14 +131,14 @@ const CommandShortcut = ({
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,

View File

@@ -1,19 +1,19 @@
"use client"
import { useMemo, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useEffect, useState } from "react"
import { X } from 'lucide-react'
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: string[]
status: Array<"活跃" | "沉睡" | "已封禁">
rfm: [number, number]
}
type Props = {
interface Props {
open: boolean
onOpenChange: (v: boolean) => void
allTags: string[]
@@ -21,122 +21,131 @@ type Props = {
onApply: (v: FilterValues) => void
}
const STATUS_OPTIONS = ["活跃", "沉睡", "已封禁"] as const
export default function FilterDrawer({
open,
onOpenChange,
allTags,
value,
onApply,
}: Props) {
export default function FilterDrawer({ open, onOpenChange, allTags, value, onApply }: Props) {
const [local, setLocal] = useState<FilterValues>(value)
// 同步外部变更
useMemo(() => setLocal(value), [value])
useEffect(() => setLocal(value), [value, open])
const toggleArrayVal = (arr: string[], val: string, checked: boolean) =>
checked ? Array.from(new Set([...arr, val])) : arr.filter((x) => x !== val)
const toggleTag = (t: string, checked: boolean) => {
setLocal((prev) => ({
...prev,
tags: checked ? Array.from(new Set([...prev.tags, t])) : prev.tags.filter((x) => x !== t),
}))
}
const toggleStatus = (s: "活跃" | "沉睡" | "已封禁", checked: boolean) => {
setLocal((prev) => ({
...prev,
status: checked ? Array.from(new Set([...prev.status, s])) : prev.status.filter((x) => x !== s),
}))
}
const apply = () => {
onApply(local)
onOpenChange(false)
}
const reset = () => {
const init: FilterValues = { tags: [], status: [], rfm: [0, 100] }
setLocal(init)
onApply(init)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div
className={`fixed inset-0 z-50 ${open ? "" : "pointer-events-none"} aria-modal`}
role="dialog"
aria-hidden={!open}
>
<div
className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`}
onClick={() => onOpenChange(false)}
/>
<aside
className={`absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl transition-transform duration-300
${open ? "translate-x-0" : "translate-x-full"}`}
aria-label="筛选"
>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold"></h2>
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} aria-label="关闭筛选">
<X className="h-5 w-5" />
</Button>
</div>
<div className="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 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 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] }
})
}
/>
{/* 状态 */}
<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 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>
)
})}
{/* 标签 */}
<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>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={() => {
onApply(local)
onOpenChange(false)
}}
>
</Button>
{/* 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>
</DialogContent>
</Dialog>
</aside>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "crypto"
import { randomUUID } from 'crypto'
export type UserStatus = "活跃" | "沉睡" | "流失风险"
export type UserStatus = '活跃' | '沉睡' | '流失风险'
export interface User {
id: string
@@ -13,106 +13,40 @@ export interface User {
rfmScore: number
createdAt: string
lastActiveAt: string
// 画像扩展
city: string
store: string
privateDomain: string
project: string
team: string
persona: string[]
source: "douyin" | "cunkebao_form_submit" | "touchkebao_call_in" | "pos_api" | "manual"
}
const cities = ["北京", "上海", "广州", "深圳", "成都", "杭州", "苏州", "武汉", "西安", "长沙"]
const stores = ["门店A1", "门店A2", "门店B1", "门店C3", "门店D2"]
const privateDomains = ["企业微信私域1", "社群A", "社群B", "公众号粉丝池"]
const projects = ["项目X", "项目Y", "项目Z", "活动618"]
const teams = ["团队一组", "团队二组", "团队增长", "客户成功"]
const personas = ["技术爱好者", "夜猫子", "内容创作者", "效率提升", "价格敏感", "品牌忠诚"]
const tagPool = [
"高价值",
"近7日活跃",
"新客",
"回流",
"社群达人",
"潜在复购",
"高互动",
"低客单",
"私域粉",
"公众号粉",
]
const statusPool: UserStatus[] = ["活跃", "沉睡", "流失风险"]
const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗']
const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰']
const tagPool = ['高价值','近7日活跃','新客','回流','社群达人','潜在复购','高互动','低客单','私域粉','公众号粉']
const statusPool: UserStatus[] = ['活跃','沉睡','流失风险']
const avatars = [
"/user-avatar-zhangsan.png",
"/user-avatar-lisi.png",
"/avatar-wanglei.png",
"/generic-user-avatar.png",
"/wechat-avatar-1.png",
"/wechat-avatar-2.png",
"/wechat-avatar-3.png",
'/user-avatar-zhangsan.png',
'/user-avatar-lisi.png',
'/avatar-wanglei.png',
'/generic-user-avatar.png',
'/wechat-avatar-1.png',
'/wechat-avatar-2.png',
'/wechat-avatar-3.png',
]
const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
const pick = <T,>(arr: T[]) => arr[rand(0, arr.length - 1)]
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",
}
return name
.split("")
.map((c) => map[c] ?? "u")
.join("")
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' }
return name.split('').map(c => map[c] ?? 'u').join('')
}
function randomPhone() {
const prefixes = [
"139",
"138",
"137",
"136",
"135",
"188",
"187",
"186",
"185",
"183",
"182",
"159",
"158",
"157",
"156",
"155",
]
return `${pick(prefixes)}${rand(1000, 9999)}${rand(1000, 9999)}`
const prefixes = ['139','138','137','136','135','188','187','186','185','184','183','182','159','158','157','156','155']
return `${pick(prefixes)}${rand(1000,9999)}${rand(1000,9999)}`
}
function randomTags() {
const count = rand(2, 4)
const count = rand(2,4)
const s = new Set<string>()
while (s.size < count) s.add(pick(tagPool))
return Array.from(s)
}
function timeNearNow(daysSpan = 90) {
const now = Date.now()
const offset = rand(0, daysSpan * 86400000)
@@ -121,96 +55,24 @@ function timeNearNow(daysSpan = 90) {
let cache: User[] | null = null
function seed(n = 240) {
const familyNames = [
"张",
"李",
"王",
"赵",
"刘",
"陈",
"杨",
"黄",
"周",
"吴",
"徐",
"孙",
"胡",
"朱",
"高",
"林",
"何",
"郭",
"马",
"罗",
]
const givenNames = [
"伟",
"芳",
"娜",
"敏",
"静",
"秀英",
"丽",
"强",
"磊",
"军",
"洋",
"艳",
"勇",
"杰",
"娟",
"涛",
"明",
"超",
"霞",
"平",
"俊",
"凯",
"佳",
"鑫",
"鹏",
"晨",
"倩",
"颖",
"梅",
"慧",
"雪",
"宇",
"涵",
"宁",
"璐",
"龙",
"震",
"航",
"璟",
"钰",
]
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 u: User = {
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: randomPhone(),
phone,
avatar: avatars[i % avatars.length],
tags: randomTags(),
status: pick(statusPool),
rfmScore: rand(20, 95),
rfmScore: rand(15, 95),
createdAt: timeNearNow(180),
lastActiveAt: timeNearNow(15),
city: pick(cities),
store: pick(stores),
privateDomain: pick(privateDomains),
project: pick(projects),
team: pick(teams),
persona: [pick(personas), Math.random() > 0.6 ? pick(personas) : undefined].filter(Boolean) as string[],
source: pick(["douyin", "cunkebao_form_submit", "touchkebao_call_in", "pos_api", "manual"]),
}
list.push(u)
})
}
return list
}
@@ -226,68 +88,76 @@ export interface QueryParams {
status?: UserStatus[]
rfmMin?: number
rfmMax?: number
city?: string[]
persona?: string[]
source?: string[]
page?: number
pageSize?: number
}
export function queryUsers(params: QueryParams) {
const { q, tags, status, rfmMin = 0, rfmMax = 100, city, persona, source, page = 1, pageSize = 20 } = params
const { q, tags, status, rfmMin = 0, rfmMax = 100, page = 1, pageSize = 20 } = params
let list = getUsersStore()
if (q && q.trim()) {
const s = q.trim().toLowerCase()
list = list.filter(
(u) =>
u.name.toLowerCase().includes(s) ||
u.email.toLowerCase().includes(s) ||
u.phone.includes(s) ||
u.tags.some((t) => t.toLowerCase().includes(s)) ||
u.persona.some((p) => p.toLowerCase().includes(s)),
list = list.filter(u =>
u.name.toLowerCase().includes(s) ||
u.email.toLowerCase().includes(s) ||
u.phone.includes(s) ||
u.tags.some(t => t.toLowerCase().includes(s)),
)
}
if (tags?.length) list = list.filter((u) => tags.every((t) => u.tags.includes(t)))
if (status?.length) list = list.filter((u) => status.includes(u.status))
list = list.filter((u) => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
if (city?.length) list = list.filter((u) => city.includes(u.city))
if (persona?.length) list = list.filter((u) => u.persona.some((p) => persona.includes(p)))
if (source?.length) list = list.filter((u) => source.includes(u.source))
if (tags?.length) {
list = list.filter(u => tags.every(t => u.tags.includes(t)))
}
if (status?.length) {
const st = new Set(status)
list = list.filter(u => st.has(u.status))
}
list = list.filter(u => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
const total = list.length
const start = (page - 1) * pageSize
const data = list.slice(start, start + pageSize)
const end = start + pageSize
const data = list.slice(start, end)
return {
data,
pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / 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(input: Partial<User>) {
const list = getUsersStore()
const now = new Date().toISOString()
const name = input.name ?? `${pick(familyNames)}${pick(givenNames)}`
const email = input.email ?? `${toPinyinLike(name)}@example.com`
const phone = input.phone ?? randomPhone()
const u: User = {
id: randomUUID(),
name: input.name ?? "新用户",
email: input.email ?? "user@example.com",
phone: input.phone ?? "13900000000",
avatar: input.avatar ?? avatars[0],
tags: input.tags ?? ["新客"],
status: input.status ?? "活跃",
rfmScore: input.rfmScore ?? 60,
name,
email,
phone,
avatar: input.avatar ?? avatars[rand(0, avatars.length - 1)],
tags: input.tags ?? randomTags(),
status: input.status ?? pick(statusPool),
rfmScore: input.rfmScore ?? rand(20, 80),
createdAt: now,
lastActiveAt: now,
city: input.city ?? pick(cities),
store: input.store ?? pick(stores),
privateDomain: input.privateDomain ?? pick(privateDomains),
project: input.project ?? pick(projects),
team: input.team ?? pick(teams),
persona: input.persona ?? [pick(personas)],
source: input.source ?? "manual",
}
list.unshift(u)
return u
}
export function getUserById(id: string) {
return getUsersStore().find(u => u.id === id) ?? null
}

View File

@@ -1,5 +1,5 @@
{
"name": "卡若数据资产中台",
"name": "卡若用户数据中台",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -63,7 +63,7 @@
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8.5",
"tailwindcss": "^3.4.17",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

98
pnpm-lock.yaml generated
View File

@@ -10,10 +10,10 @@ importers:
dependencies:
'@ai-sdk/openai':
specifier: latest
version: 2.0.17(zod@4.0.17)
version: 2.0.32(zod@4.1.11)
'@ant-design/plots':
specifier: latest
version: 2.6.3(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
version: 2.6.5(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
'@radix-ui/react-accordion':
specifier: latest
version: 1.2.12(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
@@ -82,7 +82,7 @@ importers:
version: 3.13.12(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
ai:
specifier: latest
version: 5.0.19(zod@4.0.17)
version: 5.0.49(zod@4.1.11)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -121,7 +121,7 @@ importers:
version: 18.0.0
react-day-picker:
specifier: latest
version: 9.9.0(react@18.0.0)
version: 9.11.0(react@18.0.0)
react-dom:
specifier: ^18
version: 18.0.0(react@18.0.0)
@@ -130,7 +130,7 @@ importers:
version: 9.16.0(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
recharts:
specifier: latest
version: 3.1.2(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1)
version: 3.2.1(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1)
regenerator-runtime:
specifier: latest
version: 0.14.1
@@ -142,7 +142,7 @@ importers:
version: 1.0.7(tailwindcss@3.4.17)
zod:
specifier: latest
version: 4.0.17
version: 4.1.11
devDependencies:
'@types/node':
specifier: ^22
@@ -166,7 +166,7 @@ importers:
specifier: ^8.5
version: 8.5.0
tailwindcss:
specifier: ^3.4.17
specifier: ^3.3.0
version: 3.4.17
typescript:
specifier: ^5
@@ -174,20 +174,20 @@ importers:
packages:
'@ai-sdk/gateway@1.0.9':
resolution: {integrity: sha512-kIfwunyUUwyBLg2KQcaRtjRQ1bDuJYPNIs4CNWaWPpMZ4SV5cRL1hLGMuX4bhfCJYDXHMGvJGLtUK6+iAJH2ZQ==}
'@ai-sdk/gateway@1.0.26':
resolution: {integrity: sha512-AfTkubvvHU+soI5IdIpPvXgdnNy56Kt//vBJxYNQ0eGwlVhSQ/SkCVMdQxcVDvdTvlEO46MHKuPaZnQnT5Zgxw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/openai@2.0.17':
resolution: {integrity: sha512-nt0Dvn3etQJwzJtS6XEUchZkDb3NAjn8yTmLZj1fF+F2pyUbiwKg4joW9kjsrDhcwOxdoQ26OyONsVLE9AWfMw==}
'@ai-sdk/openai@2.0.32':
resolution: {integrity: sha512-p7giSkCs66Q1qYO/NPYI41CrSg65mcm8R2uAdF86+Y1D1/q4mUrWMyf5UTOJ0bx/z4jIPiNgGDCg2Kabi5zrKQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider-utils@3.0.4':
resolution: {integrity: sha512-/3Z6lfUp8r+ewFd9yzHkCmPlMOJUXup2Sx3aoUyrdXLhOmAfHRl6Z4lDbIdV0uvw/QYoBcVLJnvXN7ncYeS3uQ==}
'@ai-sdk/provider-utils@3.0.9':
resolution: {integrity: sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
@@ -206,8 +206,8 @@ packages:
react: '>=16.8.4'
react-dom: '>=16.8.4'
'@ant-design/plots@2.6.3':
resolution: {integrity: sha512-9V6QND7QESzAlPu5bE56xTlUi2ctfDkdRiNY8SgG5/IW8InqW9SFfhO0sW5mIpTslsI8o/MgmysnSWED8h6adg==}
'@ant-design/plots@2.6.5':
resolution: {integrity: sha512-lzYMQdb5TWWfi/RnHpuJ9Iu3/WR8OJj2lYcyBlBOkCghOLstNw6sYSbGMGDIzPKp1AS1Fvomw5KwZzD/mdiykA==}
peerDependencies:
react: '>=16.8.4'
react-dom: '>=16.8.4'
@@ -1230,8 +1230,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
ai@5.0.19:
resolution: {integrity: sha512-I0yQO68Z1DdvjMc9be5scMYdsuBvpNneQDh6TUuUVlFXdCxAIm7yATnOaomfYTE2KaRDsrO+OSRPjJm4FSKioA==}
ai@5.0.49:
resolution: {integrity: sha512-7XVcmXbnAqG7waJqNcxKrzVW1Ck5fw4KhWxAyltKxnupOgFxH62ra1zEofym/KO3hPYq4aJ3/gTp1ZeLvlwLkQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
@@ -1821,9 +1821,9 @@ packages:
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
eventsource-parser@3.0.3:
resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==}
engines: {node: '>=20.0.0'}
eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2515,8 +2515,8 @@ packages:
rbush@3.0.1:
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
react-day-picker@9.9.0:
resolution: {integrity: sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==}
react-day-picker@9.11.0:
resolution: {integrity: sha512-L4FYOaPrr3+AEROeP6IG2mCORZZfxJDkJI2df8mv1jyPrNYeccgmFPZDaHyAuPCBCddQFozkxbikj2NhMEYfDQ==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
@@ -2594,8 +2594,8 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
recharts@3.1.2:
resolution: {integrity: sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g==}
recharts@3.2.1:
resolution: {integrity: sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3018,35 +3018,29 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
zod-to-json-schema@3.24.6:
resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==}
peerDependencies:
zod: ^3.24.1
zod@4.0.17:
resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==}
zod@4.1.11:
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
snapshots:
'@ai-sdk/gateway@1.0.9(zod@4.0.17)':
'@ai-sdk/gateway@1.0.26(zod@4.1.11)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.4(zod@4.0.17)
zod: 4.0.17
'@ai-sdk/provider-utils': 3.0.9(zod@4.1.11)
zod: 4.1.11
'@ai-sdk/openai@2.0.17(zod@4.0.17)':
'@ai-sdk/openai@2.0.32(zod@4.1.11)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.4(zod@4.0.17)
zod: 4.0.17
'@ai-sdk/provider-utils': 3.0.9(zod@4.1.11)
zod: 4.1.11
'@ai-sdk/provider-utils@3.0.4(zod@4.0.17)':
'@ai-sdk/provider-utils@3.0.9(zod@4.1.11)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.3
zod: 4.0.17
zod-to-json-schema: 3.24.6(zod@4.0.17)
eventsource-parser: 3.0.6
zod: 4.1.11
'@ai-sdk/provider@2.0.0':
dependencies:
@@ -3060,7 +3054,7 @@ snapshots:
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
'@ant-design/plots@2.6.3(react-dom@18.0.0(react@18.0.0))(react@18.0.0)':
'@ant-design/plots@2.6.5(react-dom@18.0.0(react@18.0.0))(react@18.0.0)':
dependencies:
'@ant-design/charts-util': 0.0.2(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
'@antv/event-emitter': 0.1.3
@@ -4217,13 +4211,13 @@ snapshots:
acorn@8.15.0: {}
ai@5.0.19(zod@4.0.17):
ai@5.0.49(zod@4.1.11):
dependencies:
'@ai-sdk/gateway': 1.0.9(zod@4.0.17)
'@ai-sdk/gateway': 1.0.26(zod@4.1.11)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.4(zod@4.0.17)
'@ai-sdk/provider-utils': 3.0.9(zod@4.1.11)
'@opentelemetry/api': 1.9.0
zod: 4.0.17
zod: 4.1.11
ajv@6.12.6:
dependencies:
@@ -4971,7 +4965,7 @@ snapshots:
eventemitter3@5.0.1: {}
eventsource-parser@3.0.3: {}
eventsource-parser@3.0.6: {}
fast-deep-equal@3.1.3: {}
@@ -5657,7 +5651,7 @@ snapshots:
dependencies:
quickselect: 2.0.0
react-day-picker@9.9.0(react@18.0.0):
react-day-picker@9.11.0(react@18.0.0):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
@@ -5736,7 +5730,7 @@ snapshots:
dependencies:
picomatch: 2.3.1
recharts@3.1.2(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1):
recharts@3.2.1(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.8.2(react-redux@9.2.0(@types/react@18.0.0)(react@18.0.0)(redux@5.0.1))(react@18.0.0)
clsx: 2.1.1
@@ -6291,8 +6285,4 @@ snapshots:
yaml@2.8.1: {}
zod-to-json-schema@3.24.6(zod@4.0.17):
dependencies:
zod: 4.0.17
zod@4.0.17: {}
zod@4.1.11: {}

View File

@@ -3,6 +3,6 @@ const config = {
plugins: {
tailwindcss: {},
},
};
}
export default config;
export default config

View File

@@ -1,31 +1,27 @@
// services/IdentityService.ts
// 统一身份识别服务(内存索引),用于 /api/ingest 流程中匹配或创建用户。
// 与 v1.4 文档的统一用户结构保持一致source_profiles / ai_insights / crm_info[^5]
// 身份识别与合并服务
// 实现基于数据字典标识的关键字段的匹配逻辑
import { getIdentityKeyFields } from "@/lib/data-dictionary"
export interface IdentityMatch {
userId: string
confidence: number
reasons: string[]
matchedFields: string[]
matchType: "exact" | "fuzzy" | "partial"
}
type IndexMaps = {
emailToUser: Map<string, string>
phoneToUser: Map<string, string>
usernameToUser: Map<string, string>
userStore: Map<string, Record<string, any>>
export interface UserIdentity {
userId: string
identityFields: Record<string, any>
}
export class IdentityService {
private static instance: IdentityService
private index: IndexMaps
private userIdentities: Map<string, UserIdentity> = new Map()
private constructor() {
this.index = {
emailToUser: new Map(),
phoneToUser: new Map(),
usernameToUser: new Map(),
userStore: new Map(),
}
// 初始化一些模拟数据
this.initializeMockData()
}
public static getInstance(): IdentityService {
@@ -35,84 +31,202 @@ export class IdentityService {
return IdentityService.instance
}
// 基于映射数据寻找可能的用户匹配
public async findMatchingIdentity(mappedData: Record<string, any>): Promise<IdentityMatch[]> {
private initializeMockData() {
// 模拟已存在的用户身份数据
const mockUsers = [
{
userId: "user_global_id_1",
identityFields: {
phone: "13800138000",
email: "zhangsan@example.com",
username: "zhangsan",
fullName: "张三",
},
},
{
userId: "user_global_id_2",
identityFields: {
phone: "13912345678",
email: "lisi@example.com",
username: "lisi",
fullName: "李四",
},
},
]
mockUsers.forEach((user) => {
this.userIdentities.set(user.userId, user)
})
}
// 根据输入数据查找匹配的用户身份
public async findMatchingIdentity(inputData: Record<string, any>): Promise<IdentityMatch[]> {
const identityKeyFields = getIdentityKeyFields()
const matches: IdentityMatch[] = []
const seen = new Set<string>()
const tryAdd = (userId: string | undefined, confidence: number, reason: string) => {
if (!userId) return
if (seen.has(userId)) return
seen.add(userId)
matches.push({ userId, confidence, reasons: [reason] })
}
if (mappedData.email) {
tryAdd(this.index.emailToUser.get(String(mappedData.email).toLowerCase()), 0.95, "email")
}
if (mappedData.phone) {
tryAdd(this.index.phoneToUser.get(String(mappedData.phone)), 0.92, "phone")
}
if (mappedData.username) {
tryAdd(this.index.usernameToUser.get(String(mappedData.username).toLowerCase()), 0.7, "username")
}
// 简单加权若同一userId命中多个关键字段提升置信度
const aggregated = new Map<string, IdentityMatch>()
for (const m of matches) {
const exists = aggregated.get(m.userId)
if (!exists) {
aggregated.set(m.userId, { ...m })
} else {
exists.confidence = Math.min(0.99, exists.confidence + 0.05)
exists.reasons = Array.from(new Set([...exists.reasons, ...m.reasons]))
for (const [userId, userIdentity] of this.userIdentities) {
const matchResult = this.calculateMatch(inputData, userIdentity.identityFields, identityKeyFields)
if (matchResult.confidence > 0) {
matches.push({
userId,
...matchResult,
})
}
}
return Array.from(aggregated.values()).sort((a, b) => b.confidence - a.confidence)
// 按置信度排序
return matches.sort((a, b) => b.confidence - a.confidence)
}
// 创建新身份
public async createNewIdentity(mappedData: Record<string, any>): Promise<string> {
const base =
(mappedData.phone && `phone_${mappedData.phone}`) ||
(mappedData.email && `email_${String(mappedData.email).toLowerCase()}`) ||
(mappedData.username && `uname_${String(mappedData.username).toLowerCase()}`) ||
`anon_${Date.now()}`
// 计算匹配度
private calculateMatch(
inputData: Record<string, any>,
existingData: Record<string, any>,
keyFields: string[],
): { confidence: number; matchedFields: string[]; matchType: "exact" | "fuzzy" | "partial" } {
const matchedFields: string[] = []
let exactMatches = 0
let fuzzyMatches = 0
let totalFields = 0
for (const field of keyFields) {
if (inputData[field] && existingData[field]) {
totalFields++
if (this.isExactMatch(inputData[field], existingData[field])) {
exactMatches++
matchedFields.push(field)
} else if (this.isFuzzyMatch(inputData[field], existingData[field])) {
fuzzyMatches++
matchedFields.push(field)
}
}
}
if (totalFields === 0) {
return { confidence: 0, matchedFields: [], matchType: "partial" }
}
const confidence = (exactMatches * 1.0 + fuzzyMatches * 0.7) / totalFields
let matchType: "exact" | "fuzzy" | "partial" = "partial"
if (exactMatches > 0 && fuzzyMatches === 0) {
matchType = "exact"
} else if (exactMatches > 0 || fuzzyMatches > 0) {
matchType = "fuzzy"
}
return { confidence, matchedFields, matchType }
}
// 精确匹配
private isExactMatch(value1: any, value2: any): boolean {
if (typeof value1 === "string" && typeof value2 === "string") {
return value1.toLowerCase().trim() === value2.toLowerCase().trim()
}
return value1 === value2
}
// 模糊匹配
private isFuzzyMatch(value1: any, value2: any): boolean {
if (typeof value1 === "string" && typeof value2 === "string") {
const str1 = value1.toLowerCase().trim()
const str2 = value2.toLowerCase().trim()
// 简单的相似度计算
const similarity = this.calculateStringSimilarity(str1, str2)
return similarity > 0.8
}
return false
}
// 计算字符串相似度
private calculateStringSimilarity(str1: string, str2: string): number {
const longer = str1.length > str2.length ? str1 : str2
const shorter = str1.length > str2.length ? str2 : str1
if (longer.length === 0) {
return 1.0
}
const editDistance = this.levenshteinDistance(longer, shorter)
return (longer.length - editDistance) / longer.length
}
// 计算编辑距离
private levenshteinDistance(str1: string, str2: string): number {
const matrix = []
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1)
}
}
}
return matrix[str2.length][str1.length]
}
// 创建新的用户身份
public async createNewIdentity(inputData: Record<string, any>): Promise<string> {
const userId = `user_global_id_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const identityKeyFields = getIdentityKeyFields()
const identityFields: Record<string, any> = {}
identityKeyFields.forEach((field) => {
if (inputData[field]) {
identityFields[field] = inputData[field]
}
})
this.userIdentities.set(userId, {
userId,
identityFields,
})
const userId = `user_${this.shortHash(base)}`
this.index.userStore.set(userId, { userId, ...mappedData })
this.bindIndexes(userId, mappedData)
return userId
}
// 更新身份(同步索引)
public async updateIdentity(userId: string, mappedData: Record<string, any>): Promise<void> {
const cur = this.index.userStore.get(userId) || { userId }
const updated = { ...cur, ...mappedData }
this.index.userStore.set(userId, updated)
this.bindIndexes(userId, mappedData)
}
private bindIndexes(userId: string, mappedData: Record<string, any>) {
if (mappedData.email) {
this.index.emailToUser.set(String(mappedData.email).toLowerCase(), userId)
}
if (mappedData.phone) {
this.index.phoneToUser.set(String(mappedData.phone), userId)
}
if (mappedData.username) {
this.index.usernameToUser.set(String(mappedData.username).toLowerCase(), userId)
// 更新用户身份信息
public async updateIdentity(userId: string, updateData: Record<string, any>): Promise<void> {
const existingIdentity = this.userIdentities.get(userId)
if (existingIdentity) {
const identityKeyFields = getIdentityKeyFields()
identityKeyFields.forEach((field) => {
if (updateData[field]) {
existingIdentity.identityFields[field] = updateData[field]
}
})
this.userIdentities.set(userId, existingIdentity)
}
}
private shortHash(str: string): string {
// 简单、稳定的字符串哈希djb2 变体),避免依赖
let h = 5381
for (let i = 0; i < str.length; i++) {
h = (h * 33) ^ str.charCodeAt(i)
// 合并用户身份
public async mergeIdentities(primaryUserId: string, secondaryUserId: string): Promise<void> {
const primaryIdentity = this.userIdentities.get(primaryUserId)
const secondaryIdentity = this.userIdentities.get(secondaryUserId)
if (primaryIdentity && secondaryIdentity) {
// 合并身份字段,优先保留主身份的数据
Object.keys(secondaryIdentity.identityFields).forEach((field) => {
if (!primaryIdentity.identityFields[field] && secondaryIdentity.identityFields[field]) {
primaryIdentity.identityFields[field] = secondaryIdentity.identityFields[field]
}
})
this.userIdentities.set(primaryUserId, primaryIdentity)
this.userIdentities.delete(secondaryUserId)
}
// 转为正数并截断
return (h >>> 0).toString(36)
}
}

View File

@@ -10,6 +10,8 @@
@layer base {
:root {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;

View File

@@ -1,74 +0,0 @@
// 订单表单数据相关类型定义
export interface OrderFormData {
// 基本信息
customerName: string
customerPhone: string
customerEmail?: string
// 产品信息
productId: string
productName: string
quantity: number
unitPrice: number
totalAmount: number
// 订单详情
orderType: "online" | "offline" | "phone"
paymentMethod: "wechat" | "alipay" | "card" | "cash" | "transfer"
deliveryMethod: "pickup" | "delivery" | "express"
// 地址信息(如需配送)
deliveryAddress?: {
province: string
city: string
district: string
street: string
zipCode?: string
}
// 时间信息
orderDate: string
expectedDeliveryDate?: string
// 备注和标签
notes?: string
tags?: string[]
// 来源信息
source: "website" | "app" | "phone" | "store" | "social" | "referral"
campaign?: string
referrer?: string
// 状态
status: "pending" | "confirmed" | "processing" | "shipped" | "delivered" | "cancelled"
// 元数据
metadata?: Record<string, any>
}
export interface OrderStats {
totalOrders: number
totalRevenue: number
avgOrderValue: number
conversionRate: number
topProducts: Array<{
productId: string
productName: string
quantity: number
revenue: number
}>
}
export interface OrderFilter {
dateRange?: {
start: string
end: string
}
status?: OrderFormData["status"]
paymentMethod?: OrderFormData["paymentMethod"]
source?: OrderFormData["source"]
minAmount?: number
maxAmount?: number
customerPhone?: string
productId?: string
}

View File

@@ -1,63 +0,0 @@
export type DeviceStatus = "online" | "offline" | "unknown" | "busy"
export type DeviceType = Device
export interface Device {
id: string
name: string
wechatId?: string
group?: string
tags?: string[]
os: "Android" | "iOS" | "Windows" | "Mac"
status: DeviceStatus
owner?: string
lastSeen?: string
}
export interface CreateDeviceParams {
name: string
wechatId?: string
group?: string
tags?: string[]
os: "Android" | "iOS" | "Windows" | "Mac"
}
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
}

View File

@@ -1,71 +0,0 @@
export type ScenarioStatus = "draft" | "running" | "paused" | "completed" | "active" | "archived"
export type Channel = "phone" | "wechat" | "douyin" | "xiaohongshu" | "api"
export interface ScenarioBase {
id: string
name: string
type: string
status: ScenarioStatus
creator: string
createdAt: string
updatedAt: string
description?: string
}
export interface Scenario {
id: string
name: string
channel: Channel
createdAt: string
status: ScenarioStatus
config?: Record<string, unknown>
}
export interface CreateScenarioParams {
name: string
type: string
channel: Channel
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: Channel
}
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
}

View File

@@ -1,59 +0,0 @@
// 流量用户相关类型定义
export interface TrafficUser {
id: string
userId?: string
sessionId: string
ip: string
userAgent: string
device: {
type: "mobile" | "tablet" | "desktop"
brand?: string
model?: string
os?: string
}
location?: {
country: string
region: string
city: string
latitude?: number
longitude?: number
}
referrer?: string
landingPage: string
currentPage: string
visitTime: string
duration: number
pageViews: number
isNewUser: boolean
source: "organic" | "direct" | "social" | "email" | "ads" | "referral"
campaign?: string
keywords?: string[]
events: TrafficEvent[]
}
export interface TrafficEvent {
id: string
type: "page_view" | "click" | "scroll" | "form_submit" | "download" | "custom"
timestamp: string
data: Record<string, any>
}
export interface TrafficStats {
totalUsers: number
newUsers: number
returningUsers: number
pageViews: number
avgDuration: number
bounceRate: number
}
export interface TrafficFilter {
dateRange?: {
start: string
end: string
}
source?: TrafficUser["source"]
device?: TrafficUser["device"]["type"]
location?: string
isNewUser?: boolean
}

View File

@@ -6,61 +6,18 @@
- 新增 Skeleton 组件与 moments-sync 编辑页 loading.tsx 作为 Suspense Fallback避免 useSearchParams 构建报错。
- 补齐 Toast / Toaster 组件的导出与实现,修复构建失败。
- 将“搜索入口”迁移并固定在首页;修复 /api/users 导出/导入冲突,稳定构建。
## 2025-08-09 身份识别服务与数据库结构 API 修复
- 完成内容:
1) 身份识别服务
- 新增 services/IdentityService.ts提供 findMatchingIdentity / createNewIdentity / updateIdentity 内存索引能力,支持基于 email/phone/username 的高置信匹配与合并。
- 与 v1.4 的统一用户结构source_profiles / ai_insights / crm_info设计一致保障数据接入的落库结构稳定 [^5]。
2) 数据库结构 API 修复
- 重写 app/api/database-structure/route.ts
- 无参数:返回数据库列表([{ Database: string }]),契合 DatabaseStructureViewer 预期。
- ?database返回该库完整结构。
- ?database&table返回指定表结构。
- 对齐前端 DatabaseStructureViewer 的取数流程(初始化先拉库列表,再按库取结构)。
3) OpenAPI 规范输出
- 新增 app/api/openapi/route.ts输出最小可用的 OpenAPI 3.1 规范(覆盖 /api/ingest 与 /api/database-structure
- 支持 ?download=1 作为附件下载。
4) API 文档页面联通
- 更新 components/data-integration/api-documentation.tsx
- “下载OpenAPI规范”直连 /api/openapi?download=1
- “下载SDK”暂指向 /api/openapi后续接入代码生成
- “API测试工具”直连 /api/ingestGET 状态)
- 变更文件:
- app/page.tsx新增首页搜索与指标卡片
- 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时间全部相对“当前时间”生成
- services/IdentityService.ts新增身份识别服务
- app/api/database-structure/route.ts重写数据库结构 API
- app/api/openapi/route.ts新增OpenAPI 规范输出)
- components/data-integration/api-documentation.tsx更新API 文档页面联通)
- 接口与数据:
- GET /api/users?id= 返回单体详情GET /api/users 返回列表与分页POST /api/users 新增一个用户(服务内内存态)。
- GET /api/database-structure 返回数据库列表GET /api/database-structure?database 返回指定数据库结构GET /api/database-structure?database&table 返回指定表结构。
- GET /api/openapi 返回 OpenAPI 规范GET /api/openapi?download=1 下载 OpenAPI 规范。
- 完成度:
- 本轮任务完成度100%
- 用户画像模块整体完成度:≈ 88%(已具备真实感数据与筛选能力,待接入真实库)
- 本迭代完成度45%
- 预计下一步(真实数据源 + SDK 下载 + 头部统一):再提升至 75%
- 影响面
- /api/ingest因补齐 IdentityService现可完整跑通 IngestionService 的识别合并流程。
- 数据库结构查看器:现与 /api/database-structure 协议一致,可正常浏览模拟库结构。
- 风险与回滚
- 本次为纯新增/替换服务与路由,未改动 UI 大结构,风险低。
- 回滚:可将新增文件删除、路由还原至上个版本。
- 下一步计划
- 下一步计划:
1. 将 /api/users 切换到真实数据库Neon 或现有 MySQL并加上索引与分页游标
2. 画像页联动更多筛选项与批量导出;
3. 详情页增加 AI 洞察与行动建议AI SDK联动 RFM
@@ -68,7 +25,3 @@
1) 数据库对接Neon/Supabase/MySQL保留接口契约不变
2) 画像页接入上述接口的分页与高级筛选,补齐批量导出;
3) 详情页接入 AI SDK 生成洞察与跟进建议RFM联动
- 接入真实数据源连接器(在 Server 侧按环境变量路由;未配齐时回退模拟)[与需求池“多源接入/数据治理”方向一致]^3[^5]
- 输出 TypeScript SDK由 OpenAPI 派生),并在文档页提供下载。
- 统一布局头部Header/MobileHeader挂载至 layout抽象搜索输入的全局状态。
- 扩展 OpenAPI覆盖 /api/users、用户画像查询等。