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) {
// 获取指定数据库的结构
// 无查询参数:返回数据库列表([{ 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 })
} else {
// 获取所有数据库列表
const databases = await getDatabases()
return NextResponse.json({ success: true, data: databases })
}
} 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"
}
export default function DataPlatformPage() {
const [dataSources, setDataSources] = useState<DataSource[]>([
const mockDataSources: DataSource[] = [
{
id: "ds_001",
id: "wechat-db",
name: "微信用户数据库",
type: "MySQL",
status: "connected",
lastSync: "2024-01-15T10:30:00Z",
recordCount: 2500000000,
description: "存储微信用户基础信息和行为数据",
},
{
id: "ds_002",
name: "流量关键词库",
type: "PostgreSQL",
type: "MySQL",
records: "2.5B",
lastSync: "2024/1/15 18:30:00",
status: "connected",
lastSync: "2024-01-15T09:45:00Z",
recordCount: 150000,
},
{
id: "traffic-keywords",
name: "流量关键词库",
description: "搜索引擎关键词和流量数据",
type: "PostgreSQL",
records: "150.0K",
lastSync: "2024/1/15 17:45:00",
status: "connected",
},
{
id: "ds_003",
id: "user-behavior",
name: "用户行为日志",
type: "MongoDB",
status: "syncing",
lastSync: "2024-01-15T11:00:00Z",
recordCount: 1500000000,
description: "用户操作行为和交互记录",
type: "MongoDB",
records: "1.5B",
lastSync: "2024/1/15 19:00:00",
status: "syncing",
},
])
]
const [aiModels, setAiModels] = useState<AIModel[]>([
const mockAIModels: AIModel[] = [
{
id: "model_001",
id: "user-value-prediction",
name: "用户价值预测模型",
type: "Classification",
status: "ready",
accuracy: 0.92,
lastTrained: "2024-01-14T15:30:00Z",
parameters: {
accuracy: "92.0%",
algorithm: "RandomForest",
features: 25,
epochs: 100,
learningRate: 0.01,
},
lastTrained: "2024/1/14 23:30:00",
status: "ready",
},
{
id: "model_002",
id: "traffic-trend-analysis",
name: "流量趋势分析模型",
type: "Regression",
status: "training",
accuracy: 0.87,
lastTrained: "2024-01-15T08:00:00Z",
parameters: {
accuracy: "87.0%",
algorithm: "LSTM",
features: 15,
epochs: 200,
learningRate: 0.001,
},
lastTrained: "2024/1/15 16:00:00",
status: "training",
},
{
id: "model_003",
id: "user-clustering",
name: "用户聚类模型",
type: "Clustering",
status: "ready",
accuracy: 0.89,
lastTrained: "2024-01-13T12:00:00Z",
parameters: {
accuracy: "89.0%",
algorithm: "KMeans",
clusters: 8,
features: 20,
iterations: 300,
lastTrained: "2024/1/13 20:00:00",
status: "ready",
},
},
])
]
const [isAddingDataSource, setIsAddingDataSource] = useState(false)
const [isTrainingModel, setIsTrainingModel] = useState(false)
export default function DataPlatformPage() {
const [activeTab, setActiveTab] = useState("datasource")
const [dataSources, setDataSources] = useState<DataSource[]>(mockDataSources)
const [aiModels, setAIModels] = useState<AIModel[]>(mockAIModels)
const [syncing, setSyncing] = useState<string | null>(null)
const [training, setTraining] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [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: "",
})
// 模拟数据导入
const handleSync = async (sourceId: string) => {
setSyncing(sourceId)
setTimeout(() => {
setSyncing(null)
setDataSources((prev) =>
prev.map((ds) =>
ds.id === newSource.id
? { ...ds, recordCount: Math.floor(Math.random() * 1000000) + 10000, status: "connected" as const }
: ds,
prev.map((source) =>
source.id === sourceId ? { ...source, lastSync: new Date().toLocaleString("zh-CN") } : source,
),
)
}, 2000)
} catch (error) {
console.error("添加数据源失败:", error)
}
}
// 同步数据源
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,
},
const handleAddDataSource = () => {
const newSource: DataSource = {
id: `datasource-${Date.now()}`,
name: newDataSource.name,
description: newDataSource.description,
type: newDataSource.type,
records: "0",
lastSync: "从未同步",
status: "disconnected",
}
: model,
),
)
setIsTrainingModel(false)
}, 5000)
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 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>
<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}>
{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,332 +248,194 @@ 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>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{dataSources.map((source) => (
<Card key={source.id} className="border-2 hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{source.name}</CardTitle>
<Badge className={`${getStatusColor(source.status)} border`}>
{getStatusIcon(source.status)}
<span className="ml-1 capitalize">{source.status}</span>
</Badge>
</div>
<p className="text-sm text-gray-600">{source.description}</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">:</span>
<span className="ml-2 font-medium">{source.type}</span>
</div>
<div>
<span className="text-gray-500">:</span>
<span className="ml-2 font-medium">{formatNumber(source.recordCount)}</span>
</div>
</div>
<div className="text-sm">
<span className="text-gray-500">:</span>
<span className="ml-2">{new Date(source.lastSync).toLocaleString()}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleSyncDataSource(source.id)}
disabled={source.status === "syncing"}
className="flex-1"
>
{source.status === "syncing" ? (
<>
<RotateCcw className="w-3 h-3 mr-1 animate-spin" />
</>
) : (
<>
<RotateCcw className="w-3 h-3 mr-1" />
</>
<Button className="flex items-center gap-2" onClick={() => handleRetrain("all")}>
<RefreshCw className="w-4 h-4" />
</Button>
)}
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="datasource" className="flex items-center gap-2">
<Database className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="aimodel" className="flex items-center gap-2">
<Brain className="w-4 h-4" />
AI模型
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<div className="container mx-auto px-4 py-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsContent value="datasource" className="space-y-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold"></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{dataSources.map((source) => (
<Card key={source.id} className="bg-white">
<CardHeader className="pb-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg font-semibold mb-2">{source.name}</CardTitle>
<p className="text-gray-600 text-sm mb-3">{source.description}</p>
<div className="space-y-1 text-sm text-gray-500">
<div>
<span className="font-medium">{source.type}</span>
</div>
<div>
<span className="font-medium">{source.records}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(syncing === source.id ? "syncing" : source.status)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
<div className="text-sm text-gray-500">{source.lastSync}</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSync(source.id)}
disabled={syncing === source.id}
className="flex items-center gap-2 flex-1"
>
<RefreshCw className={`w-4 h-4 ${syncing === source.id ? "animate-spin" : ""}`} />
{syncing === source.id ? "同步中" : "同步数据"}
</Button>
<Button size="sm" variant="outline">
<Settings className="w-3 h-3" />
<Button variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
{/* AI模型管理 */}
<TabsContent value="aimodels" className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold">AI模型管理</h2>
<Dialog open={isTrainingModel} onOpenChange={setIsTrainingModel}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Brain className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="col-span-2 space-y-2">
<Label htmlFor="model"></Label>
<Select
value={modelTrainingConfig.modelId}
onValueChange={(value) => setModelTrainingConfig((prev) => ({ ...prev, modelId: value }))}
>
<SelectTrigger>
<SelectValue placeholder="选择要训练的模型" />
</SelectTrigger>
<SelectContent>
<TabsContent value="aimodel" className="space-y-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">AI模型管理</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{aiModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<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-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 className="space-y-1 text-sm text-gray-500">
<div>
<span className="font-medium">{model.algorithm}</span>
</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>
<span className="font-medium">{model.features}</span>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsTrainingModel(false)}>
</Button>
<Button onClick={handleTrainModel} disabled={!modelTrainingConfig.modelId}>
</Button>
</div>
</DialogContent>
</Dialog>
<div className="flex items-center gap-2">
{getModelStatusBadge(training === model.id ? "training" : model.status)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{aiModels.map((model) => (
<Card key={model.id} className="border-2 hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{model.name}</CardTitle>
<Badge className={`${getStatusColor(model.status)} border`}>
{getStatusIcon(model.status)}
<span className="ml-1 capitalize">{model.status}</span>
</Badge>
</div>
<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 variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
<Button size="sm" variant="outline">
<Settings className="w-3 h-3" />
</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 className="container mx-auto px-4 py-6">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600">AI助手平台</p>
</div>
{/* 核心统计数据 - 带图标的图表 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card className="border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600"></p>
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-blue-600">{formatNumber(stats.userCount)}</p>
</div>
<div className="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>
{/* 核心数据展示 - 40亿用户为中心 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* 用户总数 - 主要指标 */}
<Card className="lg:col-span-2 border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-2xl">
<div className="p-3 bg-blue-500 rounded-xl">
<Users className="w-8 h-8 text-white" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="text-center">
<div className="text-6xl font-bold text-blue-600 mb-2">{formatNumber(systemStats.userCount)}</div>
<div className="text-lg text-gray-600"></div>
</div>
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
{growthData.map((data, index) => (
<div key={index} className="text-center">
<div className="text-2xl font-bold text-green-600">+{data.userGrowth.toFixed(1)}%</div>
<div className="text-sm text-gray-500">{data.period}</div>
<div className="text-xs text-gray-400 mt-1">: {formatNumber(data.activeUsers)}</div>
</div>
))}
<div 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>
<div className="p-3 bg-purple-500 rounded-full">
<BarChart3 className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-2 border-green-200 bg-gradient-to-r from-green-50 to-green-100">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-2xl font-bold text-green-600">{stats.dataVolume}</p>
</div>
<div className="p-3 bg-green-500 rounded-full">
<Database className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-2 border-orange-200 bg-gradient-to-r from-orange-50 to-orange-100">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">AI查询</p>
<p className="text-2xl font-bold text-orange-600">{formatNumber(stats.aiQueries)}</p>
</div>
<div className="p-3 bg-orange-500 rounded-full">
<Brain className="w-6 h-6 text-white" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 整合AI对话与数据搜索功能 */}
<Card className="mb-6 border-2 border-indigo-200 bg-gradient-to-r from-indigo-50 to-blue-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-indigo-600" />
AI智能助手
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Badge variant="secondary">{systemStats.avgResponseTime}ms</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Badge variant="secondary">{systemStats.versionCount}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<Badge variant="secondary">{systemStats.cacheSize}MB</Badge>
</div>
<div className="text-xs text-gray-500 pt-2 border-t">: {lastUpdate.toLocaleTimeString()}</div>
</CardContent>
</Card>
</div>
{/* 数据增长趋势 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="p-2 bg-purple-500 rounded-lg">
<TrendingUp className="w-6 h-6 text-white" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{growthData.map((data, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold">{data.period}</div>
<div className="text-sm text-gray-500">: {formatNumber(data.activeUsers)}</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-purple-600">+{data.userGrowth.toFixed(1)}%</div>
<div className="text-xs text-gray-500"></div>
</div>
{/* 大模型选择 */}
<div 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>
))}
</div>
</CardContent>
</Card>
<Card className="border-2 border-orange-200 bg-gradient-to-r from-orange-50 to-yellow-50">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="p-2 bg-orange-500 rounded-lg">
<Database className="w-6 h-6 text-white" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-500"> {formatNumber(systemStats.keywordCount)} </div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-orange-600">+{growthData[0].dataGrowth.toFixed(1)}%</div>
<div className="text-xs text-gray-500"></div>
</div>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-orange-600">
{formatNumber(Math.floor(systemStats.userCount * 0.001))}
</div>
<div className="text-xs text-gray-500">/</div>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
<div>
<div className="font-semibold"></div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-orange-600">
{Math.floor(systemStats.userCount / 1000000)}TB
</div>
<div className="text-xs text-gray-500">使</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 快速访问入口 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 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>
{/* 搜索输入框 */}
<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>
{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>
{/* 快速指标示例(可后续接入真实数据) */}
<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 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>
{/* 搜索 + 条件过滤 + 列表 */}
<UserSearch query={searchQuery} />
<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-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 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>
) : data ? (
<div className="space-y-4">
<section className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
<ProfileHeader
name={data.name}
avatar={data.avatar}
email={data.email}
phone={data.phone}
tags={data.tags}
/>
</section>
<Section title="RFM 指标">
<MetricsRFM
recency={data.recency}
frequency={data.frequency}
monetary={data.monetary}
rfmScore={data.rfmScore}
/>
</Section>
<Section title="互动记录">
<InteractionsList items={data.interactions} />
</Section>
<Section title="购买历史">
<PurchaseHistory items={data.purchaseHistory} />
</Section>
<Section title="绑定微信账号">
<WechatAccounts accounts={data.wechatAccounts.map((w) => ({ id: w.id, nickname: w.nickname, avatar: w.avatar }))} />
</Section>
</div>
) : (
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-red-600">
)
}
if (!data) {
return (
<div className="min-h-screen bg-slate-50 p-4 flex items-center justify-center">
<div className="text-center">
<p className="text-slate-600 mb-4"></p>
<Button onClick={() => router.back()}></Button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<div className="bg-white border-b px-4 py-3 flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="font-semibold"></h1>
</div>
<div className="container mx-auto px-4 py-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{data.name}</h2>
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
<span className="flex items-center gap-1">
<Phone className="h-3 w-3" />
{data.phone}
</span>
<span className="flex items-center gap-1">
<Mail className="h-3 w-3" />
{data.email}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{data.city} · {data.store}
</span>
<span>
{data.project} · {data.team}
</span>
</div>
</div>
<Badge variant={data.status === "活跃" ? "default" : "secondary"}>{data.status}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{data.persona.map((p) => (
<Badge key={p} variant="outline" className="text-xs">
{p}
</Badge>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">¥{data.assetValue.toLocaleString()}</div>
<div className="text-xs text-slate-600"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{data.rfmScore}</div>
<div className="text-xs text-slate-600">RFM评分</div>
</div>
<div className="text-center">
<div
className={`text-2xl font-bold ${data.riskLevel === "低" ? "text-green-600" : data.riskLevel === "中" ? "text-yellow-600" : "text-red-600"}`}
>
{data.riskLevel}
</div>
<div className="text-xs text-slate-600"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{data.source}</div>
<div className="text-xs text-slate-600"></div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
{data.assetBreakdown.map((item) => (
<div key={item.category} className="flex items-center justify-between">
<span className="text-sm">{item.category}</span>
<div className="flex items-center gap-2">
<div className="w-20 bg-slate-200 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${item.percentage}%` }}></div>
</div>
<span className="text-sm font-medium">¥{item.value.toLocaleString()}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{data.detailedTags.map((category) => (
<div key={category.category} className="space-y-2">
<h4 className="font-medium text-sm text-slate-700">{category.category}</h4>
<div className="grid gap-2">
{category.tags.map((tag) => (
<div key={tag.name} className="flex items-center justify-between p-2 bg-slate-50 rounded-md">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{tag.name}
</Badge>
<span className="text-xs text-slate-600">: {tag.source}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-12 bg-slate-200 rounded-full h-1">
<div className="bg-green-500 h-1 rounded-full" style={{ width: `${tag.confidence}%` }}></div>
</div>
<span className="text-xs font-medium">{tag.confidence}%</span>
</div>
</div>
))}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
RFM详细指标
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-xl font-bold">{data.recency}</div>
<div className="text-xs text-slate-600"> (R)</div>
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${data.recency}%` }}></div>
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold">{data.frequency}</div>
<div className="text-xs text-slate-600"> (F)</div>
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
<div className="bg-green-500 h-2 rounded-full" style={{ width: `${data.frequency}%` }}></div>
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold">{data.monetary}</div>
<div className="text-xs text-slate-600"> (M)</div>
<div className="w-full bg-slate-200 rounded-full h-2 mt-1">
<div className="bg-purple-500 h-2 rounded-full" style={{ width: `${data.monetary}%` }}></div>
</div>
</div>
</div>
<div className="mt-4 text-center">
<div className="text-sm text-slate-600"></div>
<div className="flex items-center justify-center gap-1 mt-1">
<Calendar className="h-3 w-3" />
<span className="text-sm">{data.lastActivity}</span>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</main>
<BottomNav />
</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)
export default function UserPoolPage() {
const router = useRouter()
const [q, setQ] = useState("")
const [rfmRange, setRfmRange] = useState<[number, number]>([0, 100])
const [items, setItems] = useState<ListItem[]>([])
const [meta, setMeta] = useState<Meta>({ tags: [], cities: [], personas: [], sources: [] })
const [filters, setFilters] = useState({
tags: [] as string[],
status: [] as string[],
city: [] as string[],
persona: [] as string[],
source: [] as string[],
})
const [totalValue, setTotalValue] = useState(0)
useEffect(() => {
fetch("/api/users?meta=tags")
.then((r) => r.json())
.then((res) => setMeta(res.data))
}, [])
const queryString = useMemo(() => {
const p = new URLSearchParams()
if (q) p.set("q", q)
if (filters.tags.length) p.set("tags", filters.tags.join(","))
if (filters.status.length) p.set("status", filters.status.join(","))
if (filters.city.length) p.set("city", filters.city.join(","))
if (filters.persona.length) p.set("persona", filters.persona.join(","))
if (filters.source.length) p.set("source", filters.source.join(","))
p.set("rfmMin", String(rfmRange[0]))
p.set("rfmMax", String(rfmRange[1]))
p.set("page", "1")
p.set("pageSize", "50")
return p.toString()
}, [q, filters, rfmRange])
useEffect(() => {
fetch(`/api/users?${queryString}`)
.then((r) => r.json())
.then((res) => {
setItems(res.data.items)
setTotalValue(res.data.totalValue)
})
}, [queryString])
const toggle = (key: keyof typeof filters, v: string) =>
setFilters((prev) => ({
...prev,
[key]: prev[key].includes(v) ? prev[key].filter((x) => x !== v) : [...prev[key], v],
}))
const handleUserClick = (userId: string) => {
router.push(`/user-portrait/${userId}`)
}
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">
<div className="container mx-auto px-4 py-5 space-y-4">
{/* 核心板块:用户估值概览 */}
<Card>
<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 async function UserPortraitPage({
searchParams,
}: {
searchParams: { q?: string }
}) {
const q = (searchParams?.q || "").trim()
return (
<main className="mx-auto max-w-6xl p-4 md:p-6">
<Card className="shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl md:text-2xl"></CardTitle>
<p className="text-sm text-muted-foreground"></p>
<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 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>
</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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

415
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

74
types/acquisition.ts Normal file
View File

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

View File

@@ -1,4 +1,6 @@
export type DeviceStatus = "online" | "offline" | "unknown"
export type DeviceStatus = "online" | "offline" | "unknown" | "busy"
export type DeviceType = Device
export interface Device {
id: string
@@ -6,7 +8,9 @@ export interface Device {
wechatId?: string
group?: string
tags?: string[]
os: "Android" | "iOS" | "Windows" | "Mac"
status: DeviceStatus
owner?: string
lastSeen?: string
}
@@ -15,6 +19,7 @@ export interface CreateDeviceParams {
wechatId?: string
group?: string
tags?: string[]
os: "Android" | "iOS" | "Windows" | "Mac"
}
export interface UpdateDeviceParams extends Partial<CreateDeviceParams> {

View File

@@ -1,4 +1,5 @@
export type ScenarioStatus = "draft" | "running" | "paused" | "completed"
export type ScenarioStatus = "draft" | "running" | "paused" | "completed" | "active" | "archived"
export type Channel = "phone" | "wechat" | "douyin" | "xiaohongshu" | "api"
export interface ScenarioBase {
id: string
@@ -11,9 +12,19 @@ export interface ScenarioBase {
description?: string
}
export interface Scenario {
id: string
name: string
channel: Channel
createdAt: string
status: ScenarioStatus
config?: Record<string, unknown>
}
export interface CreateScenarioParams {
name: string
type: string
channel: Channel
description?: string
}
@@ -42,7 +53,7 @@ export interface AcquisitionRecord {
scenarioId: string
userId: string
time: string
channel: string
channel: Channel
}
export interface PaginatedResponse<T> {

59
types/traffic.ts Normal file
View File

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

View File

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