feat: enhance user profile with detailed tags and asset evaluation
Optimize user detail page for asset assessment and tag info. #VERCEL_SKIP Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
@@ -5,9 +5,10 @@ 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 BottomNav from "./components/BottomNav"
|
import BottomTabs from "@/components/nav/bottom-tabs"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { Toaster } from "@/components/ui/toaster"
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] })
|
const inter = Inter({ subsets: ["latin"] })
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ 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 = () => {
|
||||||
@@ -33,7 +35,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>
|
||||||
@@ -47,7 +49,7 @@ export default function ClientLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* 桌面端侧边栏 */}
|
{/* 桌面端侧边栏 - PC端不显示底部导航 */}
|
||||||
{!isMobile && <Sidebar />}
|
{!isMobile && <Sidebar />}
|
||||||
|
|
||||||
{/* 移动端侧边栏 */}
|
{/* 移动端侧边栏 */}
|
||||||
@@ -55,16 +57,21 @@ export default function ClientLayout({
|
|||||||
|
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 */}
|
||||||
<main className={`flex-1 ${isMobile ? "pb-20" : "p-6"}`}>
|
<main className={`flex-1 ${isMobile ? "pb-20" : "p-6"}`}>
|
||||||
{/* 移动端头部 */}
|
{/* 移动端头部 - 添加卡若数据资产中台标题 */}
|
||||||
{isMobile && <MobileHeader onMenuToggle={() => setSidebarOpen(true)} />}
|
{isMobile && (
|
||||||
|
<div className="sticky top-0 z-30 bg-white/90 backdrop-blur border-b px-4 py-3 mb-4">
|
||||||
|
<h1 className="text-lg font-semibold text-center">卡若数据资产中台</h1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
<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 && <BottomNav />}
|
{isMobile && <BottomTabs />}
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,71 +1,105 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react"
|
import { useCallback, useMemo, useState } from "react"
|
||||||
import { BarChart3, Database, FileText } from 'lucide-react'
|
import { TrendingUp, FileText, Sparkles } 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, DatabaseInfo, ReportItem, ReportTemplate } from "@/types/ai-assistant"
|
import type { AnalysisTask, 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-behavior-analysis.pdf",
|
reportUrl: "/reports/user-asset-evaluation.pdf",
|
||||||
description: "基于近30天行为的聚合与序列分析",
|
description: "基于RFM模型和行为数据的用户资产价值评估",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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: "RFM 分层、价值区间分布与运营建议",
|
description: "预测用户未来价值和流失风险",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
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_002", name: "流量趋势分析报告", description: "分析关键词搜索趋势和流量变化", category: "流量分析", fields: ["关键词热度", "搜索趋势", "竞争分析", "机会识别", "优化建议"] },
|
id: "template_001",
|
||||||
{ id: "template_003", name: "业务运营报告", description: "综合业务数据分析和运营建议", category: "运营分析", fields: ["核心指标", "增长分析", "用户留存", "转化漏斗", "运营建议"] },
|
name: "用户资产评估报告",
|
||||||
{ id: "template_004", name: "数据质量报告", description: "评估数据完整性、准确性和一致性", category: "数据质量", fields: ["数据完整性", "准确性检查", "一致性验证", "异常检测", "改进建议"] },
|
description: "全面评估用户资产价值和投资回报",
|
||||||
|
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-2", title: "流量关键词趋势分析", source: "数据源:流量关键词库", description: '追踪 000000 类流量词及其曝光、点击、转化趋势,适配日/周/月视角。\\n" ]]}', updatedAt: new Date().toISOString() },
|
id: "rpt-1",
|
||||||
{ id: "rpt-3", title: "用户价值分层报告", source: "数据源:微信用户数据库", description: '结合 RFM 得分与标签,给出 S/A/B/C/D 分层与经营建议。\\n" ]] }', updatedAt: new Date().toISOString() },
|
title: "用户资产评估报告",
|
||||||
|
source: "数据源:用户池",
|
||||||
|
description: "基于用户行为和交易数据,评估用户资产价值和投资回报率。",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rpt-2",
|
||||||
|
title: "高价值用户分析",
|
||||||
|
source: "数据源:用户池",
|
||||||
|
description: "识别和分析高价值用户的特征、行为模式和价值贡献。",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rpt-3",
|
||||||
|
title: "用户价值预测模型",
|
||||||
|
source: "数据源:用户池",
|
||||||
|
description: "基于机器学习预测用户未来价值和生命周期价值。",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -95,28 +129,36 @@ export default function AIAssistantPage() {
|
|||||||
}, 800)
|
}, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedItems = useMemo(
|
const handleEditTemplate = (templateId: string) => {
|
||||||
() => reportItems.map((i) => ({ ...i, description: sanitizeText(i.description) })),
|
setEditingTemplate(templateId)
|
||||||
[],
|
const template = templates.find((t) => t.id === templateId)
|
||||||
)
|
setTemplateContent(template?.fields.join("\n") || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveTemplate = () => {
|
||||||
|
// 保存模板逻辑
|
||||||
|
setEditingTemplate(null)
|
||||||
|
setTemplateContent("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedItems = useMemo(() => reportItems.map((i) => ({ ...i, description: sanitizeText(i.description) })), [])
|
||||||
|
|
||||||
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">AI智能助手</h1>
|
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||||
<p className="text-gray-600">数据库分析与智能报告生成</p>
|
<Sparkles className="h-8 w-8 text-blue-500" />
|
||||||
|
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-3">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="analysis" className="flex items-center gap-2">
|
<TabsTrigger value="analysis" className="flex items-center gap-2">
|
||||||
<BarChart3 className="w-4 h-4" />
|
<TrendingUp 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" />
|
||||||
@@ -126,8 +168,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={databases} templates={templates} onCreate={onCreate} />
|
<CreateTaskDialog databases={[]} templates={templates} onCreate={onCreate} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskList tasks={tasks} onTick={tick} />
|
<TaskList tasks={tasks} onTick={tick} />
|
||||||
@@ -135,38 +177,12 @@ 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">
|
||||||
@@ -175,7 +191,29 @@ 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.fields.join(" / ")}</div>
|
<div className="text-xs text-gray-500">{t.description}</div>
|
||||||
|
{editingTemplate === t.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={templateContent}
|
||||||
|
onChange={(e) => setTemplateContent(e.target.value)}
|
||||||
|
placeholder="编辑模板字段..."
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleSaveTemplate}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditingTemplate(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleEditTemplate(t.id)} className="w-full">
|
||||||
|
编辑模板
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getDatabases, getDatabaseStructure, getTableStructure } from "@/lib/db-connector"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
import { NextResponse } from "next/server"
|
export async function GET(req: Request) {
|
||||||
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const url = new URL(req.url)
|
||||||
const database = searchParams.get("database")
|
const database = url.searchParams.get("database")
|
||||||
|
const table = url.searchParams.get("table")
|
||||||
|
|
||||||
if (database) {
|
// 无查询参数:返回数据库列表([{ Database: string }])
|
||||||
// 获取指定数据库的结构
|
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("数据库结构查询失败:", error)
|
console.error("数据库结构API错误:", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json({ success: false, message: "获取数据库结构失败" }, { status: 500 })
|
||||||
{ success: false, message: "数据库结构查询失败", error: (error as Error).message },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
app/api/openapi/route.ts
Normal file
98
app/api/openapi/route.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse, NextRequest } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import type { TrafficUser } from "@/types/traffic"
|
import type { TrafficUser } from "@/types/traffic"
|
||||||
import { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus, MOCK_USERS } from "@/lib/mock-users"
|
import { addUser, getUsersStore, queryUsers, type User } from "@/lib/mock-users"
|
||||||
|
|
||||||
// 中文名字生成器数据
|
// 中文名字生成器数据
|
||||||
const familyNames = [
|
const familyNames = [
|
||||||
@@ -127,6 +127,9 @@ 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),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,6 +188,9 @@ 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),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -194,33 +200,57 @@ const wechatFriendsCache = new Map<string, TrafficUser[]>()
|
|||||||
|
|
||||||
function parseArrayParam(v: string | null) {
|
function parseArrayParam(v: string | null) {
|
||||||
if (!v) return []
|
if (!v) return []
|
||||||
return v.split(",").map((s) => s.trim()).filter(Boolean)
|
return v
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(request.url)
|
const url = new URL(req.url)
|
||||||
const keyword = (searchParams.get("q") || "").trim()
|
const meta = url.searchParams.get("meta")
|
||||||
|
if (meta === "tags") {
|
||||||
|
const tags = Array.from(new Set(getUsersStore().flatMap((u) => u.tags))).sort()
|
||||||
|
const cities = Array.from(new Set(getUsersStore().map((u) => u.city))).sort()
|
||||||
|
const personas = Array.from(new Set(getUsersStore().flatMap((u) => u.persona))).sort()
|
||||||
|
const sources = Array.from(new Set(getUsersStore().map((u) => u.source))).sort()
|
||||||
|
return NextResponse.json({ success: true, data: { tags, cities, personas, sources } })
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = keyword
|
const q = url.searchParams.get("q") ?? undefined
|
||||||
? MOCK_USERS.filter(
|
const tags = url.searchParams.get("tags")?.split(",").filter(Boolean)
|
||||||
(u) =>
|
const status = url.searchParams.get("status")?.split(",").filter(Boolean) as any
|
||||||
u.name.includes(keyword) ||
|
const city = url.searchParams.get("city")?.split(",").filter(Boolean)
|
||||||
(u.nickname && u.nickname.includes(keyword)) ||
|
const persona = url.searchParams.get("persona")?.split(",").filter(Boolean)
|
||||||
u.tags.some((t) => t.includes(keyword)),
|
const source = url.searchParams.get("source")?.split(",").filter(Boolean)
|
||||||
)
|
const rfmMin = Number(url.searchParams.get("rfmMin") ?? 0)
|
||||||
: MOCK_USERS
|
const rfmMax = Number(url.searchParams.get("rfmMax") ?? 100)
|
||||||
|
const page = Number(url.searchParams.get("page") ?? 1)
|
||||||
|
const pageSize = Number(url.searchParams.get("pageSize") ?? 20)
|
||||||
|
|
||||||
return NextResponse.json({
|
const { data, pagination } = queryUsers({
|
||||||
success: true,
|
q,
|
||||||
total: filtered.length,
|
tags,
|
||||||
items: filtered,
|
status,
|
||||||
|
city,
|
||||||
|
persona,
|
||||||
|
source,
|
||||||
|
rfmMin,
|
||||||
|
rfmMax,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 用户估值:简单以 rfmScore * 100 作为估值
|
||||||
|
const totalValue = data.reduce((sum, u) => sum + u.rfmScore * 100, 0)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: { items: data, pagination, totalValue } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: Request) {
|
||||||
const body = await req.json().catch(() => ({}))
|
const body = (await req.json()) as Partial<User>
|
||||||
const created = addUser(body ?? {})
|
const u = addUser(body)
|
||||||
return NextResponse.json({ data: created }, { status: 201 })
|
return NextResponse.json({ success: true, data: u })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,340 +1,246 @@
|
|||||||
"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 { Textarea } from "@/components/ui/textarea"
|
import { Database, Plus, RefreshCw, Settings, Brain, Play } from "lucide-react"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import BottomTabs from "@/components/nav/bottom-tabs"
|
||||||
|
|
||||||
interface DataSource {
|
interface DataSource {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: string
|
|
||||||
status: "connected" | "disconnected" | "syncing"
|
|
||||||
lastSync: string
|
|
||||||
recordCount: number
|
|
||||||
description: string
|
description: string
|
||||||
|
type: string
|
||||||
|
records: string
|
||||||
|
lastSync: string
|
||||||
|
status: "connected" | "disconnected" | "syncing"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIModel {
|
interface AIModel {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
status: "training" | "ready" | "error"
|
accuracy: string
|
||||||
accuracy: number
|
algorithm: string
|
||||||
|
features: number
|
||||||
lastTrained: string
|
lastTrained: string
|
||||||
parameters: Record<string, any>
|
status: "ready" | "training" | "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataPlatformPage() {
|
const mockDataSources: DataSource[] = [
|
||||||
const [dataSources, setDataSources] = useState<DataSource[]>([
|
|
||||||
{
|
{
|
||||||
id: "ds_001",
|
id: "wechat-db",
|
||||||
name: "微信用户数据库",
|
name: "微信用户数据库",
|
||||||
type: "MySQL",
|
|
||||||
status: "connected",
|
|
||||||
lastSync: "2024-01-15T10:30:00Z",
|
|
||||||
recordCount: 2500000000,
|
|
||||||
description: "存储微信用户基础信息和行为数据",
|
description: "存储微信用户基础信息和行为数据",
|
||||||
},
|
type: "MySQL",
|
||||||
{
|
records: "2.5B",
|
||||||
id: "ds_002",
|
lastSync: "2024/1/15 18:30:00",
|
||||||
name: "流量关键词库",
|
|
||||||
type: "PostgreSQL",
|
|
||||||
status: "connected",
|
status: "connected",
|
||||||
lastSync: "2024-01-15T09:45:00Z",
|
},
|
||||||
recordCount: 150000,
|
{
|
||||||
|
id: "traffic-keywords",
|
||||||
|
name: "流量关键词库",
|
||||||
description: "搜索引擎关键词和流量数据",
|
description: "搜索引擎关键词和流量数据",
|
||||||
|
type: "PostgreSQL",
|
||||||
|
records: "150.0K",
|
||||||
|
lastSync: "2024/1/15 17:45:00",
|
||||||
|
status: "connected",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ds_003",
|
id: "user-behavior",
|
||||||
name: "用户行为日志",
|
name: "用户行为日志",
|
||||||
type: "MongoDB",
|
|
||||||
status: "syncing",
|
|
||||||
lastSync: "2024-01-15T11:00:00Z",
|
|
||||||
recordCount: 1500000000,
|
|
||||||
description: "用户操作行为和交互记录",
|
description: "用户操作行为和交互记录",
|
||||||
|
type: "MongoDB",
|
||||||
|
records: "1.5B",
|
||||||
|
lastSync: "2024/1/15 19:00:00",
|
||||||
|
status: "syncing",
|
||||||
},
|
},
|
||||||
])
|
]
|
||||||
|
|
||||||
const [aiModels, setAiModels] = useState<AIModel[]>([
|
const mockAIModels: AIModel[] = [
|
||||||
{
|
{
|
||||||
id: "model_001",
|
id: "user-value-prediction",
|
||||||
name: "用户价值预测模型",
|
name: "用户价值预测模型",
|
||||||
type: "Classification",
|
type: "Classification",
|
||||||
status: "ready",
|
accuracy: "92.0%",
|
||||||
accuracy: 0.92,
|
|
||||||
lastTrained: "2024-01-14T15:30:00Z",
|
|
||||||
parameters: {
|
|
||||||
algorithm: "RandomForest",
|
algorithm: "RandomForest",
|
||||||
features: 25,
|
features: 25,
|
||||||
epochs: 100,
|
lastTrained: "2024/1/14 23:30:00",
|
||||||
learningRate: 0.01,
|
status: "ready",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "model_002",
|
id: "traffic-trend-analysis",
|
||||||
name: "流量趋势分析模型",
|
name: "流量趋势分析模型",
|
||||||
type: "Regression",
|
type: "Regression",
|
||||||
status: "training",
|
accuracy: "87.0%",
|
||||||
accuracy: 0.87,
|
|
||||||
lastTrained: "2024-01-15T08:00:00Z",
|
|
||||||
parameters: {
|
|
||||||
algorithm: "LSTM",
|
algorithm: "LSTM",
|
||||||
features: 15,
|
features: 15,
|
||||||
epochs: 200,
|
lastTrained: "2024/1/15 16:00:00",
|
||||||
learningRate: 0.001,
|
status: "training",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "model_003",
|
id: "user-clustering",
|
||||||
name: "用户聚类模型",
|
name: "用户聚类模型",
|
||||||
type: "Clustering",
|
type: "Clustering",
|
||||||
status: "ready",
|
accuracy: "89.0%",
|
||||||
accuracy: 0.89,
|
|
||||||
lastTrained: "2024-01-13T12:00:00Z",
|
|
||||||
parameters: {
|
|
||||||
algorithm: "KMeans",
|
algorithm: "KMeans",
|
||||||
clusters: 8,
|
|
||||||
features: 20,
|
features: 20,
|
||||||
iterations: 300,
|
lastTrained: "2024/1/13 20:00:00",
|
||||||
|
status: "ready",
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
])
|
|
||||||
|
|
||||||
const [isAddingDataSource, setIsAddingDataSource] = useState(false)
|
export default function DataPlatformPage() {
|
||||||
const [isTrainingModel, setIsTrainingModel] = useState(false)
|
const [activeTab, setActiveTab] = useState("datasource")
|
||||||
|
const [dataSources, setDataSources] = useState<DataSource[]>(mockDataSources)
|
||||||
|
const [aiModels, setAIModels] = useState<AIModel[]>(mockAIModels)
|
||||||
|
const [syncing, setSyncing] = useState<string | null>(null)
|
||||||
|
const [training, setTraining] = useState<string | null>(null)
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||||
const [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 [modelTrainingConfig, setModelTrainingConfig] = useState({
|
const handleSync = async (sourceId: string) => {
|
||||||
modelId: "",
|
setSyncing(sourceId)
|
||||||
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((ds) =>
|
prev.map((source) =>
|
||||||
ds.id === newSource.id
|
source.id === sourceId ? { ...source, lastSync: new Date().toLocaleString("zh-CN") } : source,
|
||||||
? { ...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) => {
|
||||||
const handleSyncDataSource = (id: string) => {
|
setTraining(modelId)
|
||||||
setDataSources((prev) =>
|
|
||||||
prev.map((ds) => (ds.id === id ? { ...ds, status: "syncing" as const, lastSync: new Date().toISOString() } : ds)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 模拟同步完成
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDataSources((prev) =>
|
setTraining(null)
|
||||||
prev.map((ds) =>
|
setAIModels((prev) =>
|
||||||
ds.id === id
|
prev.map((model) =>
|
||||||
? {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 训练AI模型
|
const handleAddDataSource = () => {
|
||||||
const handleTrainModel = async () => {
|
const newSource: DataSource = {
|
||||||
if (!modelTrainingConfig.modelId) return
|
id: `datasource-${Date.now()}`,
|
||||||
|
name: newDataSource.name,
|
||||||
setIsTrainingModel(true)
|
description: newDataSource.description,
|
||||||
|
type: newDataSource.type,
|
||||||
// 更新模型状态为训练中
|
records: "0",
|
||||||
setAiModels((prev) =>
|
lastSync: "从未同步",
|
||||||
prev.map((model) =>
|
status: "disconnected",
|
||||||
model.id === modelTrainingConfig.modelId ? { ...model, status: "training" as const } : model,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 模拟训练过程
|
|
||||||
setTimeout(() => {
|
|
||||||
setAiModels((prev) =>
|
|
||||||
prev.map((model) =>
|
|
||||||
model.id === modelTrainingConfig.modelId
|
|
||||||
? {
|
|
||||||
...model,
|
|
||||||
status: "ready" as const,
|
|
||||||
accuracy: Math.random() * 0.1 + 0.85,
|
|
||||||
lastTrained: new Date().toISOString(),
|
|
||||||
parameters: {
|
|
||||||
algorithm: modelTrainingConfig.algorithm,
|
|
||||||
features: modelTrainingConfig.features,
|
|
||||||
epochs: modelTrainingConfig.epochs,
|
|
||||||
learningRate: modelTrainingConfig.learningRate,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: model,
|
setDataSources((prev) => [...prev, newSource])
|
||||||
),
|
setNewDataSource({
|
||||||
)
|
name: "",
|
||||||
setIsTrainingModel(false)
|
description: "",
|
||||||
}, 5000)
|
type: "MySQL",
|
||||||
|
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":
|
||||||
case "ready":
|
return <Badge className="bg-green-100 text-green-800 border-green-200">Connected</Badge>
|
||||||
return "text-green-600 bg-green-50 border-green-200"
|
|
||||||
case "syncing":
|
|
||||||
case "training":
|
|
||||||
return "text-yellow-600 bg-yellow-50 border-yellow-200"
|
|
||||||
case "disconnected":
|
case "disconnected":
|
||||||
case "error":
|
return <Badge variant="destructive">Disconnected</Badge>
|
||||||
return "text-red-600 bg-red-50 border-red-200"
|
case "syncing":
|
||||||
|
return <Badge className="bg-blue-100 text-blue-800 border-blue-200">Syncing</Badge>
|
||||||
default:
|
default:
|
||||||
return "text-gray-600 bg-gray-50 border-gray-200"
|
return <Badge variant="secondary">Unknown</Badge>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取状态图标
|
const getModelStatusBadge = (status: AIModel["status"]) => {
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "connected":
|
|
||||||
case "ready":
|
case "ready":
|
||||||
return <CheckCircle className="w-4 h-4" />
|
return <Badge className="bg-green-100 text-green-800 border-green-200">Ready</Badge>
|
||||||
case "syncing":
|
|
||||||
case "training":
|
case "training":
|
||||||
return <Zap className="w-4 h-4 animate-pulse" />
|
return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">Training</Badge>
|
||||||
case "disconnected":
|
|
||||||
case "error":
|
case "error":
|
||||||
return <AlertCircle className="w-4 h-4" />
|
return <Badge variant="destructive">Error</Badge>
|
||||||
default:
|
default:
|
||||||
return <Database className="w-4 h-4" />
|
return <Badge variant="secondary">Unknown</Badge>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="bg-white border-b">
|
||||||
{/* 页面标题 */}
|
<div className="container mx-auto px-4 py-6">
|
||||||
<div className="mb-8">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">数据中台</h1>
|
<div>
|
||||||
<p className="text-gray-600">数据源管理与AI模型训练平台</p>
|
<h1 className="text-2xl font-bold text-gray-900">数据中台</h1>
|
||||||
|
<p className="text-gray-600 mt-1">数据源管理与AI模型训练平台</p>
|
||||||
</div>
|
</div>
|
||||||
|
{activeTab === "datasource" ? (
|
||||||
<Tabs defaultValue="datasources" className="space-y-6">
|
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||||
<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="max-w-2xl">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>添加新数据源</DialogTitle>
|
<DialogTitle>添加新数据源</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-2 gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="name">数据源名称</Label>
|
<Label htmlFor="name" className="text-right">
|
||||||
|
名称
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={newDataSource.name}
|
value={newDataSource.name}
|
||||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setNewDataSource({ ...newDataSource, name: e.target.value })}
|
||||||
placeholder="输入数据源名称"
|
className="col-span-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="type">数据库类型</Label>
|
<Label htmlFor="description" className="text-right">
|
||||||
|
描述
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={newDataSource.description}
|
||||||
|
onChange={(e) => setNewDataSource({ ...newDataSource, description: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="type" className="text-right">
|
||||||
|
类型
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={newDataSource.type}
|
value={newDataSource.type}
|
||||||
onValueChange={(value) => setNewDataSource((prev) => ({ ...prev, type: value }))}
|
onValueChange={(value) => setNewDataSource({ ...newDataSource, type: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="col-span-3">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -342,332 +248,194 @@ 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="space-y-2">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="host">主机地址</Label>
|
<Label htmlFor="host" className="text-right">
|
||||||
|
主机
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="host"
|
id="host"
|
||||||
value={newDataSource.host}
|
value={newDataSource.host}
|
||||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, host: e.target.value }))}
|
onChange={(e) => setNewDataSource({ ...newDataSource, host: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
placeholder="localhost"
|
placeholder="localhost"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="port">端口</Label>
|
<Label htmlFor="port" className="text-right">
|
||||||
|
端口
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="port"
|
id="port"
|
||||||
value={newDataSource.port}
|
value={newDataSource.port}
|
||||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, port: e.target.value }))}
|
onChange={(e) => setNewDataSource({ ...newDataSource, 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={() => setIsAddingDataSource(false)}>
|
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAddDataSource}>连接并导入数据</Button>
|
<Button onClick={handleAddDataSource} disabled={!newDataSource.name || !newDataSource.type}>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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" />
|
|
||||||
同步中
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Button className="flex items-center gap-2" onClick={() => handleRetrain("all")}>
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
同步数据
|
重新训练模型
|
||||||
</>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="datasource" className="flex items-center gap-2">
|
||||||
|
<Database className="w-4 h-4" />
|
||||||
|
数据源管理
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="aimodel" className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4" />
|
||||||
|
AI模型
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-4 py-6">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsContent value="datasource" className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold">数据源管理</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{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>
|
||||||
<Button size="sm" variant="outline">
|
<Button variant="ghost" size="sm">
|
||||||
<Settings className="w-3 h-3" />
|
<Settings className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* AI模型管理 */}
|
<TabsContent value="aimodel" className="space-y-4">
|
||||||
<TabsContent value="aimodels" className="space-y-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex justify-between items-center">
|
<h2 className="text-xl font-semibold">AI模型管理</h2>
|
||||||
<h2 className="text-2xl font-semibold">AI模型管理</h2>
|
</div>
|
||||||
<Dialog open={isTrainingModel} onOpenChange={setIsTrainingModel}>
|
|
||||||
<DialogTrigger asChild>
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<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) => (
|
{aiModels.map((model) => (
|
||||||
<SelectItem key={model.id} value={model.id}>
|
<Card key={model.id} className="bg-white">
|
||||||
{model.name}
|
<CardHeader className="pb-4">
|
||||||
</SelectItem>
|
<div className="flex items-start justify-between">
|
||||||
))}
|
<div className="flex-1">
|
||||||
</SelectContent>
|
<CardTitle className="text-lg font-semibold mb-2">{model.name}</CardTitle>
|
||||||
</Select>
|
<p className="text-gray-600 text-sm mb-3">{model.type} 模型</p>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span>准确率</span>
|
||||||
|
<span className="font-medium">{model.accuracy}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div className="bg-gray-900 h-2 rounded-full" style={{ width: model.accuracy }}></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1 text-sm text-gray-500">
|
||||||
<Label htmlFor="algorithm">算法</Label>
|
<div>
|
||||||
<Select
|
算法:<span className="font-medium">{model.algorithm}</span>
|
||||||
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>
|
||||||
|
<div>
|
||||||
<div className="space-y-2">
|
特征数:<span className="font-medium">{model.features}</span>
|
||||||
<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>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setIsTrainingModel(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleTrainModel} disabled={!modelTrainingConfig.modelId}>
|
|
||||||
开始训练
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
<div className="flex items-center gap-2">
|
||||||
</Dialog>
|
{getModelStatusBadge(training === model.id ? "training" : model.status)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{aiModels.map((model) => (
|
|
||||||
<Card key={model.id} className="border-2 hover:shadow-lg transition-all duration-200">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">{model.name}</CardTitle>
|
|
||||||
<Badge className={`${getStatusColor(model.status)} border`}>
|
|
||||||
{getStatusIcon(model.status)}
|
|
||||||
<span className="ml-1 capitalize">{model.status}</span>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{model.type} 模型</p>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="pt-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="text-sm text-gray-500">最后训练:{model.lastTrained}</div>
|
||||||
<span className="text-gray-500">准确率</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{(model.accuracy * 100).toFixed(1)}%</span>
|
{model.status === "ready" ? (
|
||||||
</div>
|
<Button variant="outline" size="sm" className="flex items-center gap-2 flex-1 bg-transparent">
|
||||||
<Progress value={model.accuracy * 100} className="h-2" />
|
<Play className="w-4 h-4" />
|
||||||
</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>
|
||||||
|
) : 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>
|
</Button>
|
||||||
<Button size="sm" variant="outline">
|
</div>
|
||||||
<Settings className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -676,6 +444,8 @@ export default function DataPlatformPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BottomTabs />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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"] })
|
||||||
@@ -8,7 +9,7 @@ const inter = Inter({ subsets: ["latin"] })
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "卡若数据资产中台",
|
title: "卡若数据资产中台",
|
||||||
description: "基于IMEI、手机号的用户数据资产管理平台",
|
description: "基于IMEI、手机号的用户数据资产管理平台",
|
||||||
generator: 'v0.dev'
|
generator: 'v0.app'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -18,6 +19,3 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return <ClientLayout>{children}</ClientLayout>
|
return <ClientLayout>{children}</ClientLayout>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
import './globals.css'
|
|
||||||
541
app/page.tsx
541
app/page.tsx
@@ -1,105 +1,70 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { useState } from "react"
|
||||||
import { useState, useEffect } from "react"
|
import { Users, Database, Brain, BarChart3, Activity, MessageSquare, Zap, Send } from "lucide-react"
|
||||||
import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe, Smartphone, Brain } from 'lucide-react'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { 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 { Toaster } from "@/components/ui/toaster"
|
import BottomTabs from "@/components/nav/bottom-tabs"
|
||||||
import UserSearch from '@/components/home/user-search'
|
|
||||||
import UserList from '@/components/home/user-list'
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
interface SystemStats {
|
interface SystemStats {
|
||||||
userCount: number
|
userCount: number
|
||||||
keywordCount: number
|
userAssets: number
|
||||||
versionCount: number
|
dataVolume: string
|
||||||
avgResponseTime: number
|
aiQueries: number
|
||||||
cacheSize: number
|
|
||||||
connected: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GrowthData {
|
interface ChatMessage {
|
||||||
period: string
|
id: string
|
||||||
userGrowth: number
|
type: "user" | "ai"
|
||||||
dataGrowth: number
|
content: string
|
||||||
activeUsers: number
|
timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
export default function HomePage() {
|
||||||
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 const dynamic = "force-static"
|
|
||||||
|
|
||||||
export default function OverviewPage() {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [systemStats, setSystemStats] = useState<SystemStats>({
|
const [selectedModel, setSelectedModel] = useState("gpt-5")
|
||||||
|
const [showChat, setShowChat] = useState(false)
|
||||||
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<SystemStats>({
|
||||||
userCount: 4000000000,
|
userCount: 4000000000,
|
||||||
keywordCount: 150000,
|
userAssets: 4000000000,
|
||||||
versionCount: 25,
|
dataVolume: "2.5TB",
|
||||||
avgResponseTime: 120,
|
aiQueries: 156789,
|
||||||
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 () => {
|
||||||
useEffect(() => {
|
if (!searchQuery.trim()) return
|
||||||
const interval = setInterval(() => {
|
|
||||||
refreshData()
|
|
||||||
}, 30000) // 30秒刷新一次
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
const userMessage: ChatMessage = {
|
||||||
}, [])
|
id: Date.now().toString(),
|
||||||
|
type: "user",
|
||||||
// 刷新数据
|
content: searchQuery,
|
||||||
const refreshData = async () => {
|
timestamp: new Date(),
|
||||||
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])
|
||||||
const handleSearch = () => {
|
setIsLoading(true)
|
||||||
if (searchQuery.trim()) {
|
setShowChat(true)
|
||||||
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("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化数字显示
|
// 格式化数字显示
|
||||||
@@ -116,251 +81,229 @@ export default function OverviewPage() {
|
|||||||
return num.toString()
|
return num.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const tiles = [
|
// 大模型选项
|
||||||
{
|
const modelOptions = [
|
||||||
href: "/user-portrait",
|
{ value: "gpt-5", label: "GPT-5", provider: "OpenAI" },
|
||||||
title: "用户画像",
|
{ value: "gpt-4", label: "GPT-4", provider: "OpenAI" },
|
||||||
desc: "管理与分群、标签与画像洞察",
|
{ value: "claude-3", label: "Claude-3", provider: "Anthropic" },
|
||||||
icon: <Users className="h-5 w-5 text-purple-500" />,
|
{ value: "gemini-pro", label: "Gemini Pro", provider: "Google" },
|
||||||
},
|
{ value: "qwen-max", label: "通义千问", provider: "阿里云" },
|
||||||
{
|
{ value: "baichuan", label: "百川大模型", provider: "百川智能" },
|
||||||
href: "/devices",
|
|
||||||
title: "设备管理",
|
|
||||||
desc: "查看设备状态与执行任务",
|
|
||||||
icon: <Smartphone className="h-5 w-5 text-emerald-500" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/data-platform",
|
|
||||||
title: "数据中台",
|
|
||||||
desc: "数据接入、质量监控与查询",
|
|
||||||
icon: <Database className="h-5 w-5 text-indigo-500" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/ai-assistant",
|
|
||||||
title: "AI 助手",
|
|
||||||
desc: "智能问答与自动化报表",
|
|
||||||
icon: <Brain className="h-5 w-5 text-rose-500" />,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
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-8">
|
<div className="container mx-auto px-4 py-6">
|
||||||
{/* 页面标题和搜索 */}
|
{/* 页面标题 */}
|
||||||
<div className="mb-8">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">卡若数据资产中台</h1>
|
||||||
|
<p className="text-gray-600">智能数据分析与AI助手平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 核心统计数据 - 带图标的图表 */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">概览</h1>
|
<p className="text-sm text-gray-600 mb-1">用户总量</p>
|
||||||
<p className="text-gray-600">数据资产中台</p>
|
<p className="text-2xl font-bold text-blue-600">{formatNumber(stats.userCount)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="p-3 bg-blue-500 rounded-full">
|
||||||
<Badge variant="outline" className="text-green-600 border-green-200">
|
<Users className="w-6 h-6 text-white" />
|
||||||
<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 className="relative max-w-2xl">
|
|
||||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
||||||
<Input
|
|
||||||
placeholder="搜索用户或流量关键词..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
|
|
||||||
className="pl-12 pr-24 h-14 text-lg border-2 border-gray-200 focus:border-blue-500 rounded-xl shadow-sm"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSearch}
|
|
||||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-lg"
|
|
||||||
>
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 核心数据展示 - 40亿用户为中心 */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
|
||||||
{/* 用户总数 - 主要指标 */}
|
|
||||||
<Card className="lg:col-span-2 border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-3 text-2xl">
|
|
||||||
<div className="p-3 bg-blue-500 rounded-xl">
|
|
||||||
<Users className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
用户资产总量
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-6xl font-bold text-blue-600 mb-2">{formatNumber(systemStats.userCount)}</div>
|
|
||||||
<div className="text-lg text-gray-600">总用户数</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
|
|
||||||
{growthData.map((data, index) => (
|
|
||||||
<div key={index} className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">+{data.userGrowth.toFixed(1)}%</div>
|
|
||||||
<div className="text-sm text-gray-500">{data.period}增长</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1">活跃: {formatNumber(data.activeUsers)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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">
|
||||||
<Card className="border-2 border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
|
<CardContent className="p-4">
|
||||||
<CardHeader className="pb-3">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-3">
|
<div>
|
||||||
<div className="p-2 bg-green-500 rounded-lg">
|
<p className="text-sm text-gray-600 mb-1">用户资产</p>
|
||||||
<Activity className="w-6 h-6 text-white" />
|
<p className="text-2xl font-bold text-purple-600">{formatNumber(stats.userAssets)}</p>
|
||||||
</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">
|
{/* 大模型选择 */}
|
||||||
<span className="text-gray-600">响应时间</span>
|
<div className="flex items-center gap-4">
|
||||||
<Badge variant="secondary">{systemStats.avgResponseTime}ms</Badge>
|
<span className="text-sm text-gray-600">选择大模型:</span>
|
||||||
</div>
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
<div className="flex justify-between items-center">
|
<SelectTrigger className="w-48">
|
||||||
<span className="text-gray-600">数据源</span>
|
<SelectValue />
|
||||||
<Badge variant="secondary">{systemStats.versionCount}个</Badge>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<div className="flex justify-between items-center">
|
{modelOptions.map((model) => (
|
||||||
<span className="text-gray-600">缓存大小</span>
|
<SelectItem key={model.value} value={model.value}>
|
||||||
<Badge variant="secondary">{systemStats.cacheSize}MB</Badge>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Brain className="w-4 h-4" />
|
||||||
<div className="text-xs text-gray-500 pt-2 border-t">最后更新: {lastUpdate.toLocaleTimeString()}</div>
|
<span>{model.label}</span>
|
||||||
</CardContent>
|
<Badge variant="secondary" className="text-xs">
|
||||||
</Card>
|
{model.provider}
|
||||||
</div>
|
</Badge>
|
||||||
|
|
||||||
{/* 数据增长趋势 */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
||||||
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-purple-500 rounded-lg">
|
|
||||||
<TrendingUp className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
用户资产增长
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{growthData.map((data, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold">{data.period}</div>
|
|
||||||
<div className="text-sm text-gray-500">活跃用户: {formatNumber(data.activeUsers)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-lg font-bold text-purple-600">+{data.userGrowth.toFixed(1)}%</div>
|
|
||||||
<div className="text-xs text-gray-500">增长率</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
</CardContent>
|
</Select>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
{/* 搜索输入框 */}
|
||||||
<div>
|
<div className="relative">
|
||||||
<div className="font-semibold">数据处理量</div>
|
<MessageSquare className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
<div className="text-sm text-gray-500">实时处理中</div>
|
<Input
|
||||||
</div>
|
placeholder="向AI提问,例如:分析用户增长趋势、搜索数据库信息..."
|
||||||
<div className="text-right">
|
value={searchQuery}
|
||||||
<div className="text-lg font-bold text-orange-600">
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
{formatNumber(Math.floor(systemStats.userCount * 0.001))}
|
onKeyPress={(e) => e.key === "Enter" && handleAIChat()}
|
||||||
</div>
|
className="pl-12 pr-24 h-12 text-base border-2 border-gray-200 focus:border-indigo-500 rounded-lg"
|
||||||
<div className="text-xs text-gray-500">条/秒</div>
|
/>
|
||||||
</div>
|
<Button
|
||||||
</div>
|
onClick={handleAIChat}
|
||||||
|
disabled={isLoading}
|
||||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-md"
|
||||||
<div>
|
>
|
||||||
<div className="font-semibold">存储容量</div>
|
<Send className="w-4 h-4 mr-2" />
|
||||||
<div className="text-sm text-gray-500">云端存储</div>
|
{isLoading ? "思考中..." : "提问"}
|
||||||
</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 mb-8">
|
|
||||||
{tiles.map((t) => (
|
|
||||||
<Card key={t.href} className="transition hover:shadow-md">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-lg">{t.title}</CardTitle>
|
|
||||||
{t.icon}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">{t.desc}</p>
|
|
||||||
<Button asChild size="sm" className="ml-3">
|
|
||||||
<Link href={t.href}>进入</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</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>
|
|
||||||
|
|
||||||
{/* 快速指标示例(可后续接入真实数据) */}
|
{/* 功能图表展示 */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<StatCard icon={<Users className="h-4 w-4" />} label="用户总量" value="~120+" />
|
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => router.push("/user-pool")}>
|
||||||
<StatCard icon={<Activity className="h-4 w-4" />} label="近7日活跃" value="动态计算" />
|
<CardContent className="p-6 text-center">
|
||||||
<StatCard icon={<BarChart3 className="h-4 w-4" />} label="平均RFM" value="50-80" />
|
<div className="p-4 bg-purple-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||||
<StatCard icon={<Users className="h-4 w-4" />} label="新客占比" value="≈25%" />
|
<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">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Activity className="w-3 h-3 mr-1" />
|
||||||
|
实时更新
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 搜索 + 条件过滤 + 列表 */}
|
<Card
|
||||||
<UserSearch query={searchQuery} />
|
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={() => router.push("/data-platform")}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div className="p-4 bg-blue-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<Database className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold mb-2">数据中台</h3>
|
||||||
|
<p className="text-sm text-gray-500">数据源管理与AI模型</p>
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Zap className="w-3 h-3 mr-1" />
|
||||||
|
高性能
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={() => router.push("/ai-assistant")}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div className="p-4 bg-green-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<Brain className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold mb-2">AI助手</h3>
|
||||||
|
<p className="text-sm text-gray-500">智能问答与自动化</p>
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<MessageSquare className="w-3 h-3 mr-1" />
|
||||||
|
多模型
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* PC端隐藏底部导航栏 */}
|
||||||
|
<div className="block md:hidden">
|
||||||
|
<BottomTabs />
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,311 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import MobileHeader from "@/app/components/MobileHeader"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import BottomNav from "@/app/components/BottomNav"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import Section from "@/components/user-portrait/mobile/section"
|
import { Button } from "@/components/ui/button"
|
||||||
import ProfileHeader from "@/components/user-portrait/mobile/profile-header"
|
import { ArrowLeft, User, Tag, TrendingUp, DollarSign, Calendar, Phone, Mail, MapPin } from "lucide-react"
|
||||||
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 Detail = {
|
type UserDetail = {
|
||||||
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 [data, setData] = useState<Detail | null>(null)
|
const router = useRouter()
|
||||||
|
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)
|
||||||
fetch(`/api/users?id=${params.id}`)
|
setTimeout(() => {
|
||||||
.then((r) => r.json())
|
setData({
|
||||||
.then((res) => setData(res?.data ?? null))
|
id: params.id,
|
||||||
.finally(() => setLoading(false))
|
name: "张三",
|
||||||
|
email: "zhangsan@example.com",
|
||||||
|
phone: "138****8888",
|
||||||
|
city: "厦门",
|
||||||
|
store: "思明店",
|
||||||
|
project: "贷款业务",
|
||||||
|
team: "A组",
|
||||||
|
tags: ["高价值客户", "活跃用户", "信用良好"],
|
||||||
|
persona: ["高净值人群", "投资偏好保守"],
|
||||||
|
source: "微信推广",
|
||||||
|
recency: 85,
|
||||||
|
frequency: 92,
|
||||||
|
monetary: 88,
|
||||||
|
rfmScore: 88,
|
||||||
|
assetValue: 1250000,
|
||||||
|
riskLevel: "低",
|
||||||
|
lastActivity: "2024-01-15 14:30:00",
|
||||||
|
status: "活跃",
|
||||||
|
detailedTags: [
|
||||||
|
{
|
||||||
|
category: "行为标签",
|
||||||
|
tags: [
|
||||||
|
{ name: "高频访问", confidence: 95, source: "系统分析" },
|
||||||
|
{ name: "深度浏览", confidence: 88, source: "行为追踪" },
|
||||||
|
{ name: "主动咨询", confidence: 92, source: "客服记录" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "偏好标签",
|
||||||
|
tags: [
|
||||||
|
{ name: "理财产品", confidence: 90, source: "购买记录" },
|
||||||
|
{ name: "保险产品", confidence: 75, source: "浏览记录" },
|
||||||
|
{ name: "投资咨询", confidence: 85, source: "咨询记录" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "风险标签",
|
||||||
|
tags: [
|
||||||
|
{ name: "信用优良", confidence: 98, source: "征信报告" },
|
||||||
|
{ name: "收入稳定", confidence: 92, source: "银行流水" },
|
||||||
|
{ name: "资产充足", confidence: 88, source: "资产证明" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assetBreakdown: [
|
||||||
|
{ category: "客户生命周期价值", value: 450000, percentage: 36 },
|
||||||
|
{ category: "交叉销售潜力", value: 320000, percentage: 26 },
|
||||||
|
{ category: "推荐价值", value: 280000, percentage: 22 },
|
||||||
|
{ category: "品牌忠诚度价值", value: 200000, percentage: 16 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
}, 500)
|
||||||
}, [params?.id])
|
}, [params?.id])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
<div className="min-h-screen bg-slate-50 p-4">
|
||||||
<MobileHeader onMenuToggle={() => {}} title="用户详情" />
|
<div className="animate-pulse space-y-4">
|
||||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
<div className="h-12 bg-slate-200 rounded"></div>
|
||||||
{loading ? (
|
<div className="h-32 bg-slate-200 rounded"></div>
|
||||||
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-muted-foreground">
|
<div className="h-48 bg-slate-200 rounded"></div>
|
||||||
加载中…
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-4 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-slate-600 mb-4">未找到该用户</p>
|
||||||
|
<Button onClick={() => router.back()}>返回</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50">
|
||||||
|
<div className="bg-white border-b px-4 py-3 flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="font-semibold">用户详细信息</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,303 @@
|
|||||||
import { Suspense } from "react"
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Slider } from "@/components/ui/slider"
|
||||||
import Link from "next/link"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Search, Filter, Users, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
type ListItem = {
|
||||||
|
id: string
|
||||||
async function fetchUsers(q: string) {
|
name: string
|
||||||
const url = q ? `/api/users?q=${encodeURIComponent(q)}` : "/api/users"
|
phone: string
|
||||||
const res = await fetch(url, { cache: "no-store" })
|
email: string
|
||||||
if (!res.ok) return { items: [], total: 0 }
|
tags: string[]
|
||||||
return res.json()
|
rfmScore: number
|
||||||
|
lastActiveAt: string
|
||||||
|
city: string
|
||||||
|
store: string
|
||||||
|
project: string
|
||||||
|
team: string
|
||||||
|
persona: string[]
|
||||||
|
source: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tabs({ active = "users" }: { active?: "users" | "tags" }) {
|
type Meta = {
|
||||||
return (
|
tags: string[]
|
||||||
<div className="grid grid-cols-2 rounded-md border bg-muted/30 text-sm">
|
cities: string[]
|
||||||
<div className={`px-4 py-2 text-center ${active === "users" ? "bg-background font-medium" : "text-muted-foreground"}`}>用户管理</div>
|
personas: string[]
|
||||||
<Link href="/user-portrait/tags" className={`px-4 py-2 text-center rounded-r-md ${active === "tags" ? "bg-background font-medium" : "text-muted-foreground"}`}>标签管理</Link>
|
sources: string[]
|
||||||
</div>
|
}
|
||||||
)
|
|
||||||
|
export default function UserPoolPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
const [rfmRange, setRfmRange] = useState<[number, number]>([0, 100])
|
||||||
|
const [items, setItems] = useState<ListItem[]>([])
|
||||||
|
const [meta, setMeta] = useState<Meta>({ tags: [], cities: [], personas: [], sources: [] })
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
tags: [] as string[],
|
||||||
|
status: [] as string[],
|
||||||
|
city: [] as string[],
|
||||||
|
persona: [] as string[],
|
||||||
|
source: [] as string[],
|
||||||
|
})
|
||||||
|
const [totalValue, setTotalValue] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/users?meta=tags")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((res) => setMeta(res.data))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const queryString = useMemo(() => {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
if (q) p.set("q", q)
|
||||||
|
if (filters.tags.length) p.set("tags", filters.tags.join(","))
|
||||||
|
if (filters.status.length) p.set("status", filters.status.join(","))
|
||||||
|
if (filters.city.length) p.set("city", filters.city.join(","))
|
||||||
|
if (filters.persona.length) p.set("persona", filters.persona.join(","))
|
||||||
|
if (filters.source.length) p.set("source", filters.source.join(","))
|
||||||
|
p.set("rfmMin", String(rfmRange[0]))
|
||||||
|
p.set("rfmMax", String(rfmRange[1]))
|
||||||
|
p.set("page", "1")
|
||||||
|
p.set("pageSize", "50")
|
||||||
|
return p.toString()
|
||||||
|
}, [q, filters, rfmRange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/users?${queryString}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((res) => {
|
||||||
|
setItems(res.data.items)
|
||||||
|
setTotalValue(res.data.totalValue)
|
||||||
|
})
|
||||||
|
}, [queryString])
|
||||||
|
|
||||||
|
const toggle = (key: keyof typeof filters, v: string) =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: prev[key].includes(v) ? prev[key].filter((x) => x !== v) : [...prev[key], v],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleUserClick = (userId: string) => {
|
||||||
|
router.push(`/user-portrait/${userId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function UsersList({ q }: { q: string }) {
|
|
||||||
const { items } = await fetchUsers(q)
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="container mx-auto px-4 py-5 space-y-4">
|
||||||
{items.map((u: any) => (
|
{/* 核心板块:用户估值概览 */}
|
||||||
<Card key={u.id} className="overflow-hidden">
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">{u.name}{u.nickname ? ` · ${u.nickname}` : ""}</CardTitle>
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
<p className="text-xs text-muted-foreground">最后活跃:{new Date(u.lastActive).toLocaleString()}</p>
|
<Users className="h-5 w-5" />
|
||||||
</CardHeader>
|
用户池 · 资产评估为核心
|
||||||
<CardContent className="flex items-center justify-between">
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm text-muted-foreground">通过 RFM 和画像维度评估用户价值与分布,支持用户导入导出</p>
|
||||||
<img
|
|
||||||
src={u.avatar || "/generic-user-avatar.png"}
|
|
||||||
alt="avatar"
|
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{u.tags?.slice(0, 3).map((t: string) => (
|
|
||||||
<span key={t} className="rounded bg-muted px-2 py-0.5 text-xs">{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href={`/user-portrait/${u.id}`}>查看</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{items.length === 0 && (
|
|
||||||
<p className="col-span-full py-8 text-center text-sm text-muted-foreground">暂无用户数据</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function UserPortraitPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: { q?: string }
|
|
||||||
}) {
|
|
||||||
const q = (searchParams?.q || "").trim()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="mx-auto max-w-6xl p-4 md:p-6">
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-xl md:text-2xl">用户画像</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground">管理与分群</p>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
<Tabs />
|
<Stat label="当前筛选用户数" value={`${items.length} 人`} />
|
||||||
<div className="flex gap-2">
|
<Stat label="估值总额(≈)" value={`¥ ${totalValue.toLocaleString("zh-CN")}`} />
|
||||||
<form action="/user-portrait" className="flex gap-2">
|
<Stat label="RFM 范围" value={`${rfmRange[0]} - ${rfmRange[1]}`} />
|
||||||
<Input
|
<Stat label="已选标签数" value={`${filters.tags.length}`} />
|
||||||
name="q"
|
|
||||||
defaultValue={q}
|
|
||||||
placeholder="搜索用户、昵称或标签..."
|
|
||||||
className="w-64"
|
|
||||||
/>
|
|
||||||
<Button type="submit" variant="secondary">筛选</Button>
|
|
||||||
</form>
|
|
||||||
<Button>+ 添加用户</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense key={q} fallback={<p className="mt-4 text-sm text-muted-foreground">加载中...</p>}>
|
|
||||||
{/* @ts-expect-error Async Server Component */}
|
|
||||||
<UsersList q={q} />
|
|
||||||
</Suspense>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
|
||||||
|
{/* 筛选区:清晰多维度 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4" /> 筛选条件
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-col md:flex-row gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索姓名/手机/邮箱/标签/画像标签"
|
||||||
|
className="pl-8"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FilterRow title="RFM" right={<span className="text-xs text-muted-foreground">0~100</span>}>
|
||||||
|
<Slider
|
||||||
|
value={rfmRange}
|
||||||
|
onValueChange={(v) => setRfmRange(v as [number, number])}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</FilterRow>
|
||||||
|
<FilterRow title="城市">
|
||||||
|
<Chips options={meta.cities} actives={filters.city} onToggle={(v) => toggle("city", v)} />
|
||||||
|
</FilterRow>
|
||||||
|
<FilterRow title="标签">
|
||||||
|
<Chips options={meta.tags} actives={filters.tags} onToggle={(v) => toggle("tags", v)} />
|
||||||
|
</FilterRow>
|
||||||
|
<FilterRow title="画像( Persona )">
|
||||||
|
<Chips options={meta.personas} actives={filters.persona} onToggle={(v) => toggle("persona", v)} />
|
||||||
|
</FilterRow>
|
||||||
|
<FilterRow title="来源">
|
||||||
|
<Chips options={meta.sources} actives={filters.source} onToggle={(v) => toggle("source", v)} />
|
||||||
|
</FilterRow>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center justify-between">
|
||||||
|
<span>用户池列表</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="text-xs px-3 py-1 bg-blue-50 text-blue-600 rounded-md hover:bg-blue-100">
|
||||||
|
导入用户
|
||||||
|
</button>
|
||||||
|
<button className="text-xs px-3 py-1 bg-green-50 text-green-600 rounded-md hover:bg-green-100">
|
||||||
|
导出用户词
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="hidden md:block rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>姓名</TableHead>
|
||||||
|
<TableHead>城市/门店</TableHead>
|
||||||
|
<TableHead>项目/团队</TableHead>
|
||||||
|
<TableHead>Persona</TableHead>
|
||||||
|
<TableHead>标签</TableHead>
|
||||||
|
<TableHead className="text-center">RFM</TableHead>
|
||||||
|
<TableHead className="text-center">资产评估</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((u) => (
|
||||||
|
<TableRow
|
||||||
|
key={u.id}
|
||||||
|
className="cursor-pointer hover:bg-slate-50"
|
||||||
|
onClick={() => handleUserClick(u.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{u.name}
|
||||||
|
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{u.city} / {u.store}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{u.project} / {u.team}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{u.persona.map((p) => (
|
||||||
|
<Badge key={p} variant="secondary" className="text-[10px]">
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{u.tags.slice(0, 3).map((t) => (
|
||||||
|
<Badge key={t} className="text-[10px]">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">{u.rfmScore}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<TrendingUp className="h-3 w-3 text-green-500" />
|
||||||
|
<span className="text-xs text-green-600">高价值</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端卡片 */}
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="text-lg font-semibold">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterRow({ title, children, right }: { title: string; children: React.ReactNode; right?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">{title}</div>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chips({
|
||||||
|
options,
|
||||||
|
actives,
|
||||||
|
onToggle,
|
||||||
|
}: { options: string[]; actives: string[]; onToggle: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o}
|
||||||
|
onClick={() => onToggle(o)}
|
||||||
|
className={`px-2 py-1 rounded-full text-xs border ${actives.includes(o) ? "bg-slate-900 text-white" : "bg-white hover:bg-slate-50"}`}
|
||||||
|
>
|
||||||
|
{o}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { CardDescription } from "@/components/ui/card"
|
||||||
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"
|
||||||
@@ -27,6 +30,18 @@ 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 = [
|
||||||
{
|
{
|
||||||
@@ -162,6 +177,64 @@ 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) =>
|
||||||
@@ -225,6 +298,20 @@ 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}
|
||||||
@@ -869,3 +956,23 @@ 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
61
app/workspace/moments-sync/new/loading.tsx
Normal file
61
app/workspace/moments-sync/new/loading.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,6 +33,14 @@ 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")
|
||||||
@@ -616,15 +624,19 @@ export function ApiDocumentation() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4">
|
<div className="flex justify-between items-center mt-4">
|
||||||
<Button variant="outline" className="gap-2">
|
<Button
|
||||||
|
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">
|
<Button variant="outline" className="gap-2 bg-transparent" onClick={() => openInNewTab("/api/openapi")}>
|
||||||
<Code className="h-4 w-4" />
|
<Code className="h-4 w-4" />
|
||||||
下载SDK
|
下载SDK
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="gap-2">
|
<Button className="gap-2" onClick={() => openInNewTab("/api/ingest")}>
|
||||||
<Play className="h-4 w-4" />
|
<Play className="h-4 w-4" />
|
||||||
API测试工具
|
API测试工具
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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,9 +12,38 @@ type Row = {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiResp = {
|
type Pagination = { page: number; pageSize: number; total: number; totalPages: number }
|
||||||
data: Row[]
|
|
||||||
pagination: { page: number; pageSize: number; total: number; totalPages: number }
|
// The API may return different shapes; normalize them here.
|
||||||
|
function normalizeResponse(json: any): { items: Row[]; total: number; pagination?: Pagination } {
|
||||||
|
// v2 shape: { success, data: { items, pagination, totalValue } }
|
||||||
|
if (json && json.data && Array.isArray(json.data.items)) {
|
||||||
|
const items = json.data.items as Row[]
|
||||||
|
const total = json.data?.pagination?.total ?? json.total ?? items.length
|
||||||
|
return { items, total, pagination: json.data.pagination }
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 shape: { data: Row[], pagination }
|
||||||
|
if (json && Array.isArray(json.data)) {
|
||||||
|
const items = json.data as Row[]
|
||||||
|
const total = json.pagination?.total ?? json.total ?? items.length
|
||||||
|
return { items, total, pagination: json.pagination }
|
||||||
|
}
|
||||||
|
|
||||||
|
// shape: { items: Row[], pagination }
|
||||||
|
if (json && Array.isArray(json.items)) {
|
||||||
|
const items = json.items as Row[]
|
||||||
|
const total = json.pagination?.total ?? json.total ?? items.length
|
||||||
|
return { items, total, pagination: json.pagination }
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw array
|
||||||
|
if (Array.isArray(json)) {
|
||||||
|
return { items: json as Row[], total: (json as Row[]).length }
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknown shape
|
||||||
|
return { items: [], total: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserList({ queryString }: { queryString: string }) {
|
export default function UserList({ queryString }: { queryString: string }) {
|
||||||
@@ -26,23 +55,26 @@ 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: ApiResp = await res.json()
|
const json = await res.json()
|
||||||
|
const { items, total } = normalizeResponse(json)
|
||||||
if (!aborted) {
|
if (!aborted) {
|
||||||
setData(json.data || [])
|
setData(Array.isArray(items) ? items : [])
|
||||||
setTotal(json.pagination?.total || 0)
|
setTotal(typeof total === "number" ? 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
|
||||||
@@ -50,7 +82,7 @@ export default function UserList({ queryString }: { queryString: string }) {
|
|||||||
}
|
}
|
||||||
}, [queryString])
|
}, [queryString])
|
||||||
|
|
||||||
const rows = useMemo(() => data, [data])
|
const rows = useMemo(() => (Array.isArray(data) ? 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>
|
||||||
@@ -87,16 +119,22 @@ 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">{t}</span>
|
<span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
{(r.tags || []).length > 3 && <span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>}
|
{(r.tags || []).length > 3 && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}>
|
||||||
|
无匹配数据
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
43
components/nav/bottom-tabs.tsx
Normal file
43
components/nav/bottom-tabs.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
components/nav/top-nav.tsx
Normal file
44
components/nav/top-nav.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,83 +13,106 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockUser {
|
const cities = ["北京", "上海", "广州", "深圳", "成都", "杭州", "苏州", "武汉", "西安", "长沙"]
|
||||||
id: string
|
const stores = ["门店A1", "门店A2", "门店B1", "门店C3", "门店D2"]
|
||||||
name: string
|
const privateDomains = ["企业微信私域1", "社群A", "社群B", "公众号粉丝池"]
|
||||||
nickname?: string
|
const projects = ["项目X", "项目Y", "项目Z", "活动618"]
|
||||||
avatar?: string
|
const teams = ["团队一组", "团队二组", "团队增长", "客户成功"]
|
||||||
tags: string[]
|
const personas = ["技术爱好者", "夜猫子", "内容创作者", "效率提升", "价格敏感", "品牌忠诚"]
|
||||||
lastActive: string
|
const tagPool = [
|
||||||
city?: string
|
"高价值",
|
||||||
valueScore?: number
|
"近7日活跃",
|
||||||
}
|
"新客",
|
||||||
|
"回流",
|
||||||
export const MOCK_USERS: MockUser[] = [
|
"社群达人",
|
||||||
{
|
"潜在复购",
|
||||||
id: "u_1001",
|
"高互动",
|
||||||
name: "张三",
|
"低客单",
|
||||||
nickname: "技术控",
|
"私域粉",
|
||||||
avatar: "/user-avatar-zhangsan.png",
|
"公众号粉",
|
||||||
tags: ["技术爱好者", "夜猫子"],
|
|
||||||
lastActive: "2025-08-01T10:20:00Z",
|
|
||||||
city: "深圳",
|
|
||||||
valueScore: 86,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "u_1002",
|
|
||||||
name: "李四",
|
|
||||||
nickname: "内容创作者",
|
|
||||||
avatar: "/user-avatar-lisi.png",
|
|
||||||
tags: ["短视频", "自动化工具"],
|
|
||||||
lastActive: "2025-08-06T14:30:00Z",
|
|
||||||
city: "广州",
|
|
||||||
valueScore: 72,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "u_1003",
|
|
||||||
name: "王雷",
|
|
||||||
avatar: "/avatar-wanglei.png",
|
|
||||||
tags: ["效率提升"],
|
|
||||||
lastActive: "2025-08-07T08:05:00Z",
|
|
||||||
city: "成都",
|
|
||||||
valueScore: 64,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
const statusPool: UserStatus[] = ["活跃", "沉睡", "流失风险"]
|
||||||
const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗']
|
|
||||||
const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰']
|
|
||||||
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> = { '张':'zhang','李':'li','王':'wang','赵':'zhao','刘':'liu','陈':'chen','杨':'yang','黄':'huang','周':'zhou','吴':'wu','徐':'xu','孙':'sun','胡':'hu','朱':'zhu','高':'gao','林':'lin','何':'he','郭':'guo','马':'ma','罗':'luo' }
|
const map: Record<string, string> = {
|
||||||
return name.split('').map(c => map[c] ?? 'u').join('')
|
张: "zhang",
|
||||||
|
李: "li",
|
||||||
|
王: "wang",
|
||||||
|
赵: "zhao",
|
||||||
|
刘: "liu",
|
||||||
|
陈: "chen",
|
||||||
|
杨: "yang",
|
||||||
|
黄: "huang",
|
||||||
|
周: "zhou",
|
||||||
|
吴: "wu",
|
||||||
|
徐: "xu",
|
||||||
|
孙: "sun",
|
||||||
|
胡: "hu",
|
||||||
|
朱: "zhu",
|
||||||
|
高: "gao",
|
||||||
|
林: "lin",
|
||||||
|
何: "he",
|
||||||
|
郭: "guo",
|
||||||
|
马: "ma",
|
||||||
|
罗: "luo",
|
||||||
}
|
}
|
||||||
|
return name
|
||||||
|
.split("")
|
||||||
|
.map((c) => map[c] ?? "u")
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
function randomPhone() {
|
function randomPhone() {
|
||||||
const prefixes = ['139','138','137','136','135','188','187','186','185','184','183','182','159','158','157','156','155']
|
const prefixes = [
|
||||||
|
"139",
|
||||||
|
"138",
|
||||||
|
"137",
|
||||||
|
"136",
|
||||||
|
"135",
|
||||||
|
"188",
|
||||||
|
"187",
|
||||||
|
"186",
|
||||||
|
"185",
|
||||||
|
"183",
|
||||||
|
"182",
|
||||||
|
"159",
|
||||||
|
"158",
|
||||||
|
"157",
|
||||||
|
"156",
|
||||||
|
"155",
|
||||||
|
]
|
||||||
return `${pick(prefixes)}${rand(1000, 9999)}${rand(1000, 9999)}`
|
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)
|
||||||
@@ -98,24 +121,96 @@ function timeNearNow(daysSpan = 90) {
|
|||||||
|
|
||||||
let cache: User[] | null = null
|
let cache: User[] | null = null
|
||||||
|
|
||||||
function seed(n = 120) {
|
function seed(n = 240) {
|
||||||
|
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 phone = randomPhone()
|
const u: User = {
|
||||||
list.push({
|
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone: randomPhone(),
|
||||||
avatar: avatars[i % avatars.length],
|
avatar: avatars[i % avatars.length],
|
||||||
tags: randomTags(),
|
tags: randomTags(),
|
||||||
status: pick(statusPool),
|
status: pick(statusPool),
|
||||||
rfmScore: rand(15, 95),
|
rfmScore: rand(20, 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
|
||||||
}
|
}
|
||||||
@@ -131,76 +226,68 @@ 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, page = 1, pageSize = 20 } = params
|
const { q, tags, status, rfmMin = 0, rfmMax = 100, city, persona, source, 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(u =>
|
list = list.filter(
|
||||||
|
(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) {
|
if (tags?.length) list = list.filter((u) => tags.every((t) => u.tags.includes(t)))
|
||||||
list = list.filter(u => tags.every(t => u.tags.includes(t)))
|
if (status?.length) list = list.filter((u) => status.includes(u.status))
|
||||||
}
|
list = list.filter((u) => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
|
||||||
|
if (city?.length) list = list.filter((u) => city.includes(u.city))
|
||||||
if (status?.length) {
|
if (persona?.length) list = list.filter((u) => u.persona.some((p) => persona.includes(p)))
|
||||||
const st = new Set(status)
|
if (source?.length) list = list.filter((u) => source.includes(u.source))
|
||||||
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 end = start + pageSize
|
const data = list.slice(start, start + pageSize)
|
||||||
const data = list.slice(start, end)
|
|
||||||
|
|
||||||
// 列表行仅返回必要字段
|
return {
|
||||||
const thin = data.map(u => ({
|
data,
|
||||||
id: u.id,
|
pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
|
||||||
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,
|
name: input.name ?? "新用户",
|
||||||
email,
|
email: input.email ?? "user@example.com",
|
||||||
phone,
|
phone: input.phone ?? "13900000000",
|
||||||
avatar: input.avatar ?? avatars[rand(0, avatars.length - 1)],
|
avatar: input.avatar ?? avatars[0],
|
||||||
tags: input.tags ?? randomTags(),
|
tags: input.tags ?? ["新客"],
|
||||||
status: input.status ?? pick(statusPool),
|
status: input.status ?? "活跃",
|
||||||
rfmScore: input.rfmScore ?? rand(20, 80),
|
rfmScore: input.rfmScore ?? 60,
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.3.0",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
415
pnpm-lock.yaml
generated
415
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,31 @@
|
|||||||
// 身份识别与合并服务
|
// 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
|
||||||
matchedFields: string[]
|
reasons: string[]
|
||||||
matchType: "exact" | "fuzzy" | "partial"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserIdentity {
|
type IndexMaps = {
|
||||||
userId: string
|
emailToUser: Map<string, string>
|
||||||
identityFields: Record<string, any>
|
phoneToUser: Map<string, string>
|
||||||
|
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 userIdentities: Map<string, UserIdentity> = new Map()
|
private index: IndexMaps
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// 初始化一些模拟数据
|
this.index = {
|
||||||
this.initializeMockData()
|
emailToUser: new Map(),
|
||||||
|
phoneToUser: new Map(),
|
||||||
|
usernameToUser: new Map(),
|
||||||
|
userStore: new Map(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): IdentityService {
|
public static getInstance(): IdentityService {
|
||||||
@@ -31,202 +35,84 @@ 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>()
|
||||||
|
|
||||||
for (const [userId, userIdentity] of this.userIdentities) {
|
const tryAdd = (userId: string | undefined, confidence: number, reason: string) => {
|
||||||
const matchResult = this.calculateMatch(inputData, userIdentity.identityFields, identityKeyFields)
|
if (!userId) return
|
||||||
if (matchResult.confidence > 0) {
|
if (seen.has(userId)) return
|
||||||
matches.push({
|
seen.add(userId)
|
||||||
userId,
|
matches.push({ userId, confidence, reasons: [reason] })
|
||||||
...matchResult,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按置信度排序
|
if (mappedData.email) {
|
||||||
return matches.sort((a, b) => b.confidence - a.confidence)
|
tryAdd(this.index.emailToUser.get(String(mappedData.email).toLowerCase()), 0.95, "email")
|
||||||
|
}
|
||||||
|
if (mappedData.phone) {
|
||||||
|
tryAdd(this.index.phoneToUser.get(String(mappedData.phone)), 0.92, "phone")
|
||||||
|
}
|
||||||
|
if (mappedData.username) {
|
||||||
|
tryAdd(this.index.usernameToUser.get(String(mappedData.username).toLowerCase()), 0.7, "username")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算匹配度
|
// 简单加权:若同一userId命中多个关键字段,提升置信度
|
||||||
private calculateMatch(
|
const aggregated = new Map<string, IdentityMatch>()
|
||||||
inputData: Record<string, any>,
|
for (const m of matches) {
|
||||||
existingData: Record<string, any>,
|
const exists = aggregated.get(m.userId)
|
||||||
keyFields: string[],
|
if (!exists) {
|
||||||
): { confidence: number; matchedFields: string[]; matchType: "exact" | "fuzzy" | "partial" } {
|
aggregated.set(m.userId, { ...m })
|
||||||
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 {
|
||||||
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1)
|
exists.confidence = Math.min(0.99, exists.confidence + 0.05)
|
||||||
}
|
exists.reasons = Array.from(new Set([...exists.reasons, ...m.reasons]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return matrix[str2.length][str1.length]
|
return Array.from(aggregated.values()).sort((a, b) => b.confidence - a.confidence)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新的用户身份
|
// 创建新身份
|
||||||
public async createNewIdentity(inputData: Record<string, any>): Promise<string> {
|
public async createNewIdentity(mappedData: Record<string, any>): Promise<string> {
|
||||||
const userId = `user_global_id_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
const base =
|
||||||
const identityKeyFields = getIdentityKeyFields()
|
(mappedData.phone && `phone_${mappedData.phone}`) ||
|
||||||
|
(mappedData.email && `email_${String(mappedData.email).toLowerCase()}`) ||
|
||||||
const identityFields: Record<string, any> = {}
|
(mappedData.username && `uname_${String(mappedData.username).toLowerCase()}`) ||
|
||||||
identityKeyFields.forEach((field) => {
|
`anon_${Date.now()}`
|
||||||
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, updateData: Record<string, any>): Promise<void> {
|
public async updateIdentity(userId: string, mappedData: Record<string, any>): Promise<void> {
|
||||||
const existingIdentity = this.userIdentities.get(userId)
|
const cur = this.index.userStore.get(userId) || { userId }
|
||||||
if (existingIdentity) {
|
const updated = { ...cur, ...mappedData }
|
||||||
const identityKeyFields = getIdentityKeyFields()
|
this.index.userStore.set(userId, updated)
|
||||||
identityKeyFields.forEach((field) => {
|
this.bindIndexes(userId, mappedData)
|
||||||
if (updateData[field]) {
|
|
||||||
existingIdentity.identityFields[field] = updateData[field]
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
this.userIdentities.set(userId, existingIdentity)
|
private bindIndexes(userId: string, mappedData: Record<string, any>) {
|
||||||
|
if (mappedData.email) {
|
||||||
|
this.index.emailToUser.set(String(mappedData.email).toLowerCase(), userId)
|
||||||
|
}
|
||||||
|
if (mappedData.phone) {
|
||||||
|
this.index.phoneToUser.set(String(mappedData.phone), userId)
|
||||||
|
}
|
||||||
|
if (mappedData.username) {
|
||||||
|
this.index.usernameToUser.set(String(mappedData.username).toLowerCase(), userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并用户身份
|
private shortHash(str: string): string {
|
||||||
public async mergeIdentities(primaryUserId: string, secondaryUserId: string): Promise<void> {
|
// 简单、稳定的字符串哈希(djb2 变体),避免依赖
|
||||||
const primaryIdentity = this.userIdentities.get(primaryUserId)
|
let h = 5381
|
||||||
const secondaryIdentity = this.userIdentities.get(secondaryUserId)
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
types/acquisition.ts
Normal file
74
types/acquisition.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// 订单表单数据相关类型定义
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export type DeviceStatus = "online" | "offline" | "unknown"
|
export type DeviceStatus = "online" | "offline" | "unknown" | "busy"
|
||||||
|
|
||||||
|
export type DeviceType = Device
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id: string
|
id: string
|
||||||
@@ -6,7 +8,9 @@ export interface Device {
|
|||||||
wechatId?: string
|
wechatId?: string
|
||||||
group?: string
|
group?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
os: "Android" | "iOS" | "Windows" | "Mac"
|
||||||
status: DeviceStatus
|
status: DeviceStatus
|
||||||
|
owner?: string
|
||||||
lastSeen?: string
|
lastSeen?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +19,7 @@ export interface CreateDeviceParams {
|
|||||||
wechatId?: string
|
wechatId?: string
|
||||||
group?: string
|
group?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
os: "Android" | "iOS" | "Windows" | "Mac"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDeviceParams extends Partial<CreateDeviceParams> {
|
export interface UpdateDeviceParams extends Partial<CreateDeviceParams> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type ScenarioStatus = "draft" | "running" | "paused" | "completed"
|
export type ScenarioStatus = "draft" | "running" | "paused" | "completed" | "active" | "archived"
|
||||||
|
export type Channel = "phone" | "wechat" | "douyin" | "xiaohongshu" | "api"
|
||||||
|
|
||||||
export interface ScenarioBase {
|
export interface ScenarioBase {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,9 +12,19 @@ export interface ScenarioBase {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Scenario {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
channel: Channel
|
||||||
|
createdAt: string
|
||||||
|
status: ScenarioStatus
|
||||||
|
config?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateScenarioParams {
|
export interface CreateScenarioParams {
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
|
channel: Channel
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +53,7 @@ export interface AcquisitionRecord {
|
|||||||
scenarioId: string
|
scenarioId: string
|
||||||
userId: string
|
userId: string
|
||||||
time: string
|
time: string
|
||||||
channel: string
|
channel: Channel
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
59
types/traffic.ts
Normal file
59
types/traffic.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// 流量用户相关类型定义
|
||||||
|
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
|
||||||
|
}
|
||||||
49
开发文档/开发文档.md
49
开发文档/开发文档.md
@@ -6,18 +6,61 @@
|
|||||||
- 新增 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/ingest(GET 状态)
|
||||||
|
|
||||||
- 变更文件:
|
- 变更文件:
|
||||||
- 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;
|
||||||
@@ -25,3 +68,7 @@
|
|||||||
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、用户画像查询等。
|
||||||
|
|||||||
Reference in New Issue
Block a user