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 { useState, useEffect } from "react"
|
||||
import Sidebar from "./components/Sidebar"
|
||||
import MobileHeader from "./components/MobileHeader"
|
||||
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"] })
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function ClientLayout({
|
||||
}) {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
@@ -33,7 +35,7 @@ export default function ClientLayout({
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>用户数据资产中台</title>
|
||||
<title>卡若数据资产中台</title>
|
||||
<meta name="description" content="基于苹果毛玻璃设计的用户数据资产中台" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
@@ -47,7 +49,7 @@ export default function ClientLayout({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-screen">
|
||||
{/* 桌面端侧边栏 */}
|
||||
{/* 桌面端侧边栏 - PC端不显示底部导航 */}
|
||||
{!isMobile && <Sidebar />}
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
@@ -55,16 +57,21 @@ export default function ClientLayout({
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 移动端底部导航 */}
|
||||
{isMobile && <BottomNav />}
|
||||
{/* 移动端底部导航 - 使用新的BottomTabs组件 */}
|
||||
{isMobile && <BottomTabs />}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,71 +1,105 @@
|
||||
"use client"
|
||||
|
||||
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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import TaskList from "@/components/ai-assistant/task-list"
|
||||
import CreateTaskDialog from "@/components/ai-assistant/create-task-dialog"
|
||||
import ReportCards from "@/components/ai-assistant/report-cards"
|
||||
import { sanitizeText } from "@/lib/text-sanitize"
|
||||
import type { AnalysisTask, DatabaseInfo, ReportItem, ReportTemplate } from "@/types/ai-assistant"
|
||||
import type { AnalysisTask, ReportItem, ReportTemplate } from "@/types/ai-assistant"
|
||||
|
||||
const initialTasks: AnalysisTask[] = [
|
||||
{
|
||||
id: "task_001",
|
||||
name: "用户行为分析报告",
|
||||
database: "微信用户数据库",
|
||||
name: "用户资产价值评估报告",
|
||||
database: "用户池数据",
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
createdAt: "2025-01-15T09:00:00Z",
|
||||
completedAt: "2025-01-15T09:30:00Z",
|
||||
reportUrl: "/reports/user-behavior-analysis.pdf",
|
||||
description: "基于近30天行为的聚合与序列分析",
|
||||
reportUrl: "/reports/user-asset-evaluation.pdf",
|
||||
description: "基于RFM模型和行为数据的用户资产价值评估",
|
||||
},
|
||||
{
|
||||
id: "task_002",
|
||||
name: "流量关键词趋势分析",
|
||||
database: "流量关键词库",
|
||||
name: "高价值用户画像分析",
|
||||
database: "用户池数据",
|
||||
status: "running",
|
||||
progress: 65,
|
||||
createdAt: "2025-01-15T10:00:00Z",
|
||||
description: "关注核心流量词与曝光、点击、转化等指标",
|
||||
description: "识别高价值用户特征和行为模式",
|
||||
},
|
||||
{
|
||||
id: "task_003",
|
||||
name: "用户价值分层报告",
|
||||
database: "微信用户数据库",
|
||||
name: "用户生命周期价值预测",
|
||||
database: "用户池数据",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
createdAt: "2025-01-15T10:30:00Z",
|
||||
description: "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[] = [
|
||||
{ id: "template_001", name: "用户画像分析报告", description: "深度分析用户特征、行为模式和价值分层", category: "用户分析", fields: ["用户基本信息", "RFM分析", "行为轨迹", "价值评估", "推荐策略"] },
|
||||
{ id: "template_002", name: "流量趋势分析报告", description: "分析关键词搜索趋势和流量变化", category: "流量分析", fields: ["关键词热度", "搜索趋势", "竞争分析", "机会识别", "优化建议"] },
|
||||
{ id: "template_003", name: "业务运营报告", description: "综合业务数据分析和运营建议", category: "运营分析", fields: ["核心指标", "增长分析", "用户留存", "转化漏斗", "运营建议"] },
|
||||
{ id: "template_004", name: "数据质量报告", description: "评估数据完整性、准确性和一致性", category: "数据质量", fields: ["数据完整性", "准确性检查", "一致性验证", "异常检测", "改进建议"] },
|
||||
{
|
||||
id: "template_001",
|
||||
name: "用户资产评估报告",
|
||||
description: "全面评估用户资产价值和投资回报",
|
||||
category: "资产评估",
|
||||
fields: ["资产总值", "RFM分析", "价值分层", "投资回报", "增值建议"],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
id: "template_002",
|
||||
name: "用户价值分析报告",
|
||||
description: "深度分析用户价值构成和增长潜力",
|
||||
category: "价值分析",
|
||||
fields: ["价值构成", "增长趋势", "潜力评估", "风险分析", "优化策略"],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
id: "template_003",
|
||||
name: "用户生命周期报告",
|
||||
description: "分析用户生命周期各阶段的价值贡献",
|
||||
category: "生命周期",
|
||||
fields: ["阶段划分", "价值贡献", "转化率", "留存分析", "提升方案"],
|
||||
isEditable: true,
|
||||
},
|
||||
]
|
||||
|
||||
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-3", title: "用户价值分层报告", source: "数据源:微信用户数据库", description: '结合 RFM 得分与标签,给出 S/A/B/C/D 分层与经营建议。\\n" ]] }', updatedAt: new Date().toISOString() },
|
||||
{
|
||||
id: "rpt-1",
|
||||
title: "用户资产评估报告",
|
||||
source: "数据源:用户池",
|
||||
description: "基于用户行为和交易数据,评估用户资产价值和投资回报率。",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "rpt-2",
|
||||
title: "高价值用户分析",
|
||||
source: "数据源:用户池",
|
||||
description: "识别和分析高价值用户的特征、行为模式和价值贡献。",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "rpt-3",
|
||||
title: "用户价值预测模型",
|
||||
source: "数据源:用户池",
|
||||
description: "基于机器学习预测用户未来价值和生命周期价值。",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
const [tasks, setTasks] = useState<AnalysisTask[]>(initialTasks)
|
||||
const [editingTemplate, setEditingTemplate] = useState<string | null>(null)
|
||||
const [templateContent, setTemplateContent] = useState<string>("")
|
||||
|
||||
// 进度推进(仅演示用)
|
||||
const tick = useCallback(() => {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -95,28 +129,36 @@ export default function AIAssistantPage() {
|
||||
}, 800)
|
||||
}
|
||||
|
||||
const sanitizedItems = useMemo(
|
||||
() => reportItems.map((i) => ({ ...i, description: sanitizeText(i.description) })),
|
||||
[],
|
||||
)
|
||||
const handleEditTemplate = (templateId: string) => {
|
||||
setEditingTemplate(templateId)
|
||||
const template = templates.find((t) => t.id === templateId)
|
||||
setTemplateContent(template?.fields.join("\n") || "")
|
||||
}
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
// 保存模板逻辑
|
||||
setEditingTemplate(null)
|
||||
setTemplateContent("")
|
||||
}
|
||||
|
||||
const sanitizedItems = useMemo(() => reportItems.map((i) => ({ ...i, description: sanitizeText(i.description) })), [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">AI智能助手</h1>
|
||||
<p className="text-gray-600">数据库分析与智能报告生成</p>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<Sparkles className="h-8 w-8 text-blue-500" />
|
||||
AI智能助手
|
||||
</h1>
|
||||
<p className="text-gray-600">专注用户资产评估与价值分析</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
数据分析
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="databases" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据库
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
用户资产评估
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="templates" className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
@@ -126,8 +168,8 @@ export default function AIAssistantPage() {
|
||||
|
||||
<TabsContent value="analysis" className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">数据分析任务</h2>
|
||||
<CreateTaskDialog databases={databases} templates={templates} onCreate={onCreate} />
|
||||
<h2 className="text-xl font-semibold">用户资产分析任务</h2>
|
||||
<CreateTaskDialog databases={[]} templates={templates} onCreate={onCreate} />
|
||||
</div>
|
||||
|
||||
<TaskList tasks={tasks} onTick={tick} />
|
||||
@@ -135,38 +177,12 @@ export default function AIAssistantPage() {
|
||||
<ReportCards items={sanitizedItems} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="databases">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据库
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{databases.map((db) => (
|
||||
<Card key={db.id} className="border">
|
||||
<CardContent className="p-4 space-y-1">
|
||||
<div className="font-medium">{db.name}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{db.type} · {db.tables} 表
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
最近更新 {new Date(db.lastUpdated).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
报告模板
|
||||
可编辑报告模板
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -175,7 +191,29 @@ export default function AIAssistantPage() {
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="font-medium">{t.name}</div>
|
||||
<div className="text-sm text-gray-600">{t.category}</div>
|
||||
<div className="text-xs text-gray-500">字段: {t.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>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabases, getDatabaseStructure, getTableStructure } from "@/lib/db-connector"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const database = searchParams.get("database")
|
||||
const url = new URL(req.url)
|
||||
const database = url.searchParams.get("database")
|
||||
const table = url.searchParams.get("table")
|
||||
|
||||
if (database) {
|
||||
// 获取指定数据库的结构
|
||||
const structure = await getDatabaseStructure(database)
|
||||
return NextResponse.json({ success: true, data: structure })
|
||||
} else {
|
||||
// 获取所有数据库列表
|
||||
const databases = await getDatabases()
|
||||
return NextResponse.json({ success: true, data: databases })
|
||||
// 无查询参数:返回数据库列表([{ Database: 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)
|
||||
return NextResponse.json({ success: true, data: structure })
|
||||
} catch (error) {
|
||||
console.error("数据库结构查询失败:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "数据库结构查询失败", error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
)
|
||||
console.error("数据库结构API错误:", error)
|
||||
return NextResponse.json({ success: false, 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 { 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 = [
|
||||
@@ -127,6 +127,9 @@ const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
city: "",
|
||||
persona: "",
|
||||
rfmScore: Math.floor(Math.random() * 101),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -185,6 +188,9 @@ const generateWechatFriends = (wechatId: string, count: number) => {
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
city: "",
|
||||
persona: "",
|
||||
rfmScore: Math.floor(Math.random() * 101),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -194,33 +200,57 @@ const wechatFriendsCache = new Map<string, TrafficUser[]>()
|
||||
|
||||
function parseArrayParam(v: string | null) {
|
||||
if (!v) return []
|
||||
return v.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
return v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const keyword = (searchParams.get("q") || "").trim()
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url)
|
||||
const meta = url.searchParams.get("meta")
|
||||
if (meta === "tags") {
|
||||
const tags = Array.from(new Set(getUsersStore().flatMap((u) => u.tags))).sort()
|
||||
const cities = Array.from(new Set(getUsersStore().map((u) => u.city))).sort()
|
||||
const personas = Array.from(new Set(getUsersStore().flatMap((u) => u.persona))).sort()
|
||||
const sources = Array.from(new Set(getUsersStore().map((u) => u.source))).sort()
|
||||
return NextResponse.json({ success: true, data: { tags, cities, personas, sources } })
|
||||
}
|
||||
|
||||
const filtered = keyword
|
||||
? MOCK_USERS.filter(
|
||||
(u) =>
|
||||
u.name.includes(keyword) ||
|
||||
(u.nickname && u.nickname.includes(keyword)) ||
|
||||
u.tags.some((t) => t.includes(keyword)),
|
||||
)
|
||||
: MOCK_USERS
|
||||
const q = url.searchParams.get("q") ?? undefined
|
||||
const tags = url.searchParams.get("tags")?.split(",").filter(Boolean)
|
||||
const status = url.searchParams.get("status")?.split(",").filter(Boolean) as any
|
||||
const city = url.searchParams.get("city")?.split(",").filter(Boolean)
|
||||
const persona = url.searchParams.get("persona")?.split(",").filter(Boolean)
|
||||
const source = url.searchParams.get("source")?.split(",").filter(Boolean)
|
||||
const rfmMin = Number(url.searchParams.get("rfmMin") ?? 0)
|
||||
const rfmMax = Number(url.searchParams.get("rfmMax") ?? 100)
|
||||
const page = Number(url.searchParams.get("page") ?? 1)
|
||||
const pageSize = Number(url.searchParams.get("pageSize") ?? 20)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: filtered.length,
|
||||
items: filtered,
|
||||
const { data, pagination } = queryUsers({
|
||||
q,
|
||||
tags,
|
||||
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) {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const created = addUser(body ?? {})
|
||||
return NextResponse.json({ data: created }, { status: 201 })
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json()) as Partial<User>
|
||||
const u = addUser(body)
|
||||
return NextResponse.json({ success: true, data: u })
|
||||
}
|
||||
|
||||
@@ -1,340 +1,246 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Database, Plus, Settings, Play, Pause, RotateCcw, Brain, Zap, CheckCircle, AlertCircle } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Database, Plus, RefreshCw, Settings, Brain, Play } from "lucide-react"
|
||||
import BottomTabs from "@/components/nav/bottom-tabs"
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
status: "connected" | "disconnected" | "syncing"
|
||||
lastSync: string
|
||||
recordCount: number
|
||||
description: string
|
||||
type: string
|
||||
records: string
|
||||
lastSync: string
|
||||
status: "connected" | "disconnected" | "syncing"
|
||||
}
|
||||
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
status: "training" | "ready" | "error"
|
||||
accuracy: number
|
||||
accuracy: string
|
||||
algorithm: string
|
||||
features: number
|
||||
lastTrained: string
|
||||
parameters: Record<string, any>
|
||||
status: "ready" | "training" | "error"
|
||||
}
|
||||
|
||||
const mockDataSources: DataSource[] = [
|
||||
{
|
||||
id: "wechat-db",
|
||||
name: "微信用户数据库",
|
||||
description: "存储微信用户基础信息和行为数据",
|
||||
type: "MySQL",
|
||||
records: "2.5B",
|
||||
lastSync: "2024/1/15 18:30:00",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "traffic-keywords",
|
||||
name: "流量关键词库",
|
||||
description: "搜索引擎关键词和流量数据",
|
||||
type: "PostgreSQL",
|
||||
records: "150.0K",
|
||||
lastSync: "2024/1/15 17:45:00",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "user-behavior",
|
||||
name: "用户行为日志",
|
||||
description: "用户操作行为和交互记录",
|
||||
type: "MongoDB",
|
||||
records: "1.5B",
|
||||
lastSync: "2024/1/15 19:00:00",
|
||||
status: "syncing",
|
||||
},
|
||||
]
|
||||
|
||||
const mockAIModels: AIModel[] = [
|
||||
{
|
||||
id: "user-value-prediction",
|
||||
name: "用户价值预测模型",
|
||||
type: "Classification",
|
||||
accuracy: "92.0%",
|
||||
algorithm: "RandomForest",
|
||||
features: 25,
|
||||
lastTrained: "2024/1/14 23:30:00",
|
||||
status: "ready",
|
||||
},
|
||||
{
|
||||
id: "traffic-trend-analysis",
|
||||
name: "流量趋势分析模型",
|
||||
type: "Regression",
|
||||
accuracy: "87.0%",
|
||||
algorithm: "LSTM",
|
||||
features: 15,
|
||||
lastTrained: "2024/1/15 16:00:00",
|
||||
status: "training",
|
||||
},
|
||||
{
|
||||
id: "user-clustering",
|
||||
name: "用户聚类模型",
|
||||
type: "Clustering",
|
||||
accuracy: "89.0%",
|
||||
algorithm: "KMeans",
|
||||
features: 20,
|
||||
lastTrained: "2024/1/13 20:00:00",
|
||||
status: "ready",
|
||||
},
|
||||
]
|
||||
|
||||
export default function DataPlatformPage() {
|
||||
const [dataSources, setDataSources] = useState<DataSource[]>([
|
||||
{
|
||||
id: "ds_001",
|
||||
name: "微信用户数据库",
|
||||
type: "MySQL",
|
||||
status: "connected",
|
||||
lastSync: "2024-01-15T10:30:00Z",
|
||||
recordCount: 2500000000,
|
||||
description: "存储微信用户基础信息和行为数据",
|
||||
},
|
||||
{
|
||||
id: "ds_002",
|
||||
name: "流量关键词库",
|
||||
type: "PostgreSQL",
|
||||
status: "connected",
|
||||
lastSync: "2024-01-15T09:45:00Z",
|
||||
recordCount: 150000,
|
||||
description: "搜索引擎关键词和流量数据",
|
||||
},
|
||||
{
|
||||
id: "ds_003",
|
||||
name: "用户行为日志",
|
||||
type: "MongoDB",
|
||||
status: "syncing",
|
||||
lastSync: "2024-01-15T11:00:00Z",
|
||||
recordCount: 1500000000,
|
||||
description: "用户操作行为和交互记录",
|
||||
},
|
||||
])
|
||||
|
||||
const [aiModels, setAiModels] = useState<AIModel[]>([
|
||||
{
|
||||
id: "model_001",
|
||||
name: "用户价值预测模型",
|
||||
type: "Classification",
|
||||
status: "ready",
|
||||
accuracy: 0.92,
|
||||
lastTrained: "2024-01-14T15:30:00Z",
|
||||
parameters: {
|
||||
algorithm: "RandomForest",
|
||||
features: 25,
|
||||
epochs: 100,
|
||||
learningRate: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "model_002",
|
||||
name: "流量趋势分析模型",
|
||||
type: "Regression",
|
||||
status: "training",
|
||||
accuracy: 0.87,
|
||||
lastTrained: "2024-01-15T08:00:00Z",
|
||||
parameters: {
|
||||
algorithm: "LSTM",
|
||||
features: 15,
|
||||
epochs: 200,
|
||||
learningRate: 0.001,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "model_003",
|
||||
name: "用户聚类模型",
|
||||
type: "Clustering",
|
||||
status: "ready",
|
||||
accuracy: 0.89,
|
||||
lastTrained: "2024-01-13T12:00:00Z",
|
||||
parameters: {
|
||||
algorithm: "KMeans",
|
||||
clusters: 8,
|
||||
features: 20,
|
||||
iterations: 300,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [isAddingDataSource, setIsAddingDataSource] = useState(false)
|
||||
const [isTrainingModel, setIsTrainingModel] = useState(false)
|
||||
const [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({
|
||||
name: "",
|
||||
description: "",
|
||||
type: "MySQL",
|
||||
host: "",
|
||||
port: "",
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
description: "",
|
||||
})
|
||||
|
||||
const [modelTrainingConfig, setModelTrainingConfig] = useState({
|
||||
modelId: "",
|
||||
algorithm: "RandomForest",
|
||||
features: 25,
|
||||
epochs: 100,
|
||||
learningRate: 0.01,
|
||||
validationSplit: 0.2,
|
||||
autoTune: true,
|
||||
})
|
||||
|
||||
// 添加数据源
|
||||
const handleAddDataSource = async () => {
|
||||
try {
|
||||
const newSource: DataSource = {
|
||||
id: `ds_${Date.now()}`,
|
||||
name: newDataSource.name,
|
||||
type: newDataSource.type,
|
||||
status: "connected",
|
||||
lastSync: new Date().toISOString(),
|
||||
recordCount: 0,
|
||||
description: newDataSource.description,
|
||||
}
|
||||
|
||||
setDataSources((prev) => [...prev, newSource])
|
||||
setIsAddingDataSource(false)
|
||||
setNewDataSource({
|
||||
name: "",
|
||||
type: "MySQL",
|
||||
host: "",
|
||||
port: "",
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
description: "",
|
||||
})
|
||||
|
||||
// 模拟数据导入
|
||||
setTimeout(() => {
|
||||
setDataSources((prev) =>
|
||||
prev.map((ds) =>
|
||||
ds.id === newSource.id
|
||||
? { ...ds, recordCount: Math.floor(Math.random() * 1000000) + 10000, status: "connected" as const }
|
||||
: ds,
|
||||
),
|
||||
)
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error("添加数据源失败:", error)
|
||||
}
|
||||
const handleSync = async (sourceId: string) => {
|
||||
setSyncing(sourceId)
|
||||
setTimeout(() => {
|
||||
setSyncing(null)
|
||||
setDataSources((prev) =>
|
||||
prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, lastSync: new Date().toLocaleString("zh-CN") } : source,
|
||||
),
|
||||
)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 同步数据源
|
||||
const handleSyncDataSource = (id: string) => {
|
||||
setDataSources((prev) =>
|
||||
prev.map((ds) => (ds.id === id ? { ...ds, status: "syncing" as const, lastSync: new Date().toISOString() } : ds)),
|
||||
)
|
||||
|
||||
// 模拟同步完成
|
||||
const handleRetrain = async (modelId: string) => {
|
||||
setTraining(modelId)
|
||||
setTimeout(() => {
|
||||
setDataSources((prev) =>
|
||||
prev.map((ds) =>
|
||||
ds.id === id
|
||||
? {
|
||||
...ds,
|
||||
status: "connected" as const,
|
||||
recordCount: ds.recordCount + Math.floor(Math.random() * 10000),
|
||||
lastSync: new Date().toISOString(),
|
||||
}
|
||||
: ds,
|
||||
setTraining(null)
|
||||
setAIModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.id === modelId ? { ...model, lastTrained: new Date().toLocaleString("zh-CN") } : model,
|
||||
),
|
||||
)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 训练AI模型
|
||||
const handleTrainModel = async () => {
|
||||
if (!modelTrainingConfig.modelId) return
|
||||
|
||||
setIsTrainingModel(true)
|
||||
|
||||
// 更新模型状态为训练中
|
||||
setAiModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.id === modelTrainingConfig.modelId ? { ...model, status: "training" as const } : model,
|
||||
),
|
||||
)
|
||||
|
||||
// 模拟训练过程
|
||||
setTimeout(() => {
|
||||
setAiModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.id === modelTrainingConfig.modelId
|
||||
? {
|
||||
...model,
|
||||
status: "ready" as const,
|
||||
accuracy: Math.random() * 0.1 + 0.85,
|
||||
lastTrained: new Date().toISOString(),
|
||||
parameters: {
|
||||
algorithm: modelTrainingConfig.algorithm,
|
||||
features: modelTrainingConfig.features,
|
||||
epochs: modelTrainingConfig.epochs,
|
||||
learningRate: modelTrainingConfig.learningRate,
|
||||
},
|
||||
}
|
||||
: model,
|
||||
),
|
||||
)
|
||||
setIsTrainingModel(false)
|
||||
}, 5000)
|
||||
const handleAddDataSource = () => {
|
||||
const newSource: DataSource = {
|
||||
id: `datasource-${Date.now()}`,
|
||||
name: newDataSource.name,
|
||||
description: newDataSource.description,
|
||||
type: newDataSource.type,
|
||||
records: "0",
|
||||
lastSync: "从未同步",
|
||||
status: "disconnected",
|
||||
}
|
||||
setDataSources((prev) => [...prev, newSource])
|
||||
setNewDataSource({
|
||||
name: "",
|
||||
description: "",
|
||||
type: "MySQL",
|
||||
host: "",
|
||||
port: "",
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
})
|
||||
setShowAddDialog(false)
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
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) => {
|
||||
const getStatusBadge = (status: DataSource["status"]) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
case "ready":
|
||||
return "text-green-600 bg-green-50 border-green-200"
|
||||
case "syncing":
|
||||
case "training":
|
||||
return "text-yellow-600 bg-yellow-50 border-yellow-200"
|
||||
return <Badge className="bg-green-100 text-green-800 border-green-200">Connected</Badge>
|
||||
case "disconnected":
|
||||
case "error":
|
||||
return "text-red-600 bg-red-50 border-red-200"
|
||||
return <Badge variant="destructive">Disconnected</Badge>
|
||||
case "syncing":
|
||||
return <Badge className="bg-blue-100 text-blue-800 border-blue-200">Syncing</Badge>
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50 border-gray-200"
|
||||
return <Badge variant="secondary">Unknown</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string) => {
|
||||
const getModelStatusBadge = (status: AIModel["status"]) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
case "ready":
|
||||
return <CheckCircle className="w-4 h-4" />
|
||||
case "syncing":
|
||||
return <Badge className="bg-green-100 text-green-800 border-green-200">Ready</Badge>
|
||||
case "training":
|
||||
return <Zap className="w-4 h-4 animate-pulse" />
|
||||
case "disconnected":
|
||||
return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">Training</Badge>
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4" />
|
||||
return <Badge variant="destructive">Error</Badge>
|
||||
default:
|
||||
return <Database className="w-4 h-4" />
|
||||
return <Badge variant="secondary">Unknown</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">数据中台</h1>
|
||||
<p className="text-gray-600">数据源管理与AI模型训练平台</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="datasources" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="datasources" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据源管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="aimodels" className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
AI模型
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 数据源管理 */}
|
||||
<TabsContent value="datasources" className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">数据源管理</h2>
|
||||
<Dialog open={isAddingDataSource} onOpenChange={setIsAddingDataSource}>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据中台</h1>
|
||||
<p className="text-gray-600 mt-1">数据源管理与AI模型训练平台</p>
|
||||
</div>
|
||||
{activeTab === "datasource" ? (
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加新数据源</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">数据源名称</Label>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
名称
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newDataSource.name}
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="输入数据源名称"
|
||||
onChange={(e) => setNewDataSource({ ...newDataSource, name: e.target.value })}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">数据库类型</Label>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
描述
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={newDataSource.description}
|
||||
onChange={(e) => setNewDataSource({ ...newDataSource, description: e.target.value })}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="type" className="text-right">
|
||||
类型
|
||||
</Label>
|
||||
<Select
|
||||
value={newDataSource.type}
|
||||
onValueChange={(value) => setNewDataSource((prev) => ({ ...prev, type: value }))}
|
||||
onValueChange={(value) => setNewDataSource({ ...newDataSource, type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -342,130 +248,114 @@ export default function DataPlatformPage() {
|
||||
<SelectItem value="PostgreSQL">PostgreSQL</SelectItem>
|
||||
<SelectItem value="MongoDB">MongoDB</SelectItem>
|
||||
<SelectItem value="Redis">Redis</SelectItem>
|
||||
<SelectItem value="ClickHouse">ClickHouse</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host">主机地址</Label>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="host" className="text-right">
|
||||
主机
|
||||
</Label>
|
||||
<Input
|
||||
id="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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port">端口</Label>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="port" className="text-right">
|
||||
端口
|
||||
</Label>
|
||||
<Input
|
||||
id="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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="database">数据库名</Label>
|
||||
<Input
|
||||
id="database"
|
||||
value={newDataSource.database}
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, database: e.target.value }))}
|
||||
placeholder="database_name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={newDataSource.username}
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={newDataSource.password}
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="password"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newDataSource.description}
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="数据源描述信息"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddingDataSource(false)}>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddDataSource}>连接并导入数据</Button>
|
||||
<Button onClick={handleAddDataSource} disabled={!newDataSource.name || !newDataSource.type}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button className="flex items-center gap-2" onClick={() => handleRetrain("all")}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重新训练模型
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="datasource" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据源管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="aimodel" className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
AI模型
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsContent value="datasource" className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">数据源管理</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{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>
|
||||
<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>
|
||||
<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>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">最后同步:{source.lastSync}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSync(source.id)}
|
||||
disabled={syncing === source.id}
|
||||
className="flex items-center gap-2 flex-1"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncing === source.id ? "animate-spin" : ""}`} />
|
||||
{syncing === source.id ? "同步中" : "同步数据"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">记录数:</span>
|
||||
<span className="ml-2 font-medium">{formatNumber(source.recordCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">最后同步:</span>
|
||||
<span className="ml-2">{new Date(source.lastSync).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleSyncDataSource(source.id)}
|
||||
disabled={source.status === "syncing"}
|
||||
className="flex-1"
|
||||
>
|
||||
{source.status === "syncing" ? (
|
||||
<>
|
||||
<RotateCcw className="w-3 h-3 mr-1 animate-spin" />
|
||||
同步中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
同步数据
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Settings className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -473,201 +363,79 @@ export default function DataPlatformPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* AI模型管理 */}
|
||||
<TabsContent value="aimodels" className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">AI模型管理</h2>
|
||||
<Dialog open={isTrainingModel} onOpenChange={setIsTrainingModel}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
重新训练模型
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>模型训练配置</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="model">选择模型</Label>
|
||||
<Select
|
||||
value={modelTrainingConfig.modelId}
|
||||
onValueChange={(value) => setModelTrainingConfig((prev) => ({ ...prev, modelId: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择要训练的模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aiModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="algorithm">算法</Label>
|
||||
<Select
|
||||
value={modelTrainingConfig.algorithm}
|
||||
onValueChange={(value) => setModelTrainingConfig((prev) => ({ ...prev, algorithm: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="RandomForest">Random Forest</SelectItem>
|
||||
<SelectItem value="XGBoost">XGBoost</SelectItem>
|
||||
<SelectItem value="LSTM">LSTM</SelectItem>
|
||||
<SelectItem value="KMeans">K-Means</SelectItem>
|
||||
<SelectItem value="SVM">SVM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="features">特征数量</Label>
|
||||
<Input
|
||||
id="features"
|
||||
type="number"
|
||||
value={modelTrainingConfig.features}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({ ...prev, features: Number.parseInt(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="epochs">训练轮数</Label>
|
||||
<Input
|
||||
id="epochs"
|
||||
type="number"
|
||||
value={modelTrainingConfig.epochs}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({ ...prev, epochs: Number.parseInt(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="learningRate">学习率</Label>
|
||||
<Input
|
||||
id="learningRate"
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={modelTrainingConfig.learningRate}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({
|
||||
...prev,
|
||||
learningRate: Number.parseFloat(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validationSplit">验证集比例</Label>
|
||||
<Input
|
||||
id="validationSplit"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
max="0.5"
|
||||
value={modelTrainingConfig.validationSplit}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({
|
||||
...prev,
|
||||
validationSplit: Number.parseFloat(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex items-center space-x-2">
|
||||
<Switch
|
||||
id="autoTune"
|
||||
checked={modelTrainingConfig.autoTune}
|
||||
onCheckedChange={(checked) =>
|
||||
setModelTrainingConfig((prev) => ({ ...prev, autoTune: checked }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="autoTune">自动调参</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsTrainingModel(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleTrainModel} disabled={!modelTrainingConfig.modelId}>
|
||||
开始训练
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TabsContent value="aimodel" className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">AI模型管理</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{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>
|
||||
<Card key={model.id} className="bg-white">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg font-semibold mb-2">{model.name}</CardTitle>
|
||||
<p className="text-gray-600 text-sm mb-3">{model.type} 模型</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>准确率</span>
|
||||
<span className="font-medium">{model.accuracy}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-gray-900 h-2 rounded-full" style={{ width: model.accuracy }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-gray-500">
|
||||
<div>
|
||||
算法:<span className="font-medium">{model.algorithm}</span>
|
||||
</div>
|
||||
<div>
|
||||
特征数:<span className="font-medium">{model.features}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getModelStatusBadge(training === model.id ? "training" : model.status)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{model.type} 模型</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">准确率</span>
|
||||
<span className="font-medium">{(model.accuracy * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={model.accuracy * 100} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">算法:</span>
|
||||
<span className="ml-2 font-medium">{model.parameters.algorithm}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">特征数:</span>
|
||||
<span className="ml-2 font-medium">{model.parameters.features}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">最后训练:</span>
|
||||
<span className="ml-2">{new Date(model.lastTrained).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={model.status === "training"}
|
||||
className="flex-1 bg-transparent"
|
||||
>
|
||||
{model.status === "training" ? (
|
||||
<>
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
训练中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">最后训练:{model.lastTrained}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.status === "ready" ? (
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-2 flex-1 bg-transparent">
|
||||
<Play className="w-4 h-4" />
|
||||
预测
|
||||
</>
|
||||
</Button>
|
||||
) : model.status === "training" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
className="flex items-center gap-2 flex-1 bg-transparent"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
训练中
|
||||
</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>
|
||||
<Button size="sm" variant="outline">
|
||||
<Settings className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -676,6 +444,8 @@ export default function DataPlatformPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<BottomTabs />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import ClientLayout from "./ClientLayout"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
@@ -8,7 +9,7 @@ const inter = Inter({ subsets: ["latin"] })
|
||||
export const metadata: Metadata = {
|
||||
title: "卡若数据资产中台",
|
||||
description: "基于IMEI、手机号的用户数据资产管理平台",
|
||||
generator: 'v0.dev'
|
||||
generator: 'v0.app'
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -18,6 +19,3 @@ export default function RootLayout({
|
||||
}) {
|
||||
return <ClientLayout>{children}</ClientLayout>
|
||||
}
|
||||
|
||||
|
||||
import './globals.css'
|
||||
533
app/page.tsx
533
app/page.tsx
@@ -1,105 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe, Smartphone, Brain } from 'lucide-react'
|
||||
import { useState } from "react"
|
||||
import { Users, Database, Brain, BarChart3, Activity, MessageSquare, Zap, Send } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import UserSearch from '@/components/home/user-search'
|
||||
import UserList from '@/components/home/user-list'
|
||||
import Link from "next/link"
|
||||
import BottomTabs from "@/components/nav/bottom-tabs"
|
||||
|
||||
interface SystemStats {
|
||||
userCount: number
|
||||
keywordCount: number
|
||||
versionCount: number
|
||||
avgResponseTime: number
|
||||
cacheSize: number
|
||||
connected: boolean
|
||||
userAssets: number
|
||||
dataVolume: string
|
||||
aiQueries: number
|
||||
}
|
||||
|
||||
interface GrowthData {
|
||||
period: string
|
||||
userGrowth: number
|
||||
dataGrowth: number
|
||||
activeUsers: number
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
type: "user" | "ai"
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs">{icon}<span>{label}</span></div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export default function OverviewPage() {
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
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,
|
||||
keywordCount: 150000,
|
||||
versionCount: 25,
|
||||
avgResponseTime: 120,
|
||||
cacheSize: 0,
|
||||
connected: true,
|
||||
userAssets: 4000000000,
|
||||
dataVolume: "2.5TB",
|
||||
aiQueries: 156789,
|
||||
})
|
||||
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())
|
||||
|
||||
// 自动刷新数据
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
refreshData()
|
||||
}, 30000) // 30秒刷新一次
|
||||
const handleAIChat = async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
// 模拟数据更新
|
||||
setSystemStats((prev) => ({
|
||||
...prev,
|
||||
userCount: prev.userCount + Math.floor(Math.random() * 1000),
|
||||
avgResponseTime: Math.floor(Math.random() * 50) + 100,
|
||||
}))
|
||||
|
||||
setGrowthData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
userGrowth: item.userGrowth + (Math.random() - 0.5) * 0.5,
|
||||
dataGrowth: item.dataGrowth + (Math.random() - 0.5) * 0.3,
|
||||
activeUsers: Math.floor(item.activeUsers * (1 + (Math.random() - 0.5) * 0.01)),
|
||||
})),
|
||||
)
|
||||
|
||||
setLastUpdate(new Date())
|
||||
} catch (error) {
|
||||
console.error("刷新数据失败:", error)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
const userMessage: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
type: "user",
|
||||
content: searchQuery,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/intelligent-search?q=${encodeURIComponent(searchQuery)}`)
|
||||
}
|
||||
setChatMessages((prev) => [...prev, userMessage])
|
||||
setIsLoading(true)
|
||||
setShowChat(true)
|
||||
|
||||
// 模拟AI响应
|
||||
setTimeout(() => {
|
||||
const aiMessage: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
type: "ai",
|
||||
content: `基于${selectedModel}模型分析:${searchQuery}的相关数据显示用户增长趋势良好,建议进一步优化用户体验。`,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
setChatMessages((prev) => [...prev, aiMessage])
|
||||
setIsLoading(false)
|
||||
}, 1500)
|
||||
|
||||
setSearchQuery("")
|
||||
}
|
||||
|
||||
// 格式化数字显示
|
||||
@@ -116,251 +81,229 @@ export default function OverviewPage() {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
href: "/user-portrait",
|
||||
title: "用户画像",
|
||||
desc: "管理与分群、标签与画像洞察",
|
||||
icon: <Users className="h-5 w-5 text-purple-500" />,
|
||||
},
|
||||
{
|
||||
href: "/devices",
|
||||
title: "设备管理",
|
||||
desc: "查看设备状态与执行任务",
|
||||
icon: <Smartphone className="h-5 w-5 text-emerald-500" />,
|
||||
},
|
||||
{
|
||||
href: "/data-platform",
|
||||
title: "数据中台",
|
||||
desc: "数据接入、质量监控与查询",
|
||||
icon: <Database className="h-5 w-5 text-indigo-500" />,
|
||||
},
|
||||
{
|
||||
href: "/ai-assistant",
|
||||
title: "AI 助手",
|
||||
desc: "智能问答与自动化报表",
|
||||
icon: <Brain className="h-5 w-5 text-rose-500" />,
|
||||
},
|
||||
// 大模型选项
|
||||
const modelOptions = [
|
||||
{ value: "gpt-5", label: "GPT-5", provider: "OpenAI" },
|
||||
{ value: "gpt-4", label: "GPT-4", provider: "OpenAI" },
|
||||
{ value: "claude-3", label: "Claude-3", provider: "Anthropic" },
|
||||
{ value: "gemini-pro", label: "Gemini Pro", provider: "Google" },
|
||||
{ value: "qwen-max", label: "通义千问", provider: "阿里云" },
|
||||
{ value: "baichuan", label: "百川大模型", provider: "百川智能" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题和搜索 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">概览</h1>
|
||||
<p className="text-gray-600">数据资产中台</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="text-green-600 border-green-200">
|
||||
<Activity className="w-3 h-3 mr-1" />
|
||||
系统正常
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-2 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div 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 className="container mx-auto px-4 py-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">卡若数据资产中台</h1>
|
||||
<p className="text-gray-600">智能数据分析与AI助手平台</p>
|
||||
</div>
|
||||
|
||||
{/* 核心数据展示 - 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 className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">用户总量</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{formatNumber(stats.userCount)}</p>
|
||||
</div>
|
||||
用户资产总量
|
||||
</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 className="p-3 bg-blue-500 rounded-full">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 系统状态 */}
|
||||
<Card className="border-2 border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-white" />
|
||||
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-purple-100">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">用户资产</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{formatNumber(stats.userAssets)}</p>
|
||||
</div>
|
||||
实时状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">响应时间</span>
|
||||
<Badge variant="secondary">{systemStats.avgResponseTime}ms</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">数据源</span>
|
||||
<Badge variant="secondary">{systemStats.versionCount}个</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">缓存大小</span>
|
||||
<Badge variant="secondary">{systemStats.cacheSize}MB</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 pt-2 border-t">最后更新: {lastUpdate.toLocaleTimeString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 数据增长趋势 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500 rounded-lg">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
<div className="p-3 bg-purple-500 rounded-full">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
用户资产增长
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{growthData.map((data, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">{data.period}</div>
|
||||
<div className="text-sm text-gray-500">活跃用户: {formatNumber(data.activeUsers)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-purple-600">+{data.userGrowth.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">增长率</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-orange-200 bg-gradient-to-r from-orange-50 to-yellow-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500 rounded-lg">
|
||||
<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>
|
||||
数据增长情况
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">数据处理量</div>
|
||||
<div className="text-sm text-gray-500">实时处理中</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{formatNumber(Math.floor(systemStats.userCount * 0.001))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">条/秒</div>
|
||||
</div>
|
||||
<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="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">存储容量</div>
|
||||
<div className="text-sm text-gray-500">云端存储</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{Math.floor(systemStats.userCount / 1000000)}TB
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">已使用</div>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-500 rounded-full">
|
||||
<Brain className="w-6 h-6 text-white" />
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{/* 整合AI对话与数据搜索功能 */}
|
||||
<Card className="mb-6 border-2 border-indigo-200 bg-gradient-to-r from-indigo-50 to-blue-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-indigo-600" />
|
||||
AI智能助手
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 大模型选择 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">选择大模型:</span>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((model) => (
|
||||
<SelectItem key={model.value} value={model.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
<span>{model.label}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{model.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 快速指标示例(可后续接入真实数据) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-8">
|
||||
<StatCard icon={<Users className="h-4 w-4" />} label="用户总量" value="~120+" />
|
||||
<StatCard icon={<Activity className="h-4 w-4" />} label="近7日活跃" value="动态计算" />
|
||||
<StatCard icon={<BarChart3 className="h-4 w-4" />} label="平均RFM" value="50-80" />
|
||||
<StatCard icon={<Users className="h-4 w-4" />} label="新客占比" value="≈25%" />
|
||||
</div>
|
||||
{/* 搜索输入框 */}
|
||||
<div className="relative">
|
||||
<MessageSquare className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input
|
||||
placeholder="向AI提问,例如:分析用户增长趋势、搜索数据库信息..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleAIChat()}
|
||||
className="pl-12 pr-24 h-12 text-base border-2 border-gray-200 focus:border-indigo-500 rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAIChat}
|
||||
disabled={isLoading}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-md"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isLoading ? "思考中..." : "提问"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 条件过滤 + 列表 */}
|
||||
<UserSearch query={searchQuery} />
|
||||
{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>
|
||||
</Card>
|
||||
|
||||
{/* 功能图表展示 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => router.push("/user-pool")}>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-4 bg-purple-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">用户池</h3>
|
||||
<p className="text-sm text-gray-500">用户分群与标签管理</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Badge variant="secondary">
|
||||
<Activity className="w-3 h-3 mr-1" />
|
||||
实时更新
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="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>
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,96 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import MobileHeader from "@/app/components/MobileHeader"
|
||||
import BottomNav from "@/app/components/BottomNav"
|
||||
import Section from "@/components/user-portrait/mobile/section"
|
||||
import ProfileHeader from "@/components/user-portrait/mobile/profile-header"
|
||||
import MetricsRFM from "@/components/user-portrait/mobile/metrics-rfm"
|
||||
import InteractionsList from "@/components/user-portrait/mobile/interactions-list"
|
||||
import PurchaseHistory from "@/components/user-portrait/mobile/purchase-history"
|
||||
import WechatAccounts from "@/components/user-portrait/mobile/wechat-accounts"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft, User, Tag, TrendingUp, DollarSign, Calendar, Phone, Mail, MapPin } from "lucide-react"
|
||||
|
||||
type Detail = {
|
||||
type UserDetail = {
|
||||
id: string
|
||||
name: string
|
||||
avatar?: string
|
||||
email: string
|
||||
phone: string
|
||||
city: string
|
||||
store: string
|
||||
project: string
|
||||
team: string
|
||||
tags: string[]
|
||||
persona: string[]
|
||||
source: string
|
||||
recency: number
|
||||
frequency: number
|
||||
monetary: number
|
||||
rfmScore: number
|
||||
assetValue: number
|
||||
riskLevel: "低" | "中" | "高"
|
||||
lastActivity: string
|
||||
interactions: { id: string; type: string; time: string; note?: string }[]
|
||||
purchaseHistory: { id: string; amount: number; time: string; item: string }[]
|
||||
wechatAccounts: { id: string; nickname: string; avatar?: string }[]
|
||||
status: "活跃" | "沉睡" | "流失风险"
|
||||
detailedTags: {
|
||||
category: string
|
||||
tags: { name: string; confidence: number; source: string }[]
|
||||
}[]
|
||||
assetBreakdown: {
|
||||
category: string
|
||||
value: number
|
||||
percentage: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const [data, setData] = useState<Detail | null>(null)
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<UserDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) return
|
||||
setLoading(true)
|
||||
fetch(`/api/users?id=${params.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((res) => setData(res?.data ?? null))
|
||||
.finally(() => setLoading(false))
|
||||
setTimeout(() => {
|
||||
setData({
|
||||
id: params.id,
|
||||
name: "张三",
|
||||
email: "zhangsan@example.com",
|
||||
phone: "138****8888",
|
||||
city: "厦门",
|
||||
store: "思明店",
|
||||
project: "贷款业务",
|
||||
team: "A组",
|
||||
tags: ["高价值客户", "活跃用户", "信用良好"],
|
||||
persona: ["高净值人群", "投资偏好保守"],
|
||||
source: "微信推广",
|
||||
recency: 85,
|
||||
frequency: 92,
|
||||
monetary: 88,
|
||||
rfmScore: 88,
|
||||
assetValue: 1250000,
|
||||
riskLevel: "低",
|
||||
lastActivity: "2024-01-15 14:30:00",
|
||||
status: "活跃",
|
||||
detailedTags: [
|
||||
{
|
||||
category: "行为标签",
|
||||
tags: [
|
||||
{ name: "高频访问", confidence: 95, source: "系统分析" },
|
||||
{ name: "深度浏览", confidence: 88, source: "行为追踪" },
|
||||
{ name: "主动咨询", confidence: 92, source: "客服记录" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "偏好标签",
|
||||
tags: [
|
||||
{ name: "理财产品", confidence: 90, source: "购买记录" },
|
||||
{ name: "保险产品", confidence: 75, source: "浏览记录" },
|
||||
{ name: "投资咨询", confidence: 85, source: "咨询记录" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "风险标签",
|
||||
tags: [
|
||||
{ name: "信用优良", confidence: 98, source: "征信报告" },
|
||||
{ name: "收入稳定", confidence: 92, source: "银行流水" },
|
||||
{ name: "资产充足", confidence: 88, source: "资产证明" },
|
||||
],
|
||||
},
|
||||
],
|
||||
assetBreakdown: [
|
||||
{ category: "客户生命周期价值", value: 450000, percentage: 36 },
|
||||
{ category: "交叉销售潜力", value: 320000, percentage: 26 },
|
||||
{ category: "推荐价值", value: 280000, percentage: 22 },
|
||||
{ category: "品牌忠诚度价值", value: 200000, percentage: 16 },
|
||||
],
|
||||
})
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
}, [params?.id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-4">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-12 bg-slate-200 rounded"></div>
|
||||
<div className="h-32 bg-slate-200 rounded"></div>
|
||||
<div className="h-48 bg-slate-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-4 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-slate-600 mb-4">未找到该用户</p>
|
||||
<Button onClick={() => router.back()}>返回</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户详情" />
|
||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<ProfileHeader
|
||||
name={data.name}
|
||||
avatar={data.avatar}
|
||||
email={data.email}
|
||||
phone={data.phone}
|
||||
tags={data.tags}
|
||||
/>
|
||||
</section>
|
||||
<div className="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>
|
||||
|
||||
<Section title="RFM 指标">
|
||||
<MetricsRFM
|
||||
recency={data.recency}
|
||||
frequency={data.frequency}
|
||||
monetary={data.monetary}
|
||||
rfmScore={data.rfmScore}
|
||||
/>
|
||||
</Section>
|
||||
<div className="container mx-auto px-4 py-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
基本信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{data.name}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="h-3 w-3" />
|
||||
{data.phone}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
{data.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{data.city} · {data.store}
|
||||
</span>
|
||||
<span>
|
||||
{data.project} · {data.team}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={data.status === "活跃" ? "default" : "secondary"}>{data.status}</Badge>
|
||||
</div>
|
||||
|
||||
<Section title="互动记录">
|
||||
<InteractionsList items={data.interactions} />
|
||||
</Section>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.persona.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Section title="购买历史">
|
||||
<PurchaseHistory items={data.purchaseHistory} />
|
||||
</Section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
用户资产评估
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">¥{data.assetValue.toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-600">总资产价值</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{data.rfmScore}</div>
|
||||
<div className="text-xs text-slate-600">RFM评分</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-2xl font-bold ${data.riskLevel === "低" ? "text-green-600" : data.riskLevel === "中" ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{data.riskLevel}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">风险等级</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{data.source}</div>
|
||||
<div className="text-xs text-slate-600">获客来源</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section title="绑定微信账号">
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Search, Filter, Users, TrendingUp } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
async function fetchUsers(q: string) {
|
||||
const url = q ? `/api/users?q=${encodeURIComponent(q)}` : "/api/users"
|
||||
const res = await fetch(url, { cache: "no-store" })
|
||||
if (!res.ok) return { items: [], total: 0 }
|
||||
return res.json()
|
||||
type ListItem = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
tags: string[]
|
||||
rfmScore: number
|
||||
lastActiveAt: string
|
||||
city: string
|
||||
store: string
|
||||
project: string
|
||||
team: string
|
||||
persona: string[]
|
||||
source: string
|
||||
}
|
||||
|
||||
function Tabs({ active = "users" }: { active?: "users" | "tags" }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 rounded-md border bg-muted/30 text-sm">
|
||||
<div className={`px-4 py-2 text-center ${active === "users" ? "bg-background font-medium" : "text-muted-foreground"}`}>用户管理</div>
|
||||
<Link href="/user-portrait/tags" className={`px-4 py-2 text-center rounded-r-md ${active === "tags" ? "bg-background font-medium" : "text-muted-foreground"}`}>标签管理</Link>
|
||||
</div>
|
||||
)
|
||||
type Meta = {
|
||||
tags: string[]
|
||||
cities: string[]
|
||||
personas: string[]
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
async function UsersList({ q }: { q: string }) {
|
||||
const { items } = await fetchUsers(q)
|
||||
return (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((u: any) => (
|
||||
<Card key={u.id} className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{u.name}{u.nickname ? ` · ${u.nickname}` : ""}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">最后活跃:{new Date(u.lastActive).toLocaleString()}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={u.avatar || "/generic-user-avatar.png"}
|
||||
alt="avatar"
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.tags?.slice(0, 3).map((t: string) => (
|
||||
<span key={t} className="rounded bg-muted px-2 py-0.5 text-xs">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/user-portrait/${u.id}`}>查看</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="col-span-full py-8 text-center text-sm text-muted-foreground">暂无用户数据</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default function UserPoolPage() {
|
||||
const router = useRouter()
|
||||
const [q, setQ] = useState("")
|
||||
const [rfmRange, setRfmRange] = useState<[number, number]>([0, 100])
|
||||
const [items, setItems] = useState<ListItem[]>([])
|
||||
const [meta, setMeta] = useState<Meta>({ tags: [], cities: [], personas: [], sources: [] })
|
||||
const [filters, setFilters] = useState({
|
||||
tags: [] as string[],
|
||||
status: [] as string[],
|
||||
city: [] as string[],
|
||||
persona: [] as string[],
|
||||
source: [] as string[],
|
||||
})
|
||||
const [totalValue, setTotalValue] = useState(0)
|
||||
|
||||
export default async function UserPortraitPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { q?: string }
|
||||
}) {
|
||||
const q = (searchParams?.q || "").trim()
|
||||
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}`)
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="container mx-auto px-4 py-5 space-y-4">
|
||||
{/* 核心板块:用户估值概览 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
用户池 · 资产评估为核心
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">通过 RFM 和画像维度评估用户价值与分布,支持用户导入导出</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Tabs />
|
||||
<div className="flex gap-2">
|
||||
<form action="/user-portrait" className="flex gap-2">
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="搜索用户、昵称或标签..."
|
||||
className="w-64"
|
||||
/>
|
||||
<Button type="submit" variant="secondary">筛选</Button>
|
||||
</form>
|
||||
<Button>+ 添加用户</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<Stat label="当前筛选用户数" value={`${items.length} 人`} />
|
||||
<Stat label="估值总额(≈)" value={`¥ ${totalValue.toLocaleString("zh-CN")}`} />
|
||||
<Stat label="RFM 范围" value={`${rfmRange[0]} - ${rfmRange[1]}`} />
|
||||
<Stat label="已选标签数" value={`${filters.tags.length}`} />
|
||||
</div>
|
||||
|
||||
<Suspense key={q} fallback={<p className="mt-4 text-sm text-muted-foreground">加载中...</p>}>
|
||||
{/* @ts-expect-error Async Server Component */}
|
||||
<UsersList q={q} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
{/* 筛选区:清晰多维度 */}
|
||||
<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"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { CardDescription } from "@/components/ui/card"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -27,6 +30,18 @@ import {
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
|
||||
type U = {
|
||||
id: string
|
||||
city: string
|
||||
store: string
|
||||
privateDomain: string
|
||||
project: string
|
||||
team: string
|
||||
rfmScore: number
|
||||
}
|
||||
|
||||
type R = { items: (U & { valueScore: number })[] }
|
||||
|
||||
// 使用通用公司名称和当天时间线的用户数据
|
||||
const mockUsers = [
|
||||
{
|
||||
@@ -162,6 +177,64 @@ export default function UserValuationPage() {
|
||||
analysis: true,
|
||||
upgradePaths: false,
|
||||
})
|
||||
const [data, setData] = useState<R | null>(null)
|
||||
|
||||
const city = useMemo(() => {
|
||||
const m = new Map<string, { count: number; value: number }>()
|
||||
mockUsers.forEach((i) => {
|
||||
const k = String(i.city)
|
||||
const cur = m.get(k) ?? { count: 0, value: 0 }
|
||||
cur.count += 1
|
||||
cur.value += i.rfmScore * 100
|
||||
m.set(k, cur)
|
||||
})
|
||||
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
|
||||
}, [])
|
||||
|
||||
const store = useMemo(() => {
|
||||
const m = new Map<string, { count: number; value: number }>()
|
||||
mockUsers.forEach((i) => {
|
||||
const k = String(i.store)
|
||||
const cur = m.get(k) ?? { count: 0, value: 0 }
|
||||
cur.count += 1
|
||||
cur.value += i.rfmScore * 100
|
||||
m.set(k, cur)
|
||||
})
|
||||
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
|
||||
}, [])
|
||||
|
||||
const project = useMemo(() => {
|
||||
const m = new Map<string, { count: number; value: number }>()
|
||||
mockUsers.forEach((i) => {
|
||||
const k = String(i.project)
|
||||
const cur = m.get(k) ?? { count: 0, value: 0 }
|
||||
cur.count += 1
|
||||
cur.value += i.rfmScore * 100
|
||||
m.set(k, cur)
|
||||
})
|
||||
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
|
||||
}, [])
|
||||
|
||||
const team = useMemo(() => {
|
||||
const m = new Map<string, { count: number; value: number }>()
|
||||
mockUsers.forEach((i) => {
|
||||
const k = String(i.team)
|
||||
const cur = m.get(k) ?? { count: 0, value: 0 }
|
||||
cur.count += 1
|
||||
cur.value += i.rfmScore * 100
|
||||
m.set(k, cur)
|
||||
})
|
||||
return Array.from(m.entries()).sort((a, b) => b[1].value - a[1].value)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users?pageSize=500")
|
||||
.then((r) => r.json())
|
||||
.then((res) => {
|
||||
const items = res.data.items.map((x: any) => ({ ...x, valueScore: x.rfmScore * 100 }))
|
||||
setData({ items })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filteredUsers = mockUsers.filter(
|
||||
(user) =>
|
||||
@@ -225,6 +298,20 @@ export default function UserValuationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户资产估值 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl">用户资产估值</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">基于流量池维度进行价值分布</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Section title="按城市分布" items={city} />
|
||||
<Section title="按门店分布" items={store} />
|
||||
<Section title="按项目分布" items={project} />
|
||||
<Section title="按团队分布" items={team} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 用户估值概览 */}
|
||||
<Collapsible
|
||||
open={expandedSections.overview}
|
||||
@@ -869,3 +956,23 @@ export default function UserValuationPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, items }: { title: string; items: [string, { count: number; value: number }][] }) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="px-3 py-2 border-b text-sm font-medium">{title}</div>
|
||||
<div className="p-3 space-y-2">
|
||||
{items.slice(0, 8).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{k}</span>
|
||||
<Badge variant="secondary">{v.count} 人</Badge>
|
||||
</div>
|
||||
<div className="font-semibold">¥ {Math.round(v.value).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="text-xs text-muted-foreground">暂无数据</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user