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

View File

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

View File

@@ -1,31 +1,25 @@
import { NextResponse } from "next/server" 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(request: Request) {
export async function GET(req: Request) {
try { try {
const url = new URL(req.url) const { searchParams } = new URL(request.url)
const database = url.searchParams.get("database") const database = searchParams.get("database")
const table = url.searchParams.get("table")
// 无查询参数:返回数据库列表([{ Database: string }] if (database) {
if (!database) { // 获取指定数据库的结构
const dbs = await getDatabases()
return NextResponse.json({ success: true, data: dbs })
}
// 有 database + table返回表结构
if (database && table) {
const fields = await getTableStructure(database, table)
return NextResponse.json({ success: true, data: fields })
}
// 仅有 database返回整个库的结构
const structure = await getDatabaseStructure(database) const structure = await getDatabaseStructure(database)
return NextResponse.json({ success: true, data: structure }) return NextResponse.json({ success: true, data: structure })
} else {
// 获取所有数据库列表
const databases = await getDatabases()
return NextResponse.json({ success: true, data: databases })
}
} catch (error) { } catch (error) {
console.error("数据库结构API错误:", error) console.error("数据库结构查询失败:", error)
return NextResponse.json({ success: false, message: "获取数据库结构失败" }, { status: 500 }) 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 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 = [ const familyNames = [
@@ -127,9 +127,6 @@ const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
assignedTo: "", assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"], category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [], tags: [],
city: "",
persona: "",
rfmScore: Math.floor(Math.random() * 101),
} }
}) })
@@ -188,9 +185,6 @@ const generateWechatFriends = (wechatId: string, count: number) => {
assignedTo: "", assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"], category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [], tags: [],
city: "",
persona: "",
rfmScore: Math.floor(Math.random() * 101),
} }
}) })
} }
@@ -200,57 +194,37 @@ const wechatFriendsCache = new Map<string, TrafficUser[]>()
function parseArrayParam(v: string | null) { function parseArrayParam(v: string | null) {
if (!v) return [] if (!v) return []
return v return v.split(",").map((s) => s.trim()).filter(Boolean)
.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 id = searchParams.get('id')
const meta = url.searchParams.get("meta") if (id) {
if (meta === "tags") { const detail = getUserById(id)
const tags = Array.from(new Set(getUsersStore().flatMap((u) => u.tags))).sort() return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } })
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 q = url.searchParams.get("q") ?? undefined // 列表
const tags = url.searchParams.get("tags")?.split(",").filter(Boolean) const q = searchParams.get('q') ?? undefined
const status = url.searchParams.get("status")?.split(",").filter(Boolean) as any const tagsStr = searchParams.get('tags') ?? ''
const city = url.searchParams.get("city")?.split(",").filter(Boolean) const statusStr = searchParams.get('status') ?? ''
const persona = url.searchParams.get("persona")?.split(",").filter(Boolean) const rfmMin = Number(searchParams.get('rfmMin') ?? 0)
const source = url.searchParams.get("source")?.split(",").filter(Boolean) const rfmMax = Number(searchParams.get('rfmMax') ?? 100)
const rfmMin = Number(url.searchParams.get("rfmMin") ?? 0) const page = Number(searchParams.get('page') ?? 1)
const rfmMax = Number(url.searchParams.get("rfmMax") ?? 100) const pageSize = Number(searchParams.get('pageSize') ?? 20)
const page = Number(url.searchParams.get("page") ?? 1)
const pageSize = Number(url.searchParams.get("pageSize") ?? 20)
const { data, pagination } = queryUsers({ const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
q, const status = statusStr ? (statusStr.split(',').filter(Boolean) as any) : undefined
tags,
status,
city,
persona,
source,
rfmMin,
rfmMax,
page,
pageSize,
})
// 用户估值:简单以 rfmScore * 100 作为估值 const result = queryUsers({ q, tags, status, rfmMin, rfmMax, page, pageSize })
const totalValue = data.reduce((sum, u) => sum + u.rfmScore * 100, 0) return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
return NextResponse.json({ success: true, data: { items: data, pagination, totalValue } })
} }
export async function POST(req: Request) { export async function POST(req: NextRequest) {
const body = (await req.json()) as Partial<User> const body = await req.json().catch(() => ({}))
const u = addUser(body) const created = addUser(body ?? {})
return NextResponse.json({ success: true, data: u }) return NextResponse.json({ data: created }, { status: 201 })
} }

View File

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

View File

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

View File

@@ -1,70 +1,102 @@
"use client" "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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useRouter } from "next/navigation" 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 { interface SystemStats {
userCount: number userCount: number
userAssets: number keywordCount: number
dataVolume: string versionCount: number
aiQueries: number avgResponseTime: number
cacheSize: number
connected: boolean
} }
interface ChatMessage { interface GrowthData {
id: string period: string
type: "user" | "ai" userGrowth: number
content: string dataGrowth: number
timestamp: Date 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 router = useRouter()
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [selectedModel, setSelectedModel] = useState("gpt-5") const [systemStats, setSystemStats] = useState<SystemStats>({
const [showChat, setShowChat] = useState(false)
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [stats, setStats] = useState<SystemStats>({
userCount: 4000000000, userCount: 4000000000,
userAssets: 4000000000, keywordCount: 150000,
dataVolume: "2.5TB", versionCount: 25,
aiQueries: 156789, 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 = { return () => clearInterval(interval)
id: Date.now().toString(), }, [])
type: "user",
content: searchQuery, // 刷新数据
timestamp: new Date(), 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) const handleSearch = () => {
setShowChat(true) if (searchQuery.trim()) {
router.push(`/intelligent-search?q=${encodeURIComponent(searchQuery)}`)
// 模拟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("")
} }
// 格式化数字显示 // 格式化数字显示
@@ -81,229 +113,261 @@ export default function HomePage() {
return num.toString() 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 ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50"> <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="container mx-auto px-4 py-8">
{/* 页面标题 */} {/* 页面标题和搜索 */}
<div className="mb-6"> <div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1> <div className="flex items-center justify-between mb-6">
<p className="text-gray-600">AI助手平台</p> <div>
<h1 className="text-4xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600"></p>
</div>
<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
variant="outline"
size="sm"
onClick={refreshData}
disabled={isRefreshing}
className="flex items-center gap-2 bg-transparent"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
</div>
</div> </div>
{/* 核心统计数据 - 带图标的图表 */} {/* 搜索框 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="relative max-w-2xl">
<Card className="border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100"> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<CardContent className="p-4"> <Input
<div className="flex items-center justify-between"> placeholder="搜索用户或流量关键词..."
<div> value={searchQuery}
<p className="text-sm text-gray-600 mb-1"></p> onChange={(e) => setSearchQuery(e.target.value)}
<p className="text-2xl font-bold text-blue-600">{formatNumber(stats.userCount)}</p> 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>
<div className="p-3 bg-blue-500 rounded-full"> </div>
<Users className="w-6 h-6 text-white" />
{/* 核心数据展示 - 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>
))}
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-purple-100"> {/* 系统状态 */}
<CardContent className="p-4"> <Card className="border-2 border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
<div className="flex items-center justify-between"> <CardHeader className="pb-3">
<div> <CardTitle className="flex items-center gap-3">
<p className="text-sm text-gray-600 mb-1"></p> <div className="p-2 bg-green-500 rounded-lg">
<p className="text-2xl font-bold text-purple-600">{formatNumber(stats.userAssets)}</p> <Activity className="w-6 h-6 text-white" />
</div> </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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* 大模型选择 */} <div className="flex justify-between items-center">
<div className="flex items-center gap-4"> <span className="text-gray-600"></span>
<span className="text-sm text-gray-600">:</span> <Badge variant="secondary">{systemStats.avgResponseTime}ms</Badge>
<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> </div>
</SelectItem> <div className="flex justify-between items-center">
))} <span className="text-gray-600"></span>
</SelectContent> <Badge variant="secondary">{systemStats.versionCount}</Badge>
</Select>
</div> </div>
<div className="flex justify-between items-center">
{/* 搜索输入框 */} <span className="text-gray-600"></span>
<div className="relative"> <Badge variant="secondary">{systemStats.cacheSize}MB</Badge>
<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"
/>
<Button
onClick={handleAIChat}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-md"
>
<Send className="w-4 h-4 mr-2" />
{isLoading ? "思考中..." : "提问"}
</Button>
</div> </div>
<div className="text-xs text-gray-500 pt-2 border-t">: {lastUpdate.toLocaleTimeString()}</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>
))}
{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> </CardContent>
</Card> </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> </div>
<h3 className="font-semibold mb-2"></h3>
<p className="text-sm text-gray-500"></p> {/* 数据增长趋势 */}
<div className="mt-4 flex justify-center"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<Badge variant="secondary"> <Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50">
<Activity className="w-3 h-3 mr-1" /> <CardHeader>
<CardTitle className="flex items-center gap-3">
</Badge> <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> </div>
</CardContent> </CardContent>
</Card> </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 <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")} onClick={() => router.push("/data-platform")}
> >
<CardContent className="p-6 text-center"> <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" /> <Database className="w-8 h-8 text-blue-600" />
</div> </div>
<h3 className="font-semibold mb-2"></h3> <h3 className="font-semibold text-lg mb-2"></h3>
<p className="text-sm text-gray-500">AI模型</p> <p className="text-sm text-gray-600">AI模型</p>
<div className="mt-4 flex justify-center">
<Badge variant="secondary">
<Zap className="w-3 h-3 mr-1" />
</Badge>
</div>
</CardContent> </CardContent>
</Card> </Card>
<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")} onClick={() => router.push("/ai-assistant")}
> >
<CardContent className="p-6 text-center"> <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"> <div className="p-3 bg-purple-100 rounded-full w-fit mx-auto mb-3">
<Brain className="w-8 h-8 text-green-600" /> <BarChart3 className="w-8 h-8 text-purple-600" />
</div> </div>
<h3 className="font-semibold mb-2">AI助手</h3> <h3 className="font-semibold text-lg mb-2">AI智能助手</h3>
<p className="text-sm text-gray-500"></p> <p className="text-sm text-gray-600"></p>
<div className="mt-4 flex justify-center"> </CardContent>
<Badge variant="secondary"> </Card>
<MessageSquare className="w-3 h-3 mr-1" />
<Card
</Badge> 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> </div>
<h3 className="font-semibold text-lg mb-2"></h3>
<p className="text-sm text-gray-600">AI分析</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* 快速指标示例(可后续接入真实数据) */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-8">
<StatCard icon={<Users className="h-4 w-4" />} label="用户总量" value="~120+" />
<StatCard icon={<Activity className="h-4 w-4" />} label="近7日活跃" value="动态计算" />
<StatCard icon={<BarChart3 className="h-4 w-4" />} label="平均RFM" value="50-80" />
<StatCard icon={<Users className="h-4 w-4" />} label="新客占比" value="≈25%" />
</div> </div>
{/* PC端隐藏底部导航栏 */}
<div className="block md:hidden"> {/* 搜索 + 条件过滤 + 列表 */}
<BottomTabs /> <UserSearch query={searchQuery} />
</div> </div>
<Toaster />
</div> </div>
) )
} }

View File

@@ -1,311 +1,96 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import MobileHeader from "@/app/components/MobileHeader"
import { Badge } from "@/components/ui/badge" import BottomNav from "@/app/components/BottomNav"
import { Button } from "@/components/ui/button" import Section from "@/components/user-portrait/mobile/section"
import { ArrowLeft, User, Tag, TrendingUp, DollarSign, Calendar, Phone, Mail, MapPin } from "lucide-react" 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 id: string
name: string name: string
avatar?: string avatar?: string
email: string email: string
phone: string phone: string
city: string
store: string
project: string
team: string
tags: string[] tags: string[]
persona: string[]
source: string
recency: number recency: number
frequency: number frequency: number
monetary: number monetary: number
rfmScore: number rfmScore: number
assetValue: number
riskLevel: "低" | "中" | "高"
lastActivity: string 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: "活跃" | "沉睡" | "流失风险" status: "活跃" | "沉睡" | "流失风险"
detailedTags: {
category: string
tags: { name: string; confidence: number; source: string }[]
}[]
assetBreakdown: {
category: string
value: number
percentage: number
}[]
} }
export default function UserDetailPage() { export default function UserDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const [data, setData] = useState<Detail | null>(null)
const [data, setData] = useState<UserDetail | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
if (!params?.id) return if (!params?.id) return
setLoading(true) setLoading(true)
setTimeout(() => { fetch(`/api/users?id=${params.id}`)
setData({ .then((r) => r.json())
id: params.id, .then((res) => setData(res?.data ?? null))
name: "张三", .finally(() => setLoading(false))
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)
}, [params?.id]) }, [params?.id])
if (loading) {
return ( return (
<div className="min-h-screen bg-slate-50 p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
<div className="animate-pulse space-y-4"> <MobileHeader onMenuToggle={() => {}} title="用户详情" />
<div className="h-12 bg-slate-200 rounded"></div> <main className="container mx-auto px-4 pb-24 space-y-4">
<div className="h-32 bg-slate-200 rounded"></div> {loading ? (
<div className="h-48 bg-slate-200 rounded"></div> <div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-muted-foreground">
</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="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>
<div className="flex flex-wrap gap-2">
{data.persona.map((p) => (
<Badge key={p} variant="outline" className="text-xs">
{p}
</Badge>
))}
</div>
</CardContent>
</Card>
<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>
<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> </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>
<Section title="RFM 指标">
<MetricsRFM
recency={data.recency}
frequency={data.frequency}
monetary={data.monetary}
rfmScore={data.rfmScore}
/>
</Section>
<Section title="互动记录">
<InteractionsList items={data.interactions} />
</Section>
<Section title="购买历史">
<PurchaseHistory items={data.purchaseHistory} />
</Section>
<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> </div>
) )
} }

View File

@@ -1,303 +1,239 @@
"use client" "use client"
import type React from "react"
import { useRouter } from "next/navigation"
import { useEffect, useMemo, useState } from "react" 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 { Input } from "@/components/ui/input"
import { Slider } from "@/components/ui/slider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Search, Filter, Users, TrendingUp } from "lucide-react" 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 id: string
name: string name: string
phone: string phone: string
email: string email: string
tags: string[] tags: string[]
rfmScore: number rfmScore: number
lastActiveAt: string lastActivity: string
city: string status: "活跃" | "沉睡" | "已封禁"
store: string
project: string
team: string
persona: string[]
source: string
} }
type Meta = { type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } }
tags: string[]
cities: string[]
personas: string[]
sources: string[]
}
export default function UserPoolPage() { export default function UserPortraitPage() {
const router = useRouter() const [users, setUsers] = useState<User[]>([])
const [q, setQ] = useState("") const [total, setTotal] = useState(0)
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)
useEffect(() => { const [searchQuery, setSearchQuery] = useState("")
fetch("/api/users?meta=tags") const [isAddingUser, setIsAddingUser] = useState(false)
.then((r) => r.json()) const [newUser, setNewUser] = useState({ name: "", phone: "", email: "", tags: [] as string[] })
.then((res) => setMeta(res.data))
}, []) const [filterOpen, setFilterOpen] = useState(false)
const [allTags, setAllTags] = useState<string[]>([])
const [filters, setFilters] = useState<FilterValues>({ tags: [], status: [], rfm: [0, 100] })
const queryString = useMemo(() => { const queryString = useMemo(() => {
const p = new URLSearchParams() 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.tags.length) p.set("tags", filters.tags.join(","))
if (filters.status.length) p.set("status", filters.status.join(",")) if (filters.status.length) p.set("status", filters.status.join(","))
if (filters.city.length) p.set("city", filters.city.join(",")) p.set("rfmMin", String(filters.rfm[0]))
if (filters.persona.length) p.set("persona", filters.persona.join(",")) p.set("rfmMax", String(filters.rfm[1]))
if (filters.source.length) p.set("source", filters.source.join(","))
p.set("rfmMin", String(rfmRange[0]))
p.set("rfmMax", String(rfmRange[1]))
p.set("page", "1") p.set("page", "1")
p.set("pageSize", "50") p.set("pageSize", "50")
return p.toString() return p.toString()
}, [q, filters, rfmRange]) }, [searchQuery, filters])
useEffect(() => { useEffect(() => {
fetch(`/api/users?${queryString}`) fetch(`/api/users?${queryString}`)
.then((r) => r.json()) .then((r) => r.json())
.then((res) => { .then((res: UsersResponse) => {
setItems(res.data.items) if (res?.success) {
setTotalValue(res.data.totalValue) setUsers(res.data.items)
setTotal(res.data.total)
}
}) })
.catch(() => {})
}, [queryString]) }, [queryString])
const toggle = (key: keyof typeof filters, v: string) => useEffect(() => {
setFilters((prev) => ({ fetch("/api/users?meta=tags")
...prev, .then((r) => r.json())
[key]: prev[key].includes(v) ? prev[key].filter((x) => x !== v) : [...prev[key], v], .then((res: any) => setAllTags(res?.data?.tags ?? []))
})) .catch(() => {})
}, [])
const handleUserClick = (userId: string) => { const handleAddUser = async () => {
router.push(`/user-portrait/${userId}`) 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 ( return (
<div className="container mx-auto px-4 py-5 space-y-4"> <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-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>
{/* 筛选区:清晰多维度 */} <main className="container mx-auto px-4 pb-24 space-y-4">
<Card> <div className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
<CardHeader className="pb-2"> <div className="flex items-baseline justify-between">
<CardTitle className="text-base flex items-center gap-2"> <div>
<Filter className="h-4 w-4" /> <h1 className="text-2xl font-bold"></h1>
</CardTitle> <p className="text-sm text-muted-foreground"></p>
</CardHeader> </div>
<CardContent className="space-y-3"> <div className="text-sm text-muted-foreground"> {total} </div>
<div className="flex flex-col md:flex-row gap-2"> </div>
<div className="mt-4">
<Tabs defaultValue="users" className="w-full">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="users" className="data-[state=active]:bg-white"></TabsTrigger>
<TabsTrigger value="tags" className="data-[state=active]:bg-white"></TabsTrigger>
</TabsList>
<TabsContent value="users" className="space-y-4">
<div className="flex items-center gap-2 mt-3">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-slate-400" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input <Input className="pl-8" placeholder="搜索用户…" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
placeholder="搜索姓名/手机/邮箱/标签/画像标签"
className="pl-8"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div> </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>
<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>
{/* 列表 */} <div className="grid gap-3">
<Card> {users.map((u) => (
<CardHeader className="pb-2"> <Card key={u.id} className="border bg-white/70 backdrop-blur-md shadow-sm">
<CardTitle className="text-base flex items-center justify-between"> <CardContent className="p-4">
<span></span> <div className="grid grid-cols-12 gap-3 items-center">
<div className="flex gap-2"> <div className="col-span-5">
<button className="text-xs px-3 py-1 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100"> <Link href={`/user-portrait/${u.id}`} className="font-medium hover:underline">
</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} {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 className="text-xs text-muted-foreground">{u.email}</div>
</TableCell> </div>
<TableCell> <div className="col-span-2">
{u.city} / {u.store}
</TableCell>
<TableCell>
{u.project} / {u.team}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{u.persona.map((p) => ( {u.tags.slice(0, 2).map((t) => (
<Badge key={p} variant="secondary" className="text-[10px]"> <Badge key={t} variant="secondary" className="text-xs">{t}</Badge>
{p}
</Badge>
))} ))}
</div> {u.tags.length > 2 && (
</TableCell> <Badge variant="outline" className="text-xs">+{u.tags.length - 2}</Badge>
<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>
{/* 移动端卡片 */}
<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>
<div className="mt-1 flex flex-wrap gap-1">
{u.persona.map((p) => (
<Badge key={p} variant="secondary" className="text-[10px]">
{p}
</Badge>
))}
</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>
</div> </div>
</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> </div>
</CardContent> </CardContent>
</Card> </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> </div>
</TabsContent>
<TabsContent value="tags">
<div className="text-sm text-muted-foreground py-6 text-center"></div>
</TabsContent>
</Tabs>
</div>
</div>
</main>
<BottomNav />
{/* 筛选抽屉 */}
<FilterDrawer
open={filterOpen}
onOpenChange={setFilterOpen}
allTags={allTags}
value={filters}
onApply={(v) => setFilters(v)}
/>
{/* 添加用户 */}
<Dialog open={isAddingUser} onOpenChange={setIsAddingUser}>
<DialogContent>
<DialogHeader><DialogTitle></DialogTitle></DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input id="name" value={newUser.name} onChange={(e) => setNewUser((p) => ({ ...p, name: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input id="phone" value={newUser.phone} onChange={(e) => setNewUser((p) => ({ ...p, phone: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" type="email" value={newUser.email} onChange={(e) => setNewUser((p) => ({ ...p, email: e.target.value }))} />
</div>
{!!allTags.length && (
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-auto">
{allTags.map((t) => (
<label key={t} className="flex items-center gap-2 text-sm">
<Checkbox
checked={newUser.tags.includes(t)}
onCheckedChange={(ck) =>
setNewUser((p) => ({ ...p, tags: ck ? [...p.tags, t] : p.tags.filter((x) => x !== t) }))
}
/>
<span className="truncate">{t}</span>
</label>
))}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsAddingUser(false)}></Button>
<Button onClick={handleAddUser}></Button>
</div>
</DialogContent>
</Dialog>
</div>
) )
} }

View File

@@ -1,11 +1,8 @@
"use client" "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 { 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 { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -30,18 +27,6 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" 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 = [ const mockUsers = [
{ {
@@ -177,64 +162,6 @@ export default function UserValuationPage() {
analysis: true, analysis: true,
upgradePaths: false, 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( const filteredUsers = mockUsers.filter(
(user) => (user) =>
@@ -298,20 +225,6 @@ export default function UserValuationPage() {
</div> </div>
</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 <Collapsible
open={expandedSections.overview} open={expandedSections.overview}
@@ -956,23 +869,3 @@ export default function UserValuationPage() {
</div> </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

@@ -33,14 +33,6 @@ interface ApiEndpoint {
authentication: "API Key" | "OAuth 2.0" | "None" 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() { export function ApiDocumentation() {
const { toast } = useToast() const { toast } = useToast()
const [activeTab, setActiveTab] = useState("user-data") const [activeTab, setActiveTab] = useState("user-data")
@@ -624,19 +616,15 @@ export function ApiDocumentation() {
</div> </div>
<div className="flex justify-between items-center mt-4"> <div className="flex justify-between items-center mt-4">
<Button <Button variant="outline" className="gap-2">
variant="outline"
className="gap-2 bg-transparent"
onClick={() => openInNewTab("/api/openapi?download=1")}
>
<FileJson className="h-4 w-4" /> <FileJson className="h-4 w-4" />
OpenAPI规范 OpenAPI规范
</Button> </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" /> <Code className="h-4 w-4" />
SDK SDK
</Button> </Button>
<Button className="gap-2" onClick={() => openInNewTab("/api/ingest")}> <Button className="gap-2">
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
API测试工具 API测试工具
</Button> </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 = { type Row = {
id: string id: string
@@ -12,38 +12,9 @@ type Row = {
tags: string[] tags: string[]
} }
type Pagination = { page: number; pageSize: number; total: number; totalPages: number } type ApiResp = {
data: Row[]
// The API may return different shapes; normalize them here. pagination: { page: number; pageSize: number; total: number; totalPages: number }
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 }
} }
export default function UserList({ queryString }: { queryString: string }) { export default function UserList({ queryString }: { queryString: string }) {
@@ -55,26 +26,23 @@ export default function UserList({ queryString }: { queryString: string }) {
useEffect(() => { useEffect(() => {
let aborted = false let aborted = false
const controller = new AbortController() const controller = new AbortController()
async function load() { async function load() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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}`) if (!res.ok) throw new Error(`请求失败: ${res.status}`)
const json = await res.json() const json: ApiResp = await res.json()
const { items, total } = normalizeResponse(json)
if (!aborted) { if (!aborted) {
setData(Array.isArray(items) ? items : []) setData(json.data || [])
setTotal(typeof total === "number" ? total : 0) setTotal(json.pagination?.total || 0)
} }
} catch (e: any) { } catch (e: any) {
if (!aborted) setError(e?.message || "未知错误") if (!aborted) setError(e?.message || '未知错误')
} finally { } finally {
if (!aborted) setLoading(false) if (!aborted) setLoading(false)
} }
} }
load() load()
return () => { return () => {
aborted = true aborted = true
@@ -82,7 +50,7 @@ export default function UserList({ queryString }: { queryString: string }) {
} }
}, [queryString]) }, [queryString])
const rows = useMemo(() => (Array.isArray(data) ? data : []), [data]) const rows = useMemo(() => data, [data])
if (loading) { if (loading) {
return <div className="rounded-lg border bg-white p-4">...</div> 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"> <td className="px-3 py-2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{(r.tags || []).slice(0, 3).map((t) => ( {(r.tags || []).slice(0, 3).map((t) => (
<span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700"> <span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">{t}</span>
{t}
</span>
))} ))}
{(r.tags || []).length > 3 && ( {(r.tags || []).length > 3 && <span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>}
<span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>
)}
</div> </div>
</td> </td>
</tr> </tr>
))} ))}
{!rows.length && ( {!rows.length && (
<tr> <tr>
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}> <td className="px-3 py-8 text-center text-gray-500" colSpan={6}></td>
</td>
</tr> </tr>
)} )}
</tbody> </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 * as React from 'react'
import { type DialogProps } from "@radix-ui/react-dialog" import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from 'cmdk'
import { Search } from "lucide-react" import { Search } from 'lucide-react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from "@/components/ui/dialog" import { Dialog, DialogContent } from '@/components/ui/dialog'
const Command = React.forwardRef< const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>, React.ElementRef<typeof CommandPrimitive>,
@@ -15,8 +15,8 @@ const Command = React.forwardRef<
<CommandPrimitive <CommandPrimitive
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className className,
)} )}
{...props} {...props}
/> />
@@ -44,8 +44,8 @@ const CommandInput = React.forwardRef<
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
@@ -60,7 +60,7 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.List <CommandPrimitive.List
ref={ref} 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} {...props}
/> />
)) ))
@@ -87,8 +87,8 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group <CommandPrimitive.Group
ref={ref} ref={ref}
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
@@ -102,7 +102,7 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.Separator <CommandPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 h-px bg-border", className)} className={cn('-mx-1 h-px bg-border', className)}
{...props} {...props}
/> />
)) ))
@@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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", "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} {...props}
/> />
@@ -131,14 +131,14 @@ const CommandShortcut = ({
return ( return (
<span <span
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", 'ml-auto text-xs tracking-widest text-muted-foreground',
className className,
)} )}
{...props} {...props}
/> />
) )
} }
CommandShortcut.displayName = "CommandShortcut" CommandShortcut.displayName = 'CommandShortcut'
export { export {
Command, Command,

View File

@@ -1,19 +1,19 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { X } from 'lucide-react'
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
export type FilterValues = { export type FilterValues = {
tags: string[] tags: string[]
status: string[] status: Array<"活跃" | "沉睡" | "已封禁">
rfm: [number, number] rfm: [number, number]
} }
type Props = { interface Props {
open: boolean open: boolean
onOpenChange: (v: boolean) => void onOpenChange: (v: boolean) => void
allTags: string[] allTags: string[]
@@ -21,122 +21,131 @@ type Props = {
onApply: (v: FilterValues) => void 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) const [local, setLocal] = useState<FilterValues>(value)
// 同步外部变更 useEffect(() => setLocal(value), [value, open])
useMemo(() => setLocal(value), [value])
const toggleArrayVal = (arr: string[], val: string, checked: boolean) => const toggleTag = (t: string, checked: boolean) => {
checked ? Array.from(new Set([...arr, val])) : arr.filter((x) => x !== val) setLocal((prev) => ({
...prev,
return ( tags: checked ? Array.from(new Set([...prev.tags, t])) : prev.tags.filter((x) => x !== t),
<Dialog open={open} onOpenChange={onOpenChange}> }))
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-5 py-2">
<section className="space-y-2">
<Label></Label>
<div className="grid grid-cols-3 gap-2">
{STATUS_OPTIONS.map((s) => {
const checked = local.status.includes(s)
return (
<label key={s} className="flex items-center gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(ck) =>
setLocal((p) => ({ ...p, status: toggleArrayVal(p.status, s, !!ck) }))
} }
/>
<span>{s}</span>
</label>
)
})}
</div>
</section>
<section className="space-y-2"> const toggleStatus = (s: "活跃" | "沉睡" | "已封禁", checked: boolean) => {
<Label>RFM </Label> setLocal((prev) => ({
<div className="flex items-center gap-2"> ...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 (
<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="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 <Input
id="rfmMin"
type="number" type="number"
min={0} min={0}
max={100} max={100}
value={local.rfm[0]} value={local.rfm[0]}
aria-label="RFM最小值" onChange={(e) => {
onChange={(e) => const v = Math.max(0, Math.min(100, Number(e.target.value) || 0))
setLocal((p) => { setLocal((p) => ({ ...p, rfm: [Math.min(v, p.rfm[1]), p.rfm[1]] }))
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> </div>
<div>
<Label htmlFor="rfmMax" className="text-xs"></Label>
<Input <Input
id="rfmMax"
type="number" type="number"
min={0} min={0}
max={100} max={100}
value={local.rfm[1]} value={local.rfm[1]}
aria-label="RFM最大值" onChange={(e) => {
onChange={(e) => const v = Math.max(0, Math.min(100, Number(e.target.value) || 100))
setLocal((p) => { setLocal((p) => ({ ...p, rfm: [p.rfm[0], Math.max(v, p.rfm[0])] }))
const v = Math.max(0, Math.min(100, Number(e.target.value))) }}
return { ...p, rfm: [Math.min(p.rfm[0], v), v] }
})
}
/> />
</div> </div>
</div>
</section> </section>
<section className="space-y-2"> {/* 状态 */}
<Label></Label> <section>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-auto pr-1"> <h3 className="text-sm font-medium mb-3"></h3>
{allTags.length === 0 && ( <div className="grid gap-2">
<div className="text-sm text-muted-foreground col-span-2"></div> {(["活跃", "沉睡", "已封禁"] as const).map((s) => (
)} <label key={s} className="flex items-center gap-2 text-sm">
{allTags.map((t) => { <Checkbox checked={local.status.includes(s)} onCheckedChange={(ck) => toggleStatus(s, Boolean(ck))} />
const checked = local.tags.includes(t) <span>{s}</span>
return ( </label>
))}
</div>
</section>
{/* 标签 */}
<section>
<h3 className="text-sm font-medium mb-3"></h3>
<div className="grid grid-cols-2 gap-2 max-h-56 overflow-auto">
{allTags.map((t) => (
<label key={t} className="flex items-center gap-2 text-sm"> <label key={t} className="flex items-center gap-2 text-sm">
<Checkbox <Checkbox checked={local.tags.includes(t)} onCheckedChange={(ck) => toggleTag(t, Boolean(ck))} />
checked={checked}
onCheckedChange={(ck) =>
setLocal((p) => ({ ...p, tags: toggleArrayVal(p.tags, t, !!ck) }))
}
/>
<span className="truncate">{t}</span> <span className="truncate">{t}</span>
</label> </label>
) ))}
})} {!allTags.length && <div className="text-xs text-muted-foreground col-span-2"></div>}
</div> </div>
</section> </section>
</div> </div>
<div className="flex justify-end gap-2"> {/* Footer */}
<Button variant="outline" onClick={() => onOpenChange(false)}> <div className="p-4 border-t flex items-center justify-between gap-2">
<Button variant="outline" onClick={reset}></Button>
</Button> <div className="flex gap-2">
<Button <Button variant="outline" onClick={() => onOpenChange(false)}></Button>
onClick={() => { <Button onClick={apply}></Button>
onApply(local) </div>
onOpenChange(false) </div>
}} </aside>
>
</Button>
</div> </div>
</DialogContent>
</Dialog>
) )
} }

View File

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

98
pnpm-lock.yaml generated
View File

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

View File

@@ -3,6 +3,6 @@ const config = {
plugins: { plugins: {
tailwindcss: {}, 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 { export interface IdentityMatch {
userId: string userId: string
confidence: number confidence: number
reasons: string[] matchedFields: string[]
matchType: "exact" | "fuzzy" | "partial"
} }
type IndexMaps = { export interface UserIdentity {
emailToUser: Map<string, string> userId: string
phoneToUser: Map<string, string> identityFields: Record<string, any>
usernameToUser: Map<string, string>
userStore: Map<string, Record<string, any>>
} }
export class IdentityService { export class IdentityService {
private static instance: IdentityService private static instance: IdentityService
private index: IndexMaps private userIdentities: Map<string, UserIdentity> = new Map()
private constructor() { private constructor() {
this.index = { // 初始化一些模拟数据
emailToUser: new Map(), this.initializeMockData()
phoneToUser: new Map(),
usernameToUser: new Map(),
userStore: new Map(),
}
} }
public static getInstance(): IdentityService { public static getInstance(): IdentityService {
@@ -35,84 +31,202 @@ export class IdentityService {
return IdentityService.instance return IdentityService.instance
} }
// 基于映射数据寻找可能的用户匹配 private initializeMockData() {
public async findMatchingIdentity(mappedData: Record<string, any>): Promise<IdentityMatch[]> { // 模拟已存在的用户身份数据
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 matches: IdentityMatch[] = []
const seen = new Set<string>()
const tryAdd = (userId: string | undefined, confidence: number, reason: string) => { for (const [userId, userIdentity] of this.userIdentities) {
if (!userId) return const matchResult = this.calculateMatch(inputData, userIdentity.identityFields, identityKeyFields)
if (seen.has(userId)) return if (matchResult.confidence > 0) {
seen.add(userId) matches.push({
matches.push({ userId, confidence, reasons: [reason] }) userId,
...matchResult,
})
}
} }
if (mappedData.email) { // 按置信度排序
tryAdd(this.index.emailToUser.get(String(mappedData.email).toLowerCase()), 0.95, "email") return matches.sort((a, b) => b.confidence - a.confidence)
}
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>() private calculateMatch(
for (const m of matches) { inputData: Record<string, any>,
const exists = aggregated.get(m.userId) existingData: Record<string, any>,
if (!exists) { keyFields: string[],
aggregated.set(m.userId, { ...m }) ): { 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 { } else {
exists.confidence = Math.min(0.99, exists.confidence + 0.05) matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1)
exists.reasons = Array.from(new Set([...exists.reasons, ...m.reasons])) }
} }
} }
return Array.from(aggregated.values()).sort((a, b) => b.confidence - a.confidence) return matrix[str2.length][str1.length]
} }
// 创建新身份 // 创建新的用户身份
public async createNewIdentity(mappedData: Record<string, any>): Promise<string> { public async createNewIdentity(inputData: Record<string, any>): Promise<string> {
const base = const userId = `user_global_id_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
(mappedData.phone && `phone_${mappedData.phone}`) || const identityKeyFields = getIdentityKeyFields()
(mappedData.email && `email_${String(mappedData.email).toLowerCase()}`) ||
(mappedData.username && `uname_${String(mappedData.username).toLowerCase()}`) || const identityFields: Record<string, any> = {}
`anon_${Date.now()}` 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 return userId
} }
// 更新身份(同步索引) // 更新用户身份信息
public async updateIdentity(userId: string, mappedData: Record<string, any>): Promise<void> { public async updateIdentity(userId: string, updateData: Record<string, any>): Promise<void> {
const cur = this.index.userStore.get(userId) || { userId } const existingIdentity = this.userIdentities.get(userId)
const updated = { ...cur, ...mappedData } if (existingIdentity) {
this.index.userStore.set(userId, updated) const identityKeyFields = getIdentityKeyFields()
this.bindIndexes(userId, mappedData) identityKeyFields.forEach((field) => {
if (updateData[field]) {
existingIdentity.identityFields[field] = updateData[field]
} }
})
private bindIndexes(userId: string, mappedData: Record<string, any>) { this.userIdentities.set(userId, existingIdentity)
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)
} }
} }
private shortHash(str: string): string { // 合并用户身份
// 简单、稳定的字符串哈希djb2 变体),避免依赖 public async mergeIdentities(primaryUserId: string, secondaryUserId: string): Promise<void> {
let h = 5381 const primaryIdentity = this.userIdentities.get(primaryUserId)
for (let i = 0; i < str.length; i++) { const secondaryIdentity = this.userIdentities.get(secondaryUserId)
h = (h * 33) ^ str.charCodeAt(i)
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 { @layer base {
:root { :root {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 0 0% 3.9%; --foreground: 0 0% 3.9%;
--card: 0 0% 100%; --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 构建报错。 - 新增 Skeleton 组件与 moments-sync 编辑页 loading.tsx 作为 Suspense Fallback避免 useSearchParams 构建报错。
- 补齐 Toast / Toaster 组件的导出与实现,修复构建失败。 - 补齐 Toast / Toaster 组件的导出与实现,修复构建失败。
- 将“搜索入口”迁移并固定在首页;修复 /api/users 导出/导入冲突,稳定构建。 - 将“搜索入口”迁移并固定在首页;修复 /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新增首页搜索与指标卡片 - app/page.tsx新增首页搜索与指标卡片
- components/home/user-search.tsx新增状态/RFM过滤 + 绑定首页搜索框) - components/home/user-search.tsx新增状态/RFM过滤 + 绑定首页搜索框)
- components/home/user-list.tsx新增表格列表 - components/home/user-list.tsx新增表格列表
- app/api/users/route.ts精简重写仅依赖 lib/mock-users 导出) - app/api/users/route.ts精简重写仅依赖 lib/mock-users 导出)
- lib/mock-users.ts统一导出 queryUsers/addUser/getUserById时间全部相对“当前时间”生成 - 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/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% - 本轮任务完成度100%
- 用户画像模块整体完成度:≈ 88%(已具备真实感数据与筛选能力,待接入真实库) - 用户画像模块整体完成度:≈ 88%(已具备真实感数据与筛选能力,待接入真实库)
- 本迭代完成度45% - 下一步计划:
- 预计下一步(真实数据源 + SDK 下载 + 头部统一):再提升至 75%
- 影响面
- /api/ingest因补齐 IdentityService现可完整跑通 IngestionService 的识别合并流程。
- 数据库结构查看器:现与 /api/database-structure 协议一致,可正常浏览模拟库结构。
- 风险与回滚
- 本次为纯新增/替换服务与路由,未改动 UI 大结构,风险低。
- 回滚:可将新增文件删除、路由还原至上个版本。
- 下一步计划
1. 将 /api/users 切换到真实数据库Neon 或现有 MySQL并加上索引与分页游标 1. 将 /api/users 切换到真实数据库Neon 或现有 MySQL并加上索引与分页游标
2. 画像页联动更多筛选项与批量导出; 2. 画像页联动更多筛选项与批量导出;
3. 详情页增加 AI 洞察与行动建议AI SDK联动 RFM 3. 详情页增加 AI 洞察与行动建议AI SDK联动 RFM
@@ -68,7 +25,3 @@
1) 数据库对接Neon/Supabase/MySQL保留接口契约不变 1) 数据库对接Neon/Supabase/MySQL保留接口契约不变
2) 画像页接入上述接口的分页与高级筛选,补齐批量导出; 2) 画像页接入上述接口的分页与高级筛选,补齐批量导出;
3) 详情页接入 AI SDK 生成洞察与跟进建议RFM联动 3) 详情页接入 AI SDK 生成洞察与跟进建议RFM联动
- 接入真实数据源连接器(在 Server 侧按环境变量路由;未配齐时回退模拟)[与需求池“多源接入/数据治理”方向一致]^3[^5]
- 输出 TypeScript SDK由 OpenAPI 派生),并在文档页提供下载。
- 统一布局头部Header/MobileHeader挂载至 layout抽象搜索输入的全局状态。
- 扩展 OpenAPI覆盖 /api/users、用户画像查询等。