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:
v0
2025-08-21 05:32:37 +00:00
parent 9bb0ee2758
commit afc77439bb
25 changed files with 2421 additions and 1645 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'

View File

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

View File

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

View File

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

View File

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

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