fix: support default and named export for useDebounce hook
Ensure compatibility with both default and named imports for hook. #VERCEL_SKIP Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
@@ -5,10 +5,9 @@ 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 BottomTabs from "@/components/nav/bottom-tabs"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import BottomNav from "./components/BottomNav"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
|
||||
@@ -19,7 +18,6 @@ export default function ClientLayout({
|
||||
}) {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
@@ -35,7 +33,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>
|
||||
@@ -49,7 +47,7 @@ export default function ClientLayout({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-screen">
|
||||
{/* 桌面端侧边栏 - PC端不显示底部导航 */}
|
||||
{/* 桌面端侧边栏 */}
|
||||
{!isMobile && <Sidebar />}
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
@@ -57,21 +55,16 @@ export default function ClientLayout({
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className={`flex-1 ${isMobile ? "pb-20" : "p-6"}`}>
|
||||
{/* 移动端头部 - 添加卡若数据资产中台标题 */}
|
||||
{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>
|
||||
)}
|
||||
{/* 移动端头部 */}
|
||||
{isMobile && <MobileHeader onMenuToggle={() => setSidebarOpen(true)} />}
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className={`glass-card min-h-full ${isMobile ? "mx-2 mb-4" : ""}`}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 移动端底部导航 - 使用新的BottomTabs组件 */}
|
||||
{isMobile && <BottomTabs />}
|
||||
<Toaster />
|
||||
{/* 移动端底部导航 */}
|
||||
{isMobile && <BottomNav />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,105 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { TrendingUp, FileText, Sparkles } from "lucide-react"
|
||||
import { BarChart3, Database, FileText } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { 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, ReportItem, ReportTemplate } from "@/types/ai-assistant"
|
||||
import type { AnalysisTask, DatabaseInfo, 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-asset-evaluation.pdf",
|
||||
description: "基于RFM模型和行为数据的用户资产价值评估",
|
||||
reportUrl: "/reports/user-behavior-analysis.pdf",
|
||||
description: "基于近30天行为的聚合与序列分析",
|
||||
},
|
||||
{
|
||||
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: "预测用户未来价值和流失风险",
|
||||
description: "RFM 分层、价值区间分布与运营建议",
|
||||
},
|
||||
]
|
||||
|
||||
const databases: DatabaseInfo[] = [
|
||||
{ id: "db_001", name: "微信用户数据库", type: "MySQL", tables: 25, records: 4000000000, lastUpdated: "2025-01-15T11:00:00Z" },
|
||||
{ id: "db_002", name: "流量关键词库", type: "PostgreSQL", tables: 8, records: 150000, lastUpdated: "2025-01-15T10:45:00Z" },
|
||||
{ id: "db_003", name: "用户行为日志", type: "MongoDB", tables: 12, records: 1500000000, lastUpdated: "2025-01-15T11:15:00Z" },
|
||||
]
|
||||
|
||||
const templates: ReportTemplate[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ 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: ["数据完整性", "准确性检查", "一致性验证", "异常检测", "改进建议"] },
|
||||
]
|
||||
|
||||
const reportItems: ReportItem[] = [
|
||||
{
|
||||
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(),
|
||||
},
|
||||
{ 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() },
|
||||
]
|
||||
|
||||
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) => {
|
||||
@@ -129,36 +95,28 @@ export default function AIAssistantPage() {
|
||||
}, 800)
|
||||
}
|
||||
|
||||
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) })), [])
|
||||
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 flex items-center gap-2">
|
||||
<Sparkles className="h-8 w-8 text-blue-500" />
|
||||
AI智能助手
|
||||
</h1>
|
||||
<p className="text-gray-600">专注用户资产评估与价值分析</p>
|
||||
<h1 className="text-3xl font-bold">AI智能助手</h1>
|
||||
<p className="text-gray-600">数据库分析与智能报告生成</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="analysis" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="analysis" className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
用户资产评估
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
数据分析
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="databases" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据库
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="templates" className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
@@ -168,8 +126,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={[]} templates={templates} onCreate={onCreate} />
|
||||
<h2 className="text-xl font-semibold">数据分析任务</h2>
|
||||
<CreateTaskDialog databases={databases} templates={templates} onCreate={onCreate} />
|
||||
</div>
|
||||
|
||||
<TaskList tasks={tasks} onTick={tick} />
|
||||
@@ -177,12 +135,38 @@ 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">
|
||||
@@ -191,29 +175,7 @@ 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.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>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">字段: {t.fields.join(" / ")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabases, getDatabaseStructure, getTableStructure } from "@/lib/db-connector"
|
||||
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(req: Request) {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const database = url.searchParams.get("database")
|
||||
const table = url.searchParams.get("table")
|
||||
const { searchParams } = new URL(request.url)
|
||||
const database = searchParams.get("database")
|
||||
|
||||
// 无查询参数:返回数据库列表([{ Database: string }])
|
||||
if (!database) {
|
||||
const dbs = await getDatabases()
|
||||
return NextResponse.json({ success: true, data: dbs })
|
||||
if (database) {
|
||||
// 获取指定数据库的结构
|
||||
const structure = await getDatabaseStructure(database)
|
||||
return NextResponse.json({ success: true, data: structure })
|
||||
} else {
|
||||
// 获取所有数据库列表
|
||||
const databases = await getDatabases()
|
||||
return NextResponse.json({ success: true, data: databases })
|
||||
}
|
||||
|
||||
// 有 database + table:返回表结构
|
||||
if (database && table) {
|
||||
const fields = await getTableStructure(database, table)
|
||||
return NextResponse.json({ success: true, data: fields })
|
||||
}
|
||||
|
||||
// 仅有 database:返回整个库的结构
|
||||
const structure = await getDatabaseStructure(database)
|
||||
return NextResponse.json({ success: true, data: structure })
|
||||
} catch (error) {
|
||||
console.error("数据库结构API错误:", error)
|
||||
return NextResponse.json({ success: false, message: "获取数据库结构失败" }, { status: 500 })
|
||||
console.error("数据库结构查询失败:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "数据库结构查询失败", error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
// 生成最小可用的 OpenAPI 3.1 规范,覆盖当前已实现的关键接口
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url)
|
||||
const download = url.searchParams.get("download") === "1"
|
||||
|
||||
const spec = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "用户数据资产中台 API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"统一用户数据接入与治理接口。包含数据接入、数据库结构浏览等端点。模型结构参考 v1.4 文档中的统一用户画像定义。",
|
||||
},
|
||||
paths: {
|
||||
"/api/ingest": {
|
||||
get: {
|
||||
summary: "获取数据接入状态",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "成功",
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
summary: "提交单条数据进行接入处理",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string" },
|
||||
sourceUserId: { type: "string" },
|
||||
sourceRecordId: { type: "string" },
|
||||
originalData: { type: "object" },
|
||||
timestamp: { type: "string", format: "date-time" },
|
||||
},
|
||||
required: ["source", "originalData"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "成功" }, "400": { description: "参数错误" } },
|
||||
},
|
||||
put: {
|
||||
summary: "批量接入处理",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
requests: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string" },
|
||||
sourceUserId: { type: "string" },
|
||||
sourceRecordId: { type: "string" },
|
||||
originalData: { type: "object" },
|
||||
timestamp: { type: "string", format: "date-time" },
|
||||
},
|
||||
required: ["source", "originalData"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["requests"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "成功" }, "400": { description: "参数错误" } },
|
||||
},
|
||||
},
|
||||
"/api/database-structure": {
|
||||
get: {
|
||||
summary: "获取数据库列表或结构",
|
||||
parameters: [
|
||||
{ name: "database", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "table", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: { "200": { description: "成功" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const res = NextResponse.json(spec)
|
||||
if (download) {
|
||||
res.headers.set("Content-Disposition", 'attachment; filename="openapi.json"')
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { NextResponse, NextRequest } from "next/server"
|
||||
import type { TrafficUser } from "@/types/traffic"
|
||||
import { addUser, getUsersStore, queryUsers, type User } from "@/lib/mock-users"
|
||||
import { addUser, filterUsers, getDistinctTags, getUserById, queryUsers, type UserStatus } from "@/lib/mock-users"
|
||||
|
||||
// 中文名字生成器数据
|
||||
const familyNames = [
|
||||
@@ -127,9 +127,6 @@ 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),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -188,9 +185,6 @@ 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),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -200,57 +194,37 @@ 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(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
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 id = searchParams.get('id')
|
||||
if (id) {
|
||||
const detail = getUserById(id)
|
||||
return NextResponse.json({ data: detail }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
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)
|
||||
// 列表
|
||||
const q = searchParams.get('q') ?? undefined
|
||||
const tagsStr = searchParams.get('tags') ?? ''
|
||||
const statusStr = searchParams.get('status') ?? ''
|
||||
const rfmMin = Number(searchParams.get('rfmMin') ?? 0)
|
||||
const rfmMax = Number(searchParams.get('rfmMax') ?? 100)
|
||||
const page = Number(searchParams.get('page') ?? 1)
|
||||
const pageSize = Number(searchParams.get('pageSize') ?? 20)
|
||||
|
||||
const { data, pagination } = queryUsers({
|
||||
q,
|
||||
tags,
|
||||
status,
|
||||
city,
|
||||
persona,
|
||||
source,
|
||||
rfmMin,
|
||||
rfmMax,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
const tags = tagsStr ? tagsStr.split(',').filter(Boolean) : undefined
|
||||
const status = statusStr ? (statusStr.split(',').filter(Boolean) as any) : undefined
|
||||
|
||||
// 用户估值:简单以 rfmScore * 100 作为估值
|
||||
const totalValue = data.reduce((sum, u) => sum + u.rfmScore * 100, 0)
|
||||
|
||||
return NextResponse.json({ success: true, data: { items: data, pagination, totalValue } })
|
||||
const result = queryUsers({ q, tags, status, rfmMin, rfmMax, page, pageSize })
|
||||
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
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 })
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const created = addUser(body ?? {})
|
||||
return NextResponse.json({ data: created }, { status: 201 })
|
||||
}
|
||||
|
||||
@@ -1,246 +1,340 @@
|
||||
"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 { Database, Plus, RefreshCw, Settings, Brain, Play } from "lucide-react"
|
||||
import BottomTabs from "@/components/nav/bottom-tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
records: string
|
||||
lastSync: string
|
||||
status: "connected" | "disconnected" | "syncing"
|
||||
lastSync: string
|
||||
recordCount: number
|
||||
description: string
|
||||
}
|
||||
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
accuracy: string
|
||||
algorithm: string
|
||||
features: number
|
||||
status: "training" | "ready" | "error"
|
||||
accuracy: number
|
||||
lastTrained: string
|
||||
status: "ready" | "training" | "error"
|
||||
parameters: Record<string, any>
|
||||
}
|
||||
|
||||
const mockDataSources: DataSource[] = [
|
||||
{
|
||||
id: "wechat-db",
|
||||
name: "微信用户数据库",
|
||||
description: "存储微信用户基础信息和行为数据",
|
||||
type: "MySQL",
|
||||
records: "2.5B",
|
||||
lastSync: "2024/1/15 18:30:00",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "traffic-keywords",
|
||||
name: "流量关键词库",
|
||||
description: "搜索引擎关键词和流量数据",
|
||||
type: "PostgreSQL",
|
||||
records: "150.0K",
|
||||
lastSync: "2024/1/15 17:45:00",
|
||||
status: "connected",
|
||||
},
|
||||
{
|
||||
id: "user-behavior",
|
||||
name: "用户行为日志",
|
||||
description: "用户操作行为和交互记录",
|
||||
type: "MongoDB",
|
||||
records: "1.5B",
|
||||
lastSync: "2024/1/15 19:00:00",
|
||||
status: "syncing",
|
||||
},
|
||||
]
|
||||
|
||||
const mockAIModels: AIModel[] = [
|
||||
{
|
||||
id: "user-value-prediction",
|
||||
name: "用户价值预测模型",
|
||||
type: "Classification",
|
||||
accuracy: "92.0%",
|
||||
algorithm: "RandomForest",
|
||||
features: 25,
|
||||
lastTrained: "2024/1/14 23:30:00",
|
||||
status: "ready",
|
||||
},
|
||||
{
|
||||
id: "traffic-trend-analysis",
|
||||
name: "流量趋势分析模型",
|
||||
type: "Regression",
|
||||
accuracy: "87.0%",
|
||||
algorithm: "LSTM",
|
||||
features: 15,
|
||||
lastTrained: "2024/1/15 16:00:00",
|
||||
status: "training",
|
||||
},
|
||||
{
|
||||
id: "user-clustering",
|
||||
name: "用户聚类模型",
|
||||
type: "Clustering",
|
||||
accuracy: "89.0%",
|
||||
algorithm: "KMeans",
|
||||
features: 20,
|
||||
lastTrained: "2024/1/13 20:00:00",
|
||||
status: "ready",
|
||||
},
|
||||
]
|
||||
|
||||
export default function DataPlatformPage() {
|
||||
const [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 [dataSources, setDataSources] = useState<DataSource[]>([
|
||||
{
|
||||
id: "ds_001",
|
||||
name: "微信用户数据库",
|
||||
type: "MySQL",
|
||||
status: "connected",
|
||||
lastSync: "2024-01-15T10:30:00Z",
|
||||
recordCount: 2500000000,
|
||||
description: "存储微信用户基础信息和行为数据",
|
||||
},
|
||||
{
|
||||
id: "ds_002",
|
||||
name: "流量关键词库",
|
||||
type: "PostgreSQL",
|
||||
status: "connected",
|
||||
lastSync: "2024-01-15T09:45:00Z",
|
||||
recordCount: 150000,
|
||||
description: "搜索引擎关键词和流量数据",
|
||||
},
|
||||
{
|
||||
id: "ds_003",
|
||||
name: "用户行为日志",
|
||||
type: "MongoDB",
|
||||
status: "syncing",
|
||||
lastSync: "2024-01-15T11:00:00Z",
|
||||
recordCount: 1500000000,
|
||||
description: "用户操作行为和交互记录",
|
||||
},
|
||||
])
|
||||
|
||||
const [aiModels, setAiModels] = useState<AIModel[]>([
|
||||
{
|
||||
id: "model_001",
|
||||
name: "用户价值预测模型",
|
||||
type: "Classification",
|
||||
status: "ready",
|
||||
accuracy: 0.92,
|
||||
lastTrained: "2024-01-14T15:30:00Z",
|
||||
parameters: {
|
||||
algorithm: "RandomForest",
|
||||
features: 25,
|
||||
epochs: 100,
|
||||
learningRate: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "model_002",
|
||||
name: "流量趋势分析模型",
|
||||
type: "Regression",
|
||||
status: "training",
|
||||
accuracy: 0.87,
|
||||
lastTrained: "2024-01-15T08:00:00Z",
|
||||
parameters: {
|
||||
algorithm: "LSTM",
|
||||
features: 15,
|
||||
epochs: 200,
|
||||
learningRate: 0.001,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "model_003",
|
||||
name: "用户聚类模型",
|
||||
type: "Clustering",
|
||||
status: "ready",
|
||||
accuracy: 0.89,
|
||||
lastTrained: "2024-01-13T12:00:00Z",
|
||||
parameters: {
|
||||
algorithm: "KMeans",
|
||||
clusters: 8,
|
||||
features: 20,
|
||||
iterations: 300,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [isAddingDataSource, setIsAddingDataSource] = useState(false)
|
||||
const [isTrainingModel, setIsTrainingModel] = useState(false)
|
||||
const [newDataSource, setNewDataSource] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
type: "MySQL",
|
||||
host: "",
|
||||
port: "",
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
description: "",
|
||||
})
|
||||
|
||||
const handleSync = async (sourceId: string) => {
|
||||
setSyncing(sourceId)
|
||||
setTimeout(() => {
|
||||
setSyncing(null)
|
||||
setDataSources((prev) =>
|
||||
prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, lastSync: new Date().toLocaleString("zh-CN") } : source,
|
||||
),
|
||||
)
|
||||
}, 2000)
|
||||
const [modelTrainingConfig, setModelTrainingConfig] = useState({
|
||||
modelId: "",
|
||||
algorithm: "RandomForest",
|
||||
features: 25,
|
||||
epochs: 100,
|
||||
learningRate: 0.01,
|
||||
validationSplit: 0.2,
|
||||
autoTune: true,
|
||||
})
|
||||
|
||||
// 添加数据源
|
||||
const handleAddDataSource = async () => {
|
||||
try {
|
||||
const newSource: DataSource = {
|
||||
id: `ds_${Date.now()}`,
|
||||
name: newDataSource.name,
|
||||
type: newDataSource.type,
|
||||
status: "connected",
|
||||
lastSync: new Date().toISOString(),
|
||||
recordCount: 0,
|
||||
description: newDataSource.description,
|
||||
}
|
||||
|
||||
setDataSources((prev) => [...prev, newSource])
|
||||
setIsAddingDataSource(false)
|
||||
setNewDataSource({
|
||||
name: "",
|
||||
type: "MySQL",
|
||||
host: "",
|
||||
port: "",
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
description: "",
|
||||
})
|
||||
|
||||
// 模拟数据导入
|
||||
setTimeout(() => {
|
||||
setDataSources((prev) =>
|
||||
prev.map((ds) =>
|
||||
ds.id === newSource.id
|
||||
? { ...ds, recordCount: Math.floor(Math.random() * 1000000) + 10000, status: "connected" as const }
|
||||
: ds,
|
||||
),
|
||||
)
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error("添加数据源失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetrain = async (modelId: string) => {
|
||||
setTraining(modelId)
|
||||
// 同步数据源
|
||||
const handleSyncDataSource = (id: string) => {
|
||||
setDataSources((prev) =>
|
||||
prev.map((ds) => (ds.id === id ? { ...ds, status: "syncing" as const, lastSync: new Date().toISOString() } : ds)),
|
||||
)
|
||||
|
||||
// 模拟同步完成
|
||||
setTimeout(() => {
|
||||
setTraining(null)
|
||||
setAIModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.id === modelId ? { ...model, lastTrained: new Date().toLocaleString("zh-CN") } : model,
|
||||
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,
|
||||
),
|
||||
)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const handleAddDataSource = () => {
|
||||
const newSource: DataSource = {
|
||||
id: `datasource-${Date.now()}`,
|
||||
name: newDataSource.name,
|
||||
description: newDataSource.description,
|
||||
type: newDataSource.type,
|
||||
records: "0",
|
||||
lastSync: "从未同步",
|
||||
status: "disconnected",
|
||||
}
|
||||
setDataSources((prev) => [...prev, newSource])
|
||||
setNewDataSource({
|
||||
name: "",
|
||||
description: "",
|
||||
type: "MySQL",
|
||||
host: "",
|
||||
port: "",
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
})
|
||||
setShowAddDialog(false)
|
||||
// 训练AI模型
|
||||
const handleTrainModel = async () => {
|
||||
if (!modelTrainingConfig.modelId) return
|
||||
|
||||
setIsTrainingModel(true)
|
||||
|
||||
// 更新模型状态为训练中
|
||||
setAiModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.id === modelTrainingConfig.modelId ? { ...model, status: "training" as const } : model,
|
||||
),
|
||||
)
|
||||
|
||||
// 模拟训练过程
|
||||
setTimeout(() => {
|
||||
setAiModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.id === modelTrainingConfig.modelId
|
||||
? {
|
||||
...model,
|
||||
status: "ready" as const,
|
||||
accuracy: Math.random() * 0.1 + 0.85,
|
||||
lastTrained: new Date().toISOString(),
|
||||
parameters: {
|
||||
algorithm: modelTrainingConfig.algorithm,
|
||||
features: modelTrainingConfig.features,
|
||||
epochs: modelTrainingConfig.epochs,
|
||||
learningRate: modelTrainingConfig.learningRate,
|
||||
},
|
||||
}
|
||||
: model,
|
||||
),
|
||||
)
|
||||
setIsTrainingModel(false)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: DataSource["status"]) => {
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000000) {
|
||||
return `${(num / 1000000000).toFixed(1)}B`
|
||||
}
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <Badge className="bg-green-100 text-green-800 border-green-200">Connected</Badge>
|
||||
case "disconnected":
|
||||
return <Badge variant="destructive">Disconnected</Badge>
|
||||
case "ready":
|
||||
return "text-green-600 bg-green-50 border-green-200"
|
||||
case "syncing":
|
||||
return <Badge className="bg-blue-100 text-blue-800 border-blue-200">Syncing</Badge>
|
||||
case "training":
|
||||
return "text-yellow-600 bg-yellow-50 border-yellow-200"
|
||||
case "disconnected":
|
||||
case "error":
|
||||
return "text-red-600 bg-red-50 border-red-200"
|
||||
default:
|
||||
return <Badge variant="secondary">Unknown</Badge>
|
||||
return "text-gray-600 bg-gray-50 border-gray-200"
|
||||
}
|
||||
}
|
||||
|
||||
const getModelStatusBadge = (status: AIModel["status"]) => {
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
case "ready":
|
||||
return <Badge className="bg-green-100 text-green-800 border-green-200">Ready</Badge>
|
||||
return <CheckCircle className="w-4 h-4" />
|
||||
case "syncing":
|
||||
case "training":
|
||||
return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">Training</Badge>
|
||||
return <Zap className="w-4 h-4 animate-pulse" />
|
||||
case "disconnected":
|
||||
case "error":
|
||||
return <Badge variant="destructive">Error</Badge>
|
||||
return <AlertCircle className="w-4 h-4" />
|
||||
default:
|
||||
return <Badge variant="secondary">Unknown</Badge>
|
||||
return <Database className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据中台</h1>
|
||||
<p className="text-gray-600 mt-1">数据源管理与AI模型训练平台</p>
|
||||
</div>
|
||||
{activeTab === "datasource" ? (
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">数据中台</h1>
|
||||
<p className="text-gray-600">数据源管理与AI模型训练平台</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="datasources" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="datasources" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据源管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="aimodels" className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
AI模型
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 数据源管理 */}
|
||||
<TabsContent value="datasources" className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">数据源管理</h2>
|
||||
<Dialog open={isAddingDataSource} onOpenChange={setIsAddingDataSource}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加新数据源</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
名称
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">数据源名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newDataSource.name}
|
||||
onChange={(e) => setNewDataSource({ ...newDataSource, name: e.target.value })}
|
||||
className="col-span-3"
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="输入数据源名称"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">数据库类型</Label>
|
||||
<Select
|
||||
value={newDataSource.type}
|
||||
onValueChange={(value) => setNewDataSource({ ...newDataSource, type: value })}
|
||||
onValueChange={(value) => setNewDataSource((prev) => ({ ...prev, type: value }))}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -248,114 +342,130 @@ 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="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="host" className="text-right">
|
||||
主机
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host">主机地址</Label>
|
||||
<Input
|
||||
id="host"
|
||||
value={newDataSource.host}
|
||||
onChange={(e) => setNewDataSource({ ...newDataSource, host: e.target.value })}
|
||||
className="col-span-3"
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, host: e.target.value }))}
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="port" className="text-right">
|
||||
端口
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port">端口</Label>
|
||||
<Input
|
||||
id="port"
|
||||
value={newDataSource.port}
|
||||
onChange={(e) => setNewDataSource({ ...newDataSource, port: e.target.value })}
|
||||
className="col-span-3"
|
||||
onChange={(e) => setNewDataSource((prev) => ({ ...prev, port: e.target.value }))}
|
||||
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={() => setShowAddDialog(false)}>
|
||||
<Button variant="outline" onClick={() => setIsAddingDataSource(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddDataSource} disabled={!newDataSource.name || !newDataSource.type}>
|
||||
添加
|
||||
</Button>
|
||||
<Button onClick={handleAddDataSource}>连接并导入数据</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button className="flex items-center gap-2" onClick={() => handleRetrain("all")}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重新训练模型
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="datasource" className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
数据源管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="aimodel" className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
AI模型
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsContent value="datasource" className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">数据源管理</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{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>
|
||||
<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 className="flex items-center gap-2">
|
||||
{getStatusBadge(syncing === source.id ? "syncing" : source.status)}
|
||||
<div>
|
||||
<span className="text-gray-500">记录数:</span>
|
||||
<span className="ml-2 font-medium">{formatNumber(source.recordCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">最后同步:{source.lastSync}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSync(source.id)}
|
||||
disabled={syncing === source.id}
|
||||
className="flex items-center gap-2 flex-1"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncing === source.id ? "animate-spin" : ""}`} />
|
||||
{syncing === source.id ? "同步中" : "同步数据"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">最后同步:</span>
|
||||
<span className="ml-2">{new Date(source.lastSync).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleSyncDataSource(source.id)}
|
||||
disabled={source.status === "syncing"}
|
||||
className="flex-1"
|
||||
>
|
||||
{source.status === "syncing" ? (
|
||||
<>
|
||||
<RotateCcw className="w-3 h-3 mr-1 animate-spin" />
|
||||
同步中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
同步数据
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Settings className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -363,79 +473,201 @@ export default function DataPlatformPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="aimodel" className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">AI模型管理</h2>
|
||||
{/* AI模型管理 */}
|
||||
<TabsContent value="aimodels" className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">AI模型管理</h2>
|
||||
<Dialog open={isTrainingModel} onOpenChange={setIsTrainingModel}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
重新训练模型
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>模型训练配置</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="model">选择模型</Label>
|
||||
<Select
|
||||
value={modelTrainingConfig.modelId}
|
||||
onValueChange={(value) => setModelTrainingConfig((prev) => ({ ...prev, modelId: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择要训练的模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aiModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="algorithm">算法</Label>
|
||||
<Select
|
||||
value={modelTrainingConfig.algorithm}
|
||||
onValueChange={(value) => setModelTrainingConfig((prev) => ({ ...prev, algorithm: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="RandomForest">Random Forest</SelectItem>
|
||||
<SelectItem value="XGBoost">XGBoost</SelectItem>
|
||||
<SelectItem value="LSTM">LSTM</SelectItem>
|
||||
<SelectItem value="KMeans">K-Means</SelectItem>
|
||||
<SelectItem value="SVM">SVM</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="features">特征数量</Label>
|
||||
<Input
|
||||
id="features"
|
||||
type="number"
|
||||
value={modelTrainingConfig.features}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({ ...prev, features: Number.parseInt(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="epochs">训练轮数</Label>
|
||||
<Input
|
||||
id="epochs"
|
||||
type="number"
|
||||
value={modelTrainingConfig.epochs}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({ ...prev, epochs: Number.parseInt(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="learningRate">学习率</Label>
|
||||
<Input
|
||||
id="learningRate"
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={modelTrainingConfig.learningRate}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({
|
||||
...prev,
|
||||
learningRate: Number.parseFloat(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validationSplit">验证集比例</Label>
|
||||
<Input
|
||||
id="validationSplit"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
max="0.5"
|
||||
value={modelTrainingConfig.validationSplit}
|
||||
onChange={(e) =>
|
||||
setModelTrainingConfig((prev) => ({
|
||||
...prev,
|
||||
validationSplit: Number.parseFloat(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex items-center space-x-2">
|
||||
<Switch
|
||||
id="autoTune"
|
||||
checked={modelTrainingConfig.autoTune}
|
||||
onCheckedChange={(checked) =>
|
||||
setModelTrainingConfig((prev) => ({ ...prev, autoTune: checked }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="autoTune">自动调参</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsTrainingModel(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleTrainModel} disabled={!modelTrainingConfig.modelId}>
|
||||
开始训练
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{aiModels.map((model) => (
|
||||
<Card key={model.id} className="bg-white">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg font-semibold mb-2">{model.name}</CardTitle>
|
||||
<p className="text-gray-600 text-sm mb-3">{model.type} 模型</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>准确率</span>
|
||||
<span className="font-medium">{model.accuracy}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-gray-900 h-2 rounded-full" style={{ width: model.accuracy }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-gray-500">
|
||||
<div>
|
||||
算法:<span className="font-medium">{model.algorithm}</span>
|
||||
</div>
|
||||
<div>
|
||||
特征数:<span className="font-medium">{model.features}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
{getModelStatusBadge(training === model.id ? "training" : model.status)}
|
||||
<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>
|
||||
</CardHeader>
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
训练中
|
||||
</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>
|
||||
<>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
预测
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Settings className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -444,8 +676,6 @@ export default function DataPlatformPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<BottomTabs />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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"] })
|
||||
@@ -19,3 +18,6 @@ export default function RootLayout({
|
||||
}) {
|
||||
return <ClientLayout>{children}</ClientLayout>
|
||||
}
|
||||
|
||||
|
||||
import './globals.css'
|
||||
534
app/page.tsx
534
app/page.tsx
@@ -1,70 +1,102 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { Users, Database, Brain, BarChart3, Activity, MessageSquare, Zap, Send } from "lucide-react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Search, Users, TrendingUp, Database, RefreshCw, BarChart3, Activity, Globe } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { 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 BottomTabs from "@/components/nav/bottom-tabs"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import UserSearch from '@/components/home/user-search'
|
||||
import UserList from '@/components/home/user-list'
|
||||
|
||||
interface SystemStats {
|
||||
userCount: number
|
||||
userAssets: number
|
||||
dataVolume: string
|
||||
aiQueries: number
|
||||
keywordCount: number
|
||||
versionCount: number
|
||||
avgResponseTime: number
|
||||
cacheSize: number
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
type: "user" | "ai"
|
||||
content: string
|
||||
timestamp: Date
|
||||
interface GrowthData {
|
||||
period: string
|
||||
userGrowth: number
|
||||
dataGrowth: number
|
||||
activeUsers: number
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs">{icon}<span>{label}</span></div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const router = useRouter()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
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>({
|
||||
const [systemStats, setSystemStats] = useState<SystemStats>({
|
||||
userCount: 4000000000,
|
||||
userAssets: 4000000000,
|
||||
dataVolume: "2.5TB",
|
||||
aiQueries: 156789,
|
||||
keywordCount: 150000,
|
||||
versionCount: 25,
|
||||
avgResponseTime: 120,
|
||||
cacheSize: 0,
|
||||
connected: true,
|
||||
})
|
||||
const [growthData, setGrowthData] = useState<GrowthData[]>([
|
||||
{ period: "今日", userGrowth: 2.3, dataGrowth: 1.8, activeUsers: 85600000 },
|
||||
{ period: "本周", userGrowth: 12.5, dataGrowth: 8.9, activeUsers: 520000000 },
|
||||
{ period: "本月", userGrowth: 45.2, dataGrowth: 32.1, activeUsers: 1200000000 },
|
||||
])
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date())
|
||||
|
||||
const handleAIChat = async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
// 自动刷新数据
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
refreshData()
|
||||
}, 30000) // 30秒刷新一次
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
type: "user",
|
||||
content: searchQuery,
|
||||
timestamp: new Date(),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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("")
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/intelligent-search?q=${encodeURIComponent(searchQuery)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字显示
|
||||
@@ -81,229 +113,261 @@ export default function HomePage() {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 大模型选项
|
||||
const modelOptions = [
|
||||
{ value: "gpt-5", label: "GPT-5", provider: "OpenAI" },
|
||||
{ value: "gpt-4", label: "GPT-4", provider: "OpenAI" },
|
||||
{ value: "claude-3", label: "Claude-3", provider: "Anthropic" },
|
||||
{ value: "gemini-pro", label: "Gemini Pro", provider: "Google" },
|
||||
{ value: "qwen-max", label: "通义千问", provider: "阿里云" },
|
||||
{ value: "baichuan", label: "百川大模型", provider: "百川智能" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="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>
|
||||
<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="p-3 bg-blue-500 rounded-full">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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 items-center gap-4">
|
||||
<span className="text-sm text-gray-600">选择大模型:</span>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((model) => (
|
||||
<SelectItem key={model.value} value={model.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
<span>{model.label}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{model.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题和搜索 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">概览</h1>
|
||||
<p className="text-gray-600">数据资产中台</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索输入框 */}
|
||||
<div className="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"
|
||||
/>
|
||||
<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
|
||||
onClick={handleAIChat}
|
||||
disabled={isLoading}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 px-6 rounded-md"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-2 bg-transparent"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isLoading ? "思考中..." : "提问"}
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</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 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>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 text-gray-800 px-4 py-2 rounded-lg">
|
||||
<p className="text-sm">AI正在思考...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 功能图表展示 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => router.push("/user-pool")}>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-4 bg-purple-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">用户池</h3>
|
||||
<p className="text-sm text-gray-500">用户分群与标签管理</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Badge variant="secondary">
|
||||
<Activity className="w-3 h-3 mr-1" />
|
||||
实时更新
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 系统状态 */}
|
||||
<Card className="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" />
|
||||
</div>
|
||||
实时状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">响应时间</span>
|
||||
<Badge variant="secondary">{systemStats.avgResponseTime}ms</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">数据源</span>
|
||||
<Badge variant="secondary">{systemStats.versionCount}个</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">缓存大小</span>
|
||||
<Badge variant="secondary">{systemStats.cacheSize}MB</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 pt-2 border-t">最后更新: {lastUpdate.toLocaleTimeString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 数据增长趋势 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<Card className="border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500 rounded-lg">
|
||||
<TrendingUp className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
用户资产增长
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{growthData.map((data, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">{data.period}</div>
|
||||
<div className="text-sm text-gray-500">活跃用户: {formatNumber(data.activeUsers)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-purple-600">+{data.userGrowth.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">增长率</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-orange-200 bg-gradient-to-r from-orange-50 to-yellow-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500 rounded-lg">
|
||||
<Database className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
数据增长情况
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">流量关键词</div>
|
||||
<div className="text-sm text-gray-500">总计 {formatNumber(systemStats.keywordCount)} 个</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">+{growthData[0].dataGrowth.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">今日增长</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">数据处理量</div>
|
||||
<div className="text-sm text-gray-500">实时处理中</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{formatNumber(Math.floor(systemStats.userCount * 0.001))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">条/秒</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold">存储容量</div>
|
||||
<div className="text-sm text-gray-500">云端存储</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{Math.floor(systemStats.userCount / 1000000)}TB
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">已使用</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 快速访问入口 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-blue-100 hover:border-blue-300"
|
||||
onClick={() => router.push("/data-platform")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-4 bg-blue-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<div className="p-3 bg-blue-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
</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>
|
||||
<h3 className="font-semibold text-lg mb-2">数据中台</h3>
|
||||
<p className="text-sm text-gray-600">数据源管理与AI模型</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-green-100 hover:border-green-300"
|
||||
onClick={() => router.push("/user-portrait")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-green-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Users className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">用户画像</h3>
|
||||
<p className="text-sm text-gray-600">用户管理与标签体系</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-purple-100 hover:border-purple-300"
|
||||
onClick={() => router.push("/ai-assistant")}
|
||||
>
|
||||
<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 className="p-3 bg-purple-100 rounded-full w-fit mx-auto mb-3">
|
||||
<BarChart3 className="w-8 h-8 text-purple-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>
|
||||
<h3 className="font-semibold text-lg mb-2">AI智能助手</h3>
|
||||
<p className="text-sm text-gray-600">数据分析与报告生成</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-all duration-200 border-2 border-orange-100 hover:border-orange-300"
|
||||
onClick={() => router.push("/intelligent-search")}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="p-3 bg-orange-100 rounded-full w-fit mx-auto mb-3">
|
||||
<Globe className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">智能搜索</h3>
|
||||
<p className="text-sm text-gray-600">全局搜索与AI分析</p>
|
||||
</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>
|
||||
|
||||
{/* 搜索 + 条件过滤 + 列表 */}
|
||||
<UserSearch query={searchQuery} />
|
||||
</div>
|
||||
{/* PC端隐藏底部导航栏 */}
|
||||
<div className="block md:hidden">
|
||||
<BottomTabs />
|
||||
</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,311 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
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"
|
||||
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"
|
||||
|
||||
type UserDetail = {
|
||||
type Detail = {
|
||||
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 router = useRouter()
|
||||
const [data, setData] = useState<UserDetail | null>(null)
|
||||
const [data, setData] = useState<Detail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!params?.id) return
|
||||
setLoading(true)
|
||||
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)
|
||||
fetch(`/api/users?id=${params.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((res) => setData(res?.data ?? null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [params?.id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-4">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-12 bg-slate-200 rounded"></div>
|
||||
<div className="h-32 bg-slate-200 rounded"></div>
|
||||
<div className="h-48 bg-slate-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-4 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-slate-600 mb-4">未找到该用户</p>
|
||||
<Button onClick={() => router.back()}>返回</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-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="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户详情" />
|
||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-muted-foreground">
|
||||
加载中…
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<ProfileHeader
|
||||
name={data.name}
|
||||
avatar={data.avatar}
|
||||
email={data.email}
|
||||
phone={data.phone}
|
||||
tags={data.tags}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="container mx-auto px-4 py-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
基本信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{data.name}</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="h-3 w-3" />
|
||||
{data.phone}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
{data.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{data.city} · {data.store}
|
||||
</span>
|
||||
<span>
|
||||
{data.project} · {data.team}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={data.status === "活跃" ? "default" : "secondary"}>{data.status}</Badge>
|
||||
</div>
|
||||
<Section title="RFM 指标">
|
||||
<MetricsRFM
|
||||
recency={data.recency}
|
||||
frequency={data.frequency}
|
||||
monetary={data.monetary}
|
||||
rfmScore={data.rfmScore}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.persona.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Section title="互动记录">
|
||||
<InteractionsList items={data.interactions} />
|
||||
</Section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
用户资产评估
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">¥{data.assetValue.toLocaleString()}</div>
|
||||
<div className="text-xs text-slate-600">总资产价值</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{data.rfmScore}</div>
|
||||
<div className="text-xs text-slate-600">RFM评分</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-2xl font-bold ${data.riskLevel === "低" ? "text-green-600" : data.riskLevel === "中" ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{data.riskLevel}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">风险等级</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{data.source}</div>
|
||||
<div className="text-xs text-slate-600">获客来源</div>
|
||||
</div>
|
||||
</div>
|
||||
<Section title="购买历史">
|
||||
<PurchaseHistory items={data.purchaseHistory} />
|
||||
</Section>
|
||||
|
||||
<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>
|
||||
<Section title="绑定微信账号">
|
||||
<WechatAccounts accounts={data.wechatAccounts.map((w) => ({ id: w.id, nickname: w.nickname, avatar: w.avatar }))} />
|
||||
</Section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-white/60 backdrop-blur p-6 text-sm text-red-600">
|
||||
未找到该用户
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,303 +1,239 @@
|
||||
"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 Link from "next/link"
|
||||
import MobileHeader from "@/app/components/MobileHeader"
|
||||
import BottomNav from "@/app/components/BottomNav"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { 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"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Search, Filter, Plus } from 'lucide-react'
|
||||
import FilterDrawer, { type FilterValues } from "@/components/user-portrait/filter-drawer"
|
||||
|
||||
type ListItem = {
|
||||
type User = {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
tags: string[]
|
||||
rfmScore: number
|
||||
lastActiveAt: string
|
||||
city: string
|
||||
store: string
|
||||
project: string
|
||||
team: string
|
||||
persona: string[]
|
||||
source: string
|
||||
lastActivity: string
|
||||
status: "活跃" | "沉睡" | "已封禁"
|
||||
}
|
||||
|
||||
type Meta = {
|
||||
tags: string[]
|
||||
cities: string[]
|
||||
personas: string[]
|
||||
sources: string[]
|
||||
}
|
||||
type UsersResponse = { success: true; data: { items: User[]; total: number; page: number; pageSize: number } }
|
||||
|
||||
export default function UserPoolPage() {
|
||||
const router = useRouter()
|
||||
const [q, setQ] = useState("")
|
||||
const [rfmRange, setRfmRange] = useState<[number, number]>([0, 100])
|
||||
const [items, setItems] = useState<ListItem[]>([])
|
||||
const [meta, setMeta] = useState<Meta>({ tags: [], cities: [], personas: [], sources: [] })
|
||||
const [filters, setFilters] = useState({
|
||||
tags: [] as string[],
|
||||
status: [] as string[],
|
||||
city: [] as string[],
|
||||
persona: [] as string[],
|
||||
source: [] as string[],
|
||||
})
|
||||
const [totalValue, setTotalValue] = useState(0)
|
||||
export default function UserPortraitPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users?meta=tags")
|
||||
.then((r) => r.json())
|
||||
.then((res) => setMeta(res.data))
|
||||
}, [])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isAddingUser, setIsAddingUser] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ name: "", phone: "", email: "", tags: [] as string[] })
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [allTags, setAllTags] = useState<string[]>([])
|
||||
const [filters, setFilters] = useState<FilterValues>({ tags: [], status: [], rfm: [0, 100] })
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
const p = new URLSearchParams()
|
||||
if (q) p.set("q", q)
|
||||
if (searchQuery) p.set("q", searchQuery)
|
||||
if (filters.tags.length) p.set("tags", filters.tags.join(","))
|
||||
if (filters.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("rfmMin", String(filters.rfm[0]))
|
||||
p.set("rfmMax", String(filters.rfm[1]))
|
||||
p.set("page", "1")
|
||||
p.set("pageSize", "50")
|
||||
return p.toString()
|
||||
}, [q, filters, rfmRange])
|
||||
}, [searchQuery, filters])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res) => {
|
||||
setItems(res.data.items)
|
||||
setTotalValue(res.data.totalValue)
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [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],
|
||||
}))
|
||||
useEffect(() => {
|
||||
fetch("/api/users?meta=tags")
|
||||
.then((r) => r.json())
|
||||
.then((res: any) => setAllTags(res?.data?.tags ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleUserClick = (userId: string) => {
|
||||
router.push(`/user-portrait/${userId}`)
|
||||
const handleAddUser = async () => {
|
||||
const resp = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newUser),
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (data?.success) {
|
||||
setIsAddingUser(false)
|
||||
setNewUser({ name: "", phone: "", email: "", tags: [] })
|
||||
// 触发刷新
|
||||
fetch(`/api/users?${queryString}`)
|
||||
.then((r) => r.json())
|
||||
.then((res: UsersResponse) => {
|
||||
if (res?.success) {
|
||||
setUsers(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-5 space-y-4">
|
||||
{/* 核心板块:用户估值概览 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
用户池 · 资产评估为核心
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">通过 RFM 和画像维度评估用户价值与分布,支持用户导入导出</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<Stat label="当前筛选用户数" value={`${items.length} 人`} />
|
||||
<Stat label="估值总额(≈)" value={`¥ ${totalValue.toLocaleString("zh-CN")}`} />
|
||||
<Stat label="RFM 范围" value={`${rfmRange[0]} - ${rfmRange[1]}`} />
|
||||
<Stat label="已选标签数" value={`${filters.tags.length}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-purple-50">
|
||||
<MobileHeader onMenuToggle={() => {}} title="用户画像" />
|
||||
|
||||
{/* 筛选区:清晰多维度 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-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)}
|
||||
/>
|
||||
<main className="container mx-auto px-4 pb-24 space-y-4">
|
||||
<div className="rounded-2xl bg-white/60 backdrop-blur-md p-4 shadow-sm border">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">用户画像</h1>
|
||||
<p className="text-sm text-muted-foreground">管理与分群</p>
|
||||
</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 className="text-sm text-muted-foreground">共 {total} 人</div>
|
||||
</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 className="mt-4">
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="users" className="data-[state=active]:bg-white">用户管理</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-white">标签管理</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input className="pl-8" placeholder="搜索用户…" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setFilterOpen(true)}>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
筛选
|
||||
</Button>
|
||||
<Button onClick={() => setIsAddingUser(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{u.persona.map((p) => (
|
||||
<Badge key={p} variant="secondary" className="text-[10px]">
|
||||
{p}
|
||||
</Badge>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{users.map((u) => (
|
||||
<Card key={u.id} className="border bg-white/70 backdrop-blur-md shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-12 gap-3 items-center">
|
||||
<div className="col-span-5">
|
||||
<Link href={`/user-portrait/${u.id}`} className="font-medium hover:underline">
|
||||
{u.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
最后活跃 {new Date(u.lastActivity).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="text-sm">{u.phone}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.tags.slice(0, 2).map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-xs">{t}</Badge>
|
||||
))}
|
||||
{u.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{u.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-sm font-semibold">{u.rfmScore}</div>
|
||||
<div className="col-span-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
u.status === "活跃" ? "bg-green-100 text-green-700" :
|
||||
u.status === "沉睡" ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{u.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<div className="text-sm text-muted-foreground py-6 text-center">标签管理将在接入数据字典后提供配置与统计</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
|
||||
{/* 筛选抽屉 */}
|
||||
<FilterDrawer
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
allTags={allTags}
|
||||
value={filters}
|
||||
onApply={(v) => setFilters(v)}
|
||||
/>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={isAddingUser} onOpenChange={setIsAddingUser}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>添加新用户</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input id="name" value={newUser.name} onChange={(e) => setNewUser((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input id="phone" value={newUser.phone} onChange={(e) => setNewUser((p) => ({ ...p, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" type="email" value={newUser.email} onChange={(e) => setNewUser((p) => ({ ...p, email: e.target.value }))} />
|
||||
</div>
|
||||
{!!allTags.length && (
|
||||
<div className="space-y-2">
|
||||
<Label>用户标签</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-auto">
|
||||
{allTags.map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={newUser.tags.includes(t)}
|
||||
onCheckedChange={(ck) =>
|
||||
setNewUser((p) => ({ ...p, tags: ck ? [...p.tags, t] : p.tags.filter((x) => x !== t) }))
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</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 className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddingUser(false)}>取消</Button>
|
||||
<Button onClick={handleAddUser}>添加用户</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { CardDescription } from "@/components/ui/card"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
import { 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"
|
||||
@@ -30,18 +27,6 @@ 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 = [
|
||||
{
|
||||
@@ -177,64 +162,6 @@ 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) =>
|
||||
@@ -298,20 +225,6 @@ 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}
|
||||
@@ -956,23 +869,3 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FA] pb-20">
|
||||
<header className="sticky top-0 z-10 bg-white">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<div className="w-6 h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="ml-2 w-32 h-5 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-8">
|
||||
{/* Step Indicator Skeleton */}
|
||||
<div className="flex justify-center items-center space-x-4 px-6">
|
||||
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
|
||||
<div className="w-16 h-1 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
|
||||
<div className="w-16 h-1 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<div className="mt-8 px-6">
|
||||
<div className="bg-white rounded-lg p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="w-20 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-full h-10 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="w-32 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="flex space-x-2">
|
||||
<div className="w-24 h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-8 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-24 h-10 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="w-24 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-8 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-16 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-10 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation Skeleton */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 h-16 bg-white border-t flex items-center justify-around px-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col items-center space-y-1">
|
||||
<div className="w-6 h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="w-12 h-3 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,6 @@ 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")
|
||||
@@ -624,19 +616,15 @@ export function ApiDocumentation() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-transparent"
|
||||
onClick={() => openInNewTab("/api/openapi?download=1")}
|
||||
>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<FileJson className="h-4 w-4" />
|
||||
下载OpenAPI规范
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2 bg-transparent" onClick={() => openInNewTab("/api/openapi")}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
下载SDK
|
||||
</Button>
|
||||
<Button className="gap-2" onClick={() => openInNewTab("/api/ingest")}>
|
||||
<Button className="gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
API测试工具
|
||||
</Button>
|
||||
|
||||
@@ -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,38 +12,9 @@ type Row = {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
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 }
|
||||
type ApiResp = {
|
||||
data: Row[]
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number }
|
||||
}
|
||||
|
||||
export default function UserList({ queryString }: { queryString: string }) {
|
||||
@@ -55,26 +26,23 @@ 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 = await res.json()
|
||||
const { items, total } = normalizeResponse(json)
|
||||
const json: ApiResp = await res.json()
|
||||
if (!aborted) {
|
||||
setData(Array.isArray(items) ? items : [])
|
||||
setTotal(typeof total === "number" ? total : 0)
|
||||
setData(json.data || [])
|
||||
setTotal(json.pagination?.total || 0)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!aborted) setError(e?.message || "未知错误")
|
||||
if (!aborted) setError(e?.message || '未知错误')
|
||||
} finally {
|
||||
if (!aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
aborted = true
|
||||
@@ -82,7 +50,7 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
}
|
||||
}, [queryString])
|
||||
|
||||
const rows = useMemo(() => (Array.isArray(data) ? data : []), [data])
|
||||
const rows = useMemo(() => data, [data])
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border bg-white p-4">加载中...</div>
|
||||
@@ -119,22 +87,16 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
<td className="px-3 py-2">
|
||||
<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>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Home, Database, Users, TrendingUp, Bot } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
export default function BottomTabs() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const tabs = [
|
||||
{ href: "/", label: "首页", icon: Home },
|
||||
{ href: "/data-platform", label: "数据中台", icon: Database },
|
||||
{ href: "/user-portrait", label: "用户池", icon: Users }, // 改名为用户池
|
||||
{ href: "/user-assets", label: "用户资产", icon: TrendingUp },
|
||||
{ href: "/ai-assistant", label: "AI助手", icon: Bot },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-40 border-t bg-white/90 backdrop-blur">
|
||||
<ul className="mx-auto grid max-w-full grid-cols-5">
|
||||
{tabs.map((t) => {
|
||||
const active = pathname === t.href || (t.href !== "/" && pathname.startsWith(t.href))
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<li key={t.href}>
|
||||
<Link
|
||||
href={t.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-2 text-xs",
|
||||
active ? "text-slate-900" : "text-slate-600",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="mt-0.5 text-[10px]">{t.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useMemo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function TopNav({ activePath }: { activePath?: string }) {
|
||||
const items = useMemo(
|
||||
() => [
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/data-platform", label: "数据中台" },
|
||||
{ href: "/user-portrait", label: "用户画像" },
|
||||
{ href: "/ai-assistant", label: "AI 助手" },
|
||||
{ href: "/user-valuation", label: "用户资产估值" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<nav className="flex h-14 items-center gap-2">
|
||||
<div className="mr-4 text-lg font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||
卡若数据资产中台
|
||||
</div>
|
||||
<ul className="flex items-center gap-1">
|
||||
{items.map((it) => {
|
||||
const active = activePath === it.href || (it.href !== "/" && activePath?.startsWith(it.href))
|
||||
return (
|
||||
<li key={it.href}>
|
||||
<Link
|
||||
href={it.href}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md text-sm hover:bg-slate-100",
|
||||
active && "bg-slate-900 text-white hover:bg-slate-900",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
import * as React from 'react'
|
||||
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
@@ -15,8 +15,8 @@ const Command = React.forwardRef<
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -44,8 +44,8 @@ const CommandInput = React.forwardRef<
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -60,7 +60,7 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -87,8 +87,8 @@ const CommandGroup = React.forwardRef<
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -102,7 +102,7 @@ const CommandSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -131,14 +131,14 @@ const CommandShortcut = ({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { useEffect, useState } from "react"
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export type FilterValues = {
|
||||
tags: string[]
|
||||
status: string[]
|
||||
status: Array<"活跃" | "沉睡" | "已封禁">
|
||||
rfm: [number, number]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
allTags: string[]
|
||||
@@ -21,122 +21,131 @@ type Props = {
|
||||
onApply: (v: FilterValues) => void
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["活跃", "沉睡", "已封禁"] as const
|
||||
|
||||
export default function FilterDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
allTags,
|
||||
value,
|
||||
onApply,
|
||||
}: Props) {
|
||||
export default function FilterDrawer({ open, onOpenChange, allTags, value, onApply }: Props) {
|
||||
const [local, setLocal] = useState<FilterValues>(value)
|
||||
|
||||
// 同步外部变更
|
||||
useMemo(() => setLocal(value), [value])
|
||||
useEffect(() => setLocal(value), [value, open])
|
||||
|
||||
const toggleArrayVal = (arr: string[], val: string, checked: boolean) =>
|
||||
checked ? Array.from(new Set([...arr, val])) : arr.filter((x) => x !== val)
|
||||
const toggleTag = (t: string, checked: boolean) => {
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
tags: checked ? Array.from(new Set([...prev.tags, t])) : prev.tags.filter((x) => x !== t),
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleStatus = (s: "活跃" | "沉睡" | "已封禁", checked: boolean) => {
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
status: checked ? Array.from(new Set([...prev.status, s])) : prev.status.filter((x) => x !== s),
|
||||
}))
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
onApply(local)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
const init: FilterValues = { tags: [], status: [], rfm: [0, 100] }
|
||||
setLocal(init)
|
||||
onApply(init)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>筛选</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={`fixed inset-0 z-50 ${open ? "" : "pointer-events-none"} aria-modal`}
|
||||
role="dialog"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<aside
|
||||
className={`absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl transition-transform duration-300
|
||||
${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
aria-label="筛选"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">筛选</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} aria-label="关闭筛选">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 py-2">
|
||||
<section className="space-y-2">
|
||||
<Label>用户状态</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{STATUS_OPTIONS.map((s) => {
|
||||
const checked = local.status.includes(s)
|
||||
return (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(ck) =>
|
||||
setLocal((p) => ({ ...p, status: toggleArrayVal(p.status, s, !!ck) }))
|
||||
}
|
||||
/>
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className="p-4 space-y-6 overflow-y-auto h-[calc(100%-120px)]">
|
||||
{/* RFM 区间 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">RFM 区间</h3>
|
||||
<div className="grid grid-cols-2 gap-2 items-center">
|
||||
<div>
|
||||
<Label htmlFor="rfmMin" className="text-xs">最小值</Label>
|
||||
<Input
|
||||
id="rfmMin"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[0]}
|
||||
onChange={(e) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value) || 0))
|
||||
setLocal((p) => ({ ...p, rfm: [Math.min(v, p.rfm[1]), p.rfm[1]] }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rfmMax" className="text-xs">最大值</Label>
|
||||
<Input
|
||||
id="rfmMax"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[1]}
|
||||
onChange={(e) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value) || 100))
|
||||
setLocal((p) => ({ ...p, rfm: [p.rfm[0], Math.max(v, p.rfm[0])] }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label>RFM 分数范围</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[0]}
|
||||
aria-label="RFM最小值"
|
||||
onChange={(e) =>
|
||||
setLocal((p) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value)))
|
||||
return { ...p, rfm: [v, Math.max(v, p.rfm[1])] }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-muted-foreground">{'—'}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[1]}
|
||||
aria-label="RFM最大值"
|
||||
onChange={(e) =>
|
||||
setLocal((p) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value)))
|
||||
return { ...p, rfm: [Math.min(p.rfm[0], v), v] }
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* 状态 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">状态</h3>
|
||||
<div className="grid gap-2">
|
||||
{(["活跃", "沉睡", "已封禁"] as const).map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={local.status.includes(s)} onCheckedChange={(ck) => toggleStatus(s, Boolean(ck))} />
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label>标签</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-auto pr-1">
|
||||
{allTags.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground col-span-2">暂无标签</div>
|
||||
)}
|
||||
{allTags.map((t) => {
|
||||
const checked = local.tags.includes(t)
|
||||
return (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(ck) =>
|
||||
setLocal((p) => ({ ...p, tags: toggleArrayVal(p.tags, t, !!ck) }))
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{/* 标签 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">标签</h3>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-56 overflow-auto">
|
||||
{allTags.map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={local.tags.includes(t)} onCheckedChange={(ck) => toggleTag(t, Boolean(ck))} />
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
))}
|
||||
{!allTags.length && <div className="text-xs text-muted-foreground col-span-2">暂无标签数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onApply(local)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
应用筛选
|
||||
</Button>
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex items-center justify-between gap-2">
|
||||
<Button variant="outline" onClick={reset}>重置</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={apply}>应用</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export type UserStatus = "活跃" | "沉睡" | "流失风险"
|
||||
export type UserStatus = '活跃' | '沉睡' | '流失风险'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
@@ -13,106 +13,40 @@ 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"
|
||||
}
|
||||
|
||||
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 statusPool: UserStatus[] = ["活跃", "沉睡", "流失风险"]
|
||||
const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗']
|
||||
const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰']
|
||||
const tagPool = ['高价值','近7日活跃','新客','回流','社群达人','潜在复购','高互动','低客单','私域粉','公众号粉']
|
||||
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",
|
||||
"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','184','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)
|
||||
@@ -121,96 +55,24 @@ function timeNearNow(daysSpan = 90) {
|
||||
|
||||
let cache: User[] | null = null
|
||||
|
||||
function seed(n = 240) {
|
||||
const familyNames = [
|
||||
"张",
|
||||
"李",
|
||||
"王",
|
||||
"赵",
|
||||
"刘",
|
||||
"陈",
|
||||
"杨",
|
||||
"黄",
|
||||
"周",
|
||||
"吴",
|
||||
"徐",
|
||||
"孙",
|
||||
"胡",
|
||||
"朱",
|
||||
"高",
|
||||
"林",
|
||||
"何",
|
||||
"郭",
|
||||
"马",
|
||||
"罗",
|
||||
]
|
||||
const givenNames = [
|
||||
"伟",
|
||||
"芳",
|
||||
"娜",
|
||||
"敏",
|
||||
"静",
|
||||
"秀英",
|
||||
"丽",
|
||||
"强",
|
||||
"磊",
|
||||
"军",
|
||||
"洋",
|
||||
"艳",
|
||||
"勇",
|
||||
"杰",
|
||||
"娟",
|
||||
"涛",
|
||||
"明",
|
||||
"超",
|
||||
"霞",
|
||||
"平",
|
||||
"俊",
|
||||
"凯",
|
||||
"佳",
|
||||
"鑫",
|
||||
"鹏",
|
||||
"晨",
|
||||
"倩",
|
||||
"颖",
|
||||
"梅",
|
||||
"慧",
|
||||
"雪",
|
||||
"宇",
|
||||
"涵",
|
||||
"宁",
|
||||
"璐",
|
||||
"龙",
|
||||
"震",
|
||||
"航",
|
||||
"璟",
|
||||
"钰",
|
||||
]
|
||||
function seed(n = 120) {
|
||||
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 u: User = {
|
||||
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({
|
||||
id: randomUUID(),
|
||||
name,
|
||||
email,
|
||||
phone: randomPhone(),
|
||||
phone,
|
||||
avatar: avatars[i % avatars.length],
|
||||
tags: randomTags(),
|
||||
status: pick(statusPool),
|
||||
rfmScore: rand(20, 95),
|
||||
rfmScore: rand(15, 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
|
||||
}
|
||||
@@ -226,68 +88,76 @@ 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, city, persona, source, page = 1, pageSize = 20 } = params
|
||||
const { q, tags, status, rfmMin = 0, rfmMax = 100, page = 1, pageSize = 20 } = params
|
||||
let list = getUsersStore()
|
||||
|
||||
if (q && q.trim()) {
|
||||
const s = q.trim().toLowerCase()
|
||||
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.persona.some((p) => p.toLowerCase().includes(s)),
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
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)
|
||||
|
||||
const total = list.length
|
||||
const start = (page - 1) * pageSize
|
||||
const data = list.slice(start, start + pageSize)
|
||||
const end = start + pageSize
|
||||
const data = list.slice(start, end)
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / 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)) } }
|
||||
}
|
||||
|
||||
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: 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,
|
||||
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),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.4.17",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
98
pnpm-lock.yaml
generated
98
pnpm-lock.yaml
generated
@@ -10,10 +10,10 @@ importers:
|
||||
dependencies:
|
||||
'@ai-sdk/openai':
|
||||
specifier: latest
|
||||
version: 2.0.17(zod@4.0.17)
|
||||
version: 2.0.32(zod@4.1.11)
|
||||
'@ant-design/plots':
|
||||
specifier: latest
|
||||
version: 2.6.3(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||
version: 2.6.5(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: latest
|
||||
version: 1.2.12(@types/react-dom@18.0.0)(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||
@@ -82,7 +82,7 @@ importers:
|
||||
version: 3.13.12(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||
ai:
|
||||
specifier: latest
|
||||
version: 5.0.19(zod@4.0.17)
|
||||
version: 5.0.49(zod@4.1.11)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -121,7 +121,7 @@ importers:
|
||||
version: 18.0.0
|
||||
react-day-picker:
|
||||
specifier: latest
|
||||
version: 9.9.0(react@18.0.0)
|
||||
version: 9.11.0(react@18.0.0)
|
||||
react-dom:
|
||||
specifier: ^18
|
||||
version: 18.0.0(react@18.0.0)
|
||||
@@ -130,7 +130,7 @@ importers:
|
||||
version: 9.16.0(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||
recharts:
|
||||
specifier: latest
|
||||
version: 3.1.2(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1)
|
||||
version: 3.2.1(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1)
|
||||
regenerator-runtime:
|
||||
specifier: latest
|
||||
version: 0.14.1
|
||||
@@ -142,7 +142,7 @@ importers:
|
||||
version: 1.0.7(tailwindcss@3.4.17)
|
||||
zod:
|
||||
specifier: latest
|
||||
version: 4.0.17
|
||||
version: 4.1.11
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
@@ -166,7 +166,7 @@ importers:
|
||||
specifier: ^8.5
|
||||
version: 8.5.0
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
specifier: ^3.3.0
|
||||
version: 3.4.17
|
||||
typescript:
|
||||
specifier: ^5
|
||||
@@ -174,20 +174,20 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@ai-sdk/gateway@1.0.9':
|
||||
resolution: {integrity: sha512-kIfwunyUUwyBLg2KQcaRtjRQ1bDuJYPNIs4CNWaWPpMZ4SV5cRL1hLGMuX4bhfCJYDXHMGvJGLtUK6+iAJH2ZQ==}
|
||||
'@ai-sdk/gateway@1.0.26':
|
||||
resolution: {integrity: sha512-AfTkubvvHU+soI5IdIpPvXgdnNy56Kt//vBJxYNQ0eGwlVhSQ/SkCVMdQxcVDvdTvlEO46MHKuPaZnQnT5Zgxw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
'@ai-sdk/openai@2.0.17':
|
||||
resolution: {integrity: sha512-nt0Dvn3etQJwzJtS6XEUchZkDb3NAjn8yTmLZj1fF+F2pyUbiwKg4joW9kjsrDhcwOxdoQ26OyONsVLE9AWfMw==}
|
||||
'@ai-sdk/openai@2.0.32':
|
||||
resolution: {integrity: sha512-p7giSkCs66Q1qYO/NPYI41CrSg65mcm8R2uAdF86+Y1D1/q4mUrWMyf5UTOJ0bx/z4jIPiNgGDCg2Kabi5zrKQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.4':
|
||||
resolution: {integrity: sha512-/3Z6lfUp8r+ewFd9yzHkCmPlMOJUXup2Sx3aoUyrdXLhOmAfHRl6Z4lDbIdV0uvw/QYoBcVLJnvXN7ncYeS3uQ==}
|
||||
'@ai-sdk/provider-utils@3.0.9':
|
||||
resolution: {integrity: sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
@@ -206,8 +206,8 @@ packages:
|
||||
react: '>=16.8.4'
|
||||
react-dom: '>=16.8.4'
|
||||
|
||||
'@ant-design/plots@2.6.3':
|
||||
resolution: {integrity: sha512-9V6QND7QESzAlPu5bE56xTlUi2ctfDkdRiNY8SgG5/IW8InqW9SFfhO0sW5mIpTslsI8o/MgmysnSWED8h6adg==}
|
||||
'@ant-design/plots@2.6.5':
|
||||
resolution: {integrity: sha512-lzYMQdb5TWWfi/RnHpuJ9Iu3/WR8OJj2lYcyBlBOkCghOLstNw6sYSbGMGDIzPKp1AS1Fvomw5KwZzD/mdiykA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.4'
|
||||
react-dom: '>=16.8.4'
|
||||
@@ -1230,8 +1230,8 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
ai@5.0.19:
|
||||
resolution: {integrity: sha512-I0yQO68Z1DdvjMc9be5scMYdsuBvpNneQDh6TUuUVlFXdCxAIm7yATnOaomfYTE2KaRDsrO+OSRPjJm4FSKioA==}
|
||||
ai@5.0.49:
|
||||
resolution: {integrity: sha512-7XVcmXbnAqG7waJqNcxKrzVW1Ck5fw4KhWxAyltKxnupOgFxH62ra1zEofym/KO3hPYq4aJ3/gTp1ZeLvlwLkQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
@@ -1821,9 +1821,9 @@ packages:
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
eventsource-parser@3.0.3:
|
||||
resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
eventsource-parser@3.0.6:
|
||||
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
@@ -2515,8 +2515,8 @@ packages:
|
||||
rbush@3.0.1:
|
||||
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
|
||||
|
||||
react-day-picker@9.9.0:
|
||||
resolution: {integrity: sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==}
|
||||
react-day-picker@9.11.0:
|
||||
resolution: {integrity: sha512-L4FYOaPrr3+AEROeP6IG2mCORZZfxJDkJI2df8mv1jyPrNYeccgmFPZDaHyAuPCBCddQFozkxbikj2NhMEYfDQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
@@ -2594,8 +2594,8 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
recharts@3.1.2:
|
||||
resolution: {integrity: sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g==}
|
||||
recharts@3.2.1:
|
||||
resolution: {integrity: sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -3018,35 +3018,29 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
zod-to-json-schema@3.24.6:
|
||||
resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.1
|
||||
|
||||
zod@4.0.17:
|
||||
resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==}
|
||||
zod@4.1.11:
|
||||
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ai-sdk/gateway@1.0.9(zod@4.0.17)':
|
||||
'@ai-sdk/gateway@1.0.26(zod@4.1.11)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.4(zod@4.0.17)
|
||||
zod: 4.0.17
|
||||
'@ai-sdk/provider-utils': 3.0.9(zod@4.1.11)
|
||||
zod: 4.1.11
|
||||
|
||||
'@ai-sdk/openai@2.0.17(zod@4.0.17)':
|
||||
'@ai-sdk/openai@2.0.32(zod@4.1.11)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.4(zod@4.0.17)
|
||||
zod: 4.0.17
|
||||
'@ai-sdk/provider-utils': 3.0.9(zod@4.1.11)
|
||||
zod: 4.1.11
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.4(zod@4.0.17)':
|
||||
'@ai-sdk/provider-utils@3.0.9(zod@4.1.11)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@standard-schema/spec': 1.0.0
|
||||
eventsource-parser: 3.0.3
|
||||
zod: 4.0.17
|
||||
zod-to-json-schema: 3.24.6(zod@4.0.17)
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 4.1.11
|
||||
|
||||
'@ai-sdk/provider@2.0.0':
|
||||
dependencies:
|
||||
@@ -3060,7 +3054,7 @@ snapshots:
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
|
||||
'@ant-design/plots@2.6.3(react-dom@18.0.0(react@18.0.0))(react@18.0.0)':
|
||||
'@ant-design/plots@2.6.5(react-dom@18.0.0(react@18.0.0))(react@18.0.0)':
|
||||
dependencies:
|
||||
'@ant-design/charts-util': 0.0.2(react-dom@18.0.0(react@18.0.0))(react@18.0.0)
|
||||
'@antv/event-emitter': 0.1.3
|
||||
@@ -4217,13 +4211,13 @@ snapshots:
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
ai@5.0.19(zod@4.0.17):
|
||||
ai@5.0.49(zod@4.1.11):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 1.0.9(zod@4.0.17)
|
||||
'@ai-sdk/gateway': 1.0.26(zod@4.1.11)
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.4(zod@4.0.17)
|
||||
'@ai-sdk/provider-utils': 3.0.9(zod@4.1.11)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 4.0.17
|
||||
zod: 4.1.11
|
||||
|
||||
ajv@6.12.6:
|
||||
dependencies:
|
||||
@@ -4971,7 +4965,7 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
eventsource-parser@3.0.3: {}
|
||||
eventsource-parser@3.0.6: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
@@ -5657,7 +5651,7 @@ snapshots:
|
||||
dependencies:
|
||||
quickselect: 2.0.0
|
||||
|
||||
react-day-picker@9.9.0(react@18.0.0):
|
||||
react-day-picker@9.11.0(react@18.0.0):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.4.1
|
||||
date-fns: 4.1.0
|
||||
@@ -5736,7 +5730,7 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
recharts@3.1.2(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1):
|
||||
recharts@3.2.1(@types/react@18.0.0)(react-dom@18.0.0(react@18.0.0))(react-is@16.13.1)(react@18.0.0)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.8.2(react-redux@9.2.0(@types/react@18.0.0)(react@18.0.0)(redux@5.0.1))(react@18.0.0)
|
||||
clsx: 2.1.1
|
||||
@@ -6291,8 +6285,4 @@ snapshots:
|
||||
|
||||
yaml@2.8.1: {}
|
||||
|
||||
zod-to-json-schema@3.24.6(zod@4.0.17):
|
||||
dependencies:
|
||||
zod: 4.0.17
|
||||
|
||||
zod@4.0.17: {}
|
||||
zod@4.1.11: {}
|
||||
|
||||
@@ -3,6 +3,6 @@ const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
// services/IdentityService.ts
|
||||
// 统一身份识别服务(内存索引),用于 /api/ingest 流程中匹配或创建用户。
|
||||
// 与 v1.4 文档的统一用户结构保持一致(source_profiles / ai_insights / crm_info)[^5]
|
||||
// 身份识别与合并服务
|
||||
// 实现基于数据字典标识的关键字段的匹配逻辑
|
||||
|
||||
import { getIdentityKeyFields } from "@/lib/data-dictionary"
|
||||
|
||||
export interface IdentityMatch {
|
||||
userId: string
|
||||
confidence: number
|
||||
reasons: string[]
|
||||
matchedFields: string[]
|
||||
matchType: "exact" | "fuzzy" | "partial"
|
||||
}
|
||||
|
||||
type IndexMaps = {
|
||||
emailToUser: Map<string, string>
|
||||
phoneToUser: Map<string, string>
|
||||
usernameToUser: Map<string, string>
|
||||
userStore: Map<string, Record<string, any>>
|
||||
export interface UserIdentity {
|
||||
userId: string
|
||||
identityFields: Record<string, any>
|
||||
}
|
||||
|
||||
export class IdentityService {
|
||||
private static instance: IdentityService
|
||||
private index: IndexMaps
|
||||
private userIdentities: Map<string, UserIdentity> = new Map()
|
||||
|
||||
private constructor() {
|
||||
this.index = {
|
||||
emailToUser: new Map(),
|
||||
phoneToUser: new Map(),
|
||||
usernameToUser: new Map(),
|
||||
userStore: new Map(),
|
||||
}
|
||||
// 初始化一些模拟数据
|
||||
this.initializeMockData()
|
||||
}
|
||||
|
||||
public static getInstance(): IdentityService {
|
||||
@@ -35,84 +31,202 @@ export class IdentityService {
|
||||
return IdentityService.instance
|
||||
}
|
||||
|
||||
// 基于映射数据寻找可能的用户匹配
|
||||
public async findMatchingIdentity(mappedData: Record<string, any>): Promise<IdentityMatch[]> {
|
||||
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()
|
||||
const matches: IdentityMatch[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
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] })
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// 简单加权:若同一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 {
|
||||
exists.confidence = Math.min(0.99, exists.confidence + 0.05)
|
||||
exists.reasons = Array.from(new Set([...exists.reasons, ...m.reasons]))
|
||||
for (const [userId, userIdentity] of this.userIdentities) {
|
||||
const matchResult = this.calculateMatch(inputData, userIdentity.identityFields, identityKeyFields)
|
||||
if (matchResult.confidence > 0) {
|
||||
matches.push({
|
||||
userId,
|
||||
...matchResult,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(aggregated.values()).sort((a, b) => b.confidence - a.confidence)
|
||||
// 按置信度排序
|
||||
return matches.sort((a, b) => b.confidence - a.confidence)
|
||||
}
|
||||
|
||||
// 创建新身份
|
||||
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()}`
|
||||
// 计算匹配度
|
||||
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]
|
||||
} else {
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length][str1.length]
|
||||
}
|
||||
|
||||
// 创建新的用户身份
|
||||
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,
|
||||
})
|
||||
|
||||
const userId = `user_${this.shortHash(base)}`
|
||||
this.index.userStore.set(userId, { userId, ...mappedData })
|
||||
this.bindIndexes(userId, mappedData)
|
||||
return userId
|
||||
}
|
||||
|
||||
// 更新身份(同步索引)
|
||||
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)
|
||||
}
|
||||
|
||||
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 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]
|
||||
}
|
||||
})
|
||||
this.userIdentities.set(userId, existingIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
private shortHash(str: string): string {
|
||||
// 简单、稳定的字符串哈希(djb2 变体),避免依赖
|
||||
let h = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h * 33) ^ str.charCodeAt(i)
|
||||
// 合并用户身份
|
||||
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)
|
||||
}
|
||||
// 转为正数并截断
|
||||
return (h >>> 0).toString(36)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// 订单表单数据相关类型定义
|
||||
export interface OrderFormData {
|
||||
// 基本信息
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
customerEmail?: string
|
||||
|
||||
// 产品信息
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
totalAmount: number
|
||||
|
||||
// 订单详情
|
||||
orderType: "online" | "offline" | "phone"
|
||||
paymentMethod: "wechat" | "alipay" | "card" | "cash" | "transfer"
|
||||
deliveryMethod: "pickup" | "delivery" | "express"
|
||||
|
||||
// 地址信息(如需配送)
|
||||
deliveryAddress?: {
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
street: string
|
||||
zipCode?: string
|
||||
}
|
||||
|
||||
// 时间信息
|
||||
orderDate: string
|
||||
expectedDeliveryDate?: string
|
||||
|
||||
// 备注和标签
|
||||
notes?: string
|
||||
tags?: string[]
|
||||
|
||||
// 来源信息
|
||||
source: "website" | "app" | "phone" | "store" | "social" | "referral"
|
||||
campaign?: string
|
||||
referrer?: string
|
||||
|
||||
// 状态
|
||||
status: "pending" | "confirmed" | "processing" | "shipped" | "delivered" | "cancelled"
|
||||
|
||||
// 元数据
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface OrderStats {
|
||||
totalOrders: number
|
||||
totalRevenue: number
|
||||
avgOrderValue: number
|
||||
conversionRate: number
|
||||
topProducts: Array<{
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
revenue: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OrderFilter {
|
||||
dateRange?: {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
status?: OrderFormData["status"]
|
||||
paymentMethod?: OrderFormData["paymentMethod"]
|
||||
source?: OrderFormData["source"]
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
customerPhone?: string
|
||||
productId?: string
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
export type DeviceStatus = "online" | "offline" | "unknown" | "busy"
|
||||
|
||||
export type DeviceType = Device
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
name: string
|
||||
wechatId?: string
|
||||
group?: string
|
||||
tags?: string[]
|
||||
os: "Android" | "iOS" | "Windows" | "Mac"
|
||||
status: DeviceStatus
|
||||
owner?: string
|
||||
lastSeen?: string
|
||||
}
|
||||
|
||||
export interface CreateDeviceParams {
|
||||
name: string
|
||||
wechatId?: string
|
||||
group?: string
|
||||
tags?: string[]
|
||||
os: "Android" | "iOS" | "Windows" | "Mac"
|
||||
}
|
||||
|
||||
export interface UpdateDeviceParams extends Partial<CreateDeviceParams> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface QueryDeviceParams {
|
||||
keyword?: string
|
||||
tags?: string[]
|
||||
dateRange?: { start: string; end: string }
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface DeviceStats {
|
||||
id: string
|
||||
tasksToday: number
|
||||
uptimePercent: number
|
||||
}
|
||||
|
||||
export interface DeviceTaskRecord {
|
||||
id: string
|
||||
deviceId: string
|
||||
type: string
|
||||
status: "success" | "failed"
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T | null
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
export type ScenarioStatus = "draft" | "running" | "paused" | "completed" | "active" | "archived"
|
||||
export type Channel = "phone" | "wechat" | "douyin" | "xiaohongshu" | "api"
|
||||
|
||||
export interface ScenarioBase {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
status: ScenarioStatus
|
||||
creator: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id: string
|
||||
name: string
|
||||
channel: Channel
|
||||
createdAt: string
|
||||
status: ScenarioStatus
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface CreateScenarioParams {
|
||||
name: string
|
||||
type: string
|
||||
channel: Channel
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface UpdateScenarioParams extends Partial<CreateScenarioParams> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface QueryScenarioParams {
|
||||
type?: string
|
||||
status?: ScenarioStatus
|
||||
keyword?: string
|
||||
dateRange?: { start: string; end: string }
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface ScenarioStats {
|
||||
id: string
|
||||
impressions: number
|
||||
clicks: number
|
||||
conversions: number
|
||||
}
|
||||
|
||||
export interface AcquisitionRecord {
|
||||
id: string
|
||||
scenarioId: string
|
||||
userId: string
|
||||
time: string
|
||||
channel: Channel
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T | null
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// 流量用户相关类型定义
|
||||
export interface TrafficUser {
|
||||
id: string
|
||||
userId?: string
|
||||
sessionId: string
|
||||
ip: string
|
||||
userAgent: string
|
||||
device: {
|
||||
type: "mobile" | "tablet" | "desktop"
|
||||
brand?: string
|
||||
model?: string
|
||||
os?: string
|
||||
}
|
||||
location?: {
|
||||
country: string
|
||||
region: string
|
||||
city: string
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
}
|
||||
referrer?: string
|
||||
landingPage: string
|
||||
currentPage: string
|
||||
visitTime: string
|
||||
duration: number
|
||||
pageViews: number
|
||||
isNewUser: boolean
|
||||
source: "organic" | "direct" | "social" | "email" | "ads" | "referral"
|
||||
campaign?: string
|
||||
keywords?: string[]
|
||||
events: TrafficEvent[]
|
||||
}
|
||||
|
||||
export interface TrafficEvent {
|
||||
id: string
|
||||
type: "page_view" | "click" | "scroll" | "form_submit" | "download" | "custom"
|
||||
timestamp: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
export interface TrafficStats {
|
||||
totalUsers: number
|
||||
newUsers: number
|
||||
returningUsers: number
|
||||
pageViews: number
|
||||
avgDuration: number
|
||||
bounceRate: number
|
||||
}
|
||||
|
||||
export interface TrafficFilter {
|
||||
dateRange?: {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
source?: TrafficUser["source"]
|
||||
device?: TrafficUser["device"]["type"]
|
||||
location?: string
|
||||
isNewUser?: boolean
|
||||
}
|
||||
49
开发文档/开发文档.md
49
开发文档/开发文档.md
@@ -6,61 +6,18 @@
|
||||
- 新增 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/ingest(GET 状态)
|
||||
|
||||
- 变更文件:
|
||||
- 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;
|
||||
@@ -68,7 +25,3 @@
|
||||
1) 数据库对接(Neon/Supabase/MySQL)保留接口契约不变;
|
||||
2) 画像页接入上述接口的分页与高级筛选,补齐批量导出;
|
||||
3) 详情页接入 AI SDK 生成洞察与跟进建议(RFM联动)。
|
||||
- 接入真实数据源连接器(在 Server 侧按环境变量路由;未配齐时回退模拟)[与需求池“多源接入/数据治理”方向一致]^3[^5]
|
||||
- 输出 TypeScript SDK(由 OpenAPI 派生),并在文档页提供下载。
|
||||
- 统一布局头部(Header/MobileHeader)挂载至 layout,抽象搜索输入的全局状态。
|
||||
- 扩展 OpenAPI:覆盖 /api/users、用户画像查询等。
|
||||
|
||||
Reference in New Issue
Block a user