Files
users/app/page.tsx
卡若 7c72871a7a chore: 同步本地到 main 和 Gitea
Made-with: Cursor
2026-03-16 14:48:26 +08:00

552 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useEffect, useRef } from "react"
import {
Users,
Database,
RefreshCw,
Activity,
Brain,
Tags,
Package,
ArrowUpRight,
Zap,
Send,
Loader2,
Search,
Phone,
User,
TrendingUp,
Settings,
ChevronRight,
Sparkles,
Target,
} 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 { Input } from "@/components/ui/input"
import Link from "next/link"
interface ChatMessage {
role: "user" | "assistant" | "thinking"
content: string
timestamp: string
data?: any
thinking?: string
portrait?: UserPortrait
fullProfile?: { valuation?: any; qqPhone?: any; ckbAsset?: any }
aiAnalysis?: string
/** 多人列表(最多 10 条完整画像) */
profiles?: { fullProfile: any; aiAnalysis: string }[]
}
// 标准用户画像模板
interface UserPortrait {
phone?: string
qq?: string
name?: string
gender?: string
province?: string
city?: string
level?: string
rfmScore?: number
tags?: string[]
behavior?: {
lastActive?: string
frequency?: string
}
dataCompleteness?: number
}
interface AIStatus {
status: string
database?: {
connected: boolean
totalUsers: number
latency: number
}
}
export default function HomePage() {
// AI对话状态
const [query, setQuery] = useState("")
const [loading, setLoading] = useState(false)
const [aiStatus, setAiStatus] = useState<AIStatus | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: "assistant",
content: "你好我是神射手AI助手。\n\n支持 **手机、QQ、身份证、姓名** 统一搜索,曼谷库内数据完整展示:\n• 可搜一人或多人,用逗号分隔,最多列出 **前 10 条** 完整画像\n• 每条含手机、QQ、地址、统一标签、流量池、存客宝、AI 分析\n• 也可问系统状态、高价值用户、RFM 等",
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
}
])
const messagesEndRef = useRef<HTMLDivElement>(null)
// 统计数据
const [stats, setStats] = useState({
totalUsers: 0,
totalDataSources: 0,
latency: 0,
})
// 获取AI状态
useEffect(() => {
fetch("/api/ai-chat")
.then(res => res.json())
.then(data => {
setAiStatus(data)
if (data.database) {
setStats({
totalUsers: data.database.totalUsers || 0,
totalDataSources: 26,
latency: data.database.latency || 0,
})
}
})
.catch(console.error)
}, [])
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
// 解析用户画像(支持 fullProfile 与旧格式)
const parsePortrait = (data: any): UserPortrait | undefined => {
if (!data) return undefined
const v = data.fullProfile?.valuation || data.valuation || data
const qq = data.fullProfile?.qqPhone || data.qqPhone
return {
phone: v.phone_masked || v.phone || qq?.phone || data.,
qq: v.qq || qq?.qq || data.qq || data.QQ,
name: v.name || data.,
gender: v.gender || data.,
province: v.province || qq?. || data.,
city: v.city || qq?. || data.,
level: v.user_level || data.user_level || data.,
rfmScore: v.user_evaluation_score ?? data.rfm_score ?? data.user_evaluation_score,
tags: v.unified_tags || v.tags || data.tags || [],
behavior: {
lastActive: v.last_active || data.,
frequency: v.frequency || data.,
},
dataCompleteness: v.data_completeness || data.,
}
}
// 生成思考过程
const generateThinking = (query: string): string => {
const isPhone = /^1[3-9]\d{9}$/.test(query.replace(/\s/g, ''))
const isQQ = /^\d{5,11}$/.test(query.replace(/\s|qq/gi, ''))
if (isPhone) {
return `🔍 识别到手机号\n→ 意图: 完整用户画像\n→ 数据源: KR + KR_腾讯 + KR_存客宝\n→ 输出: 完整画像 + AI 分析`
}
if (isQQ) {
return `🔍 识别到 QQ 号\n→ 意图: 完整用户画像\n→ 数据源: KR_腾讯 → 手机号 → 跨库画像\n→ 输出: 完整画像 + AI 分析`
}
return `🔍 关键字搜索\n→ 意图: 任意关键词匹配\n→ 数据源: KR.用户估值(姓名/城市/省份)\n→ 输出: 第一条完整画像 + AI 分析`
}
// 发送消息
const handleSend = async () => {
if (!query.trim() || loading) return
const userMessage: ChatMessage = {
role: "user",
content: query,
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
}
// 添加思考过程消息
const thinkingMessage: ChatMessage = {
role: "thinking",
content: generateThinking(query),
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
}
setMessages(prev => [...prev, userMessage, thinkingMessage])
setQuery("")
setLoading(true)
try {
const response = await fetch("/api/ai-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: query })
})
const data = await response.json()
// 解析用户画像
const portrait = parsePortrait(data.response?.data)
setMessages(prev => {
const filtered = prev.filter(m => m.role !== "thinking")
const respData = data.response?.data
const profiles = respData?.profiles || respData?.list
const first = Array.isArray(profiles)?.[0]
return [...filtered, {
role: "assistant",
content: data.success ? data.response.content : `查询失败: ${data.error || "未知错误"}`,
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
data: respData,
portrait: portrait || (first ? parsePortrait(first) : undefined),
fullProfile: respData?.fullProfile || first?.fullProfile,
aiAnalysis: respData?.aiAnalysis || first?.aiAnalysis,
profiles: Array.isArray(profiles) ? profiles : undefined,
thinking: data.response?.thinking || generateThinking(query),
}]
})
} catch (error: any) {
setMessages(prev => {
const filtered = prev.filter(m => m.role !== "thinking")
return [...filtered, {
role: "assistant",
content: `网络错误: ${error.message}`,
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
}]
})
} finally {
setLoading(false)
}
}
// 快捷查询
const quickQuery = (q: string) => {
setQuery(q)
setTimeout(() => handleSend(), 100)
}
const formatNumber = (num: number): string => {
if (num >= 1000000000) return `${(num / 1000000000).toFixed(2)}B`
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
return num.toLocaleString()
}
// 核心功能模块
const modules = [
{
title: "数据接入",
icon: Database,
color: "from-blue-500 to-cyan-500",
href: "/data-ingestion/sources",
desc: "数据源 · 清洗 · 调度",
},
{
title: "标签画像",
icon: Tags,
color: "from-green-500 to-emerald-500",
href: "/tag-portrait",
desc: "标签 · 画像 · 流量池",
},
{
title: "数据市场",
icon: Package,
color: "from-orange-500 to-red-500",
href: "/data-market",
desc: "API · 订阅 · 变现",
},
]
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 p-4 md:p-6">
{/* 顶部状态栏 */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-bold text-gray-900"></h1>
<p className="text-xs text-gray-500"></p>
</div>
<div className="flex items-center gap-2">
<Badge className={`${aiStatus?.status === 'online' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'} text-xs`}>
<div className={`w-1.5 h-1.5 rounded-full ${aiStatus?.status === 'online' ? 'bg-green-500' : 'bg-yellow-500'} mr-1`}></div>
{stats.latency}ms
</Badge>
<Link href="/monitoring/health">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Activity className="h-4 w-4" />
</Button>
</Link>
<Link href="/settings">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Settings className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-3 mb-4">
<Card className="border-0 shadow-sm bg-white/70">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-500" />
<div>
<div className="text-lg font-bold">{formatNumber(stats.totalUsers)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-white/70">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-green-500" />
<div>
<div className="text-lg font-bold">{stats.totalDataSources}</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-white/70">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-orange-500" />
<div>
<div className="text-lg font-bold">{stats.latency}ms</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* AI对话区域 - 核心功能 */}
<Card className="border-0 shadow-lg bg-white/90 backdrop-blur mb-4 flex flex-col" style={{ height: 'calc(100vh - 340px)', minHeight: '300px' }}>
<CardHeader className="pb-2 border-b flex-shrink-0">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<div className="flex items-center gap-2">
<Brain className="h-4 w-4 text-purple-500" />
AI
</div>
<Badge variant="outline" className="text-xs">
{formatNumber(stats.totalUsers)}
</Badge>
</CardTitle>
</CardHeader>
{/* 消息列表 */}
<CardContent className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{/* 思考过程 */}
{msg.role === 'thinking' && (
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 bg-yellow-50 border border-yellow-200">
<div className="flex items-center gap-2 text-yellow-700 text-xs mb-1">
<Sparkles className="h-3 w-3 animate-pulse" />
AI思考中...
</div>
<div className="text-xs text-yellow-800 whitespace-pre-wrap font-mono">{msg.content}</div>
</div>
)}
{/* 用户消息 */}
{msg.role === 'user' && (
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-500 text-white">
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
<div className="text-xs mt-1 text-blue-100">{msg.timestamp}</div>
</div>
)}
{/* AI回复 */}
{msg.role === 'assistant' && (
<div className="max-w-[85%] space-y-2">
{/* 思考过程折叠显示 */}
{msg.thinking && (
<div className="rounded-xl px-3 py-2 bg-yellow-50 border border-yellow-100 text-xs">
<div className="text-yellow-600 font-medium mb-1">💭 </div>
<div className="text-yellow-800 whitespace-pre-wrap font-mono">{msg.thinking}</div>
</div>
)}
{/* 多人列表:前 10 条完整画像手机、QQ、地址、统一标签、AI 分析) */}
{msg.profiles && msg.profiles.length > 0 && (
<div className="space-y-3">
{msg.profiles.map((item, idx) => {
const p = parsePortrait(item)
const fp = item.fullProfile
const addr = [fp?.valuation?.province, fp?.valuation?.city, fp?.qqPhone?., fp?.qqPhone?.].filter(Boolean)
const tags = [...(fp?.valuation?.unified_tags || []), ...(fp?.valuation?.tags || []), ...(fp?.ckbAsset?.tags || [])]
const uniqTags = [...new Set(tags)]
return (
<div key={idx} className="rounded-xl p-3 bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-100 space-y-2">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 flex items-center justify-center text-white text-sm font-bold">
{(p?.name || '?')[0]}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-900">{p?.name || '未知用户'}</div>
<div className="text-xs text-gray-500 flex flex-wrap gap-x-2">
{p?.phone && <span>📱 {p.phone}</span>}
{p?.qq && <span>QQ: {p.qq}</span>}
{addr.length > 0 && <span>📍 {addr.join(' ')}</span>}
</div>
</div>
{p?.level && <Badge className="shrink-0 bg-purple-100 text-purple-700">{p.level}</Badge>}
{p?.rfmScore != null && <span className="text-xs text-purple-600 font-medium">{p.rfmScore} </span>}
</div>
{fp?.valuation?.traffic_pool?.pool_name && (
<div className="text-xs text-gray-600">📦 : {fp.valuation.traffic_pool.pool_name}</div>
)}
{uniqTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{uniqTags.slice(0, 8).map((tag, ti) => (
<Badge key={ti} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
{item.aiAnalysis && (
<div className="rounded-lg p-2 bg-white/90 border border-purple-100">
<div className="text-xs text-purple-600 font-medium mb-0.5">🤖 AI </div>
<div className="text-xs text-gray-700">{item.aiAnalysis}</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* 单人完整用户画像卡片(含 AI 分析) */}
{!msg.profiles?.length && msg.portrait && (msg.portrait.phone || msg.portrait.qq || msg.fullProfile) && (
<div className="rounded-xl p-3 bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-100 space-y-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 flex items-center justify-center text-white font-bold">
{(msg.portrait.name || '?')[0]}
</div>
<div>
<div className="font-semibold text-gray-900">{msg.portrait.name || '未知用户'}</div>
<div className="text-xs text-gray-500">
{msg.portrait.phone && <span className="mr-2">📱 {msg.portrait.phone}</span>}
{msg.portrait.qq && <span>QQ: {msg.portrait.qq}</span>}
</div>
</div>
{msg.portrait.level && (
<Badge className="ml-auto bg-purple-100 text-purple-700">{msg.portrait.level}</Badge>
)}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
{msg.portrait.rfmScore !== undefined && (
<div className="p-2 rounded bg-white/80">
<div className="text-gray-500"></div>
<div className="font-bold text-purple-600">{msg.portrait.rfmScore}</div>
</div>
)}
{msg.portrait.province && (
<div className="p-2 rounded bg-white/80">
<div className="text-gray-500"></div>
<div className="font-medium">{msg.portrait.province} {msg.portrait.city}</div>
</div>
)}
{msg.fullProfile?.qqPhone && (
<div className="p-2 rounded bg-white/80 col-span-1">
<div className="text-gray-500"></div>
<div className="font-medium">{msg.fullProfile.qqPhone. || '—'}</div>
</div>
)}
{msg.fullProfile?.ckbAsset && (
<div className="p-2 rounded bg-white/80 col-span-3">
<div className="text-gray-500"></div>
<div className="font-medium">{msg.fullProfile.ckbAsset.nickname || '—'} · {msg.fullProfile.ckbAsset.total_assets ?? '—'}</div>
</div>
)}
</div>
{msg.portrait.tags && msg.portrait.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-gray-500 mr-1">:</span>
{msg.portrait.tags.slice(0, 8).map((tag, ti) => (
<Badge key={ti} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
{msg.aiAnalysis && (
<div className="rounded-lg p-2.5 bg-white/90 border border-purple-100">
<div className="text-xs text-purple-600 font-medium mb-1">🤖 AI </div>
<div className="text-xs text-gray-700 whitespace-pre-wrap">{msg.aiAnalysis.replace(/\*\*/g, '')}</div>
</div>
)}
</div>
)}
{/* 文本回复 */}
<div className="rounded-2xl px-4 py-2.5 bg-gray-100 text-gray-800">
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
<div className="text-xs mt-1 text-gray-400">{msg.timestamp}</div>
</div>
</div>
)}
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-2xl px-4 py-2.5 flex items-center gap-2 text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</CardContent>
{/* 快捷查询 */}
<div className="px-4 py-2 border-t flex-shrink-0">
<div className="flex gap-2 overflow-x-auto pb-1">
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("13407000001")}>
<Phone className="w-3 h-3 mr-1" />
</Button>
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("28533368 qq")}>
<User className="w-3 h-3 mr-1" /> QQ
</Button>
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("系统状态")}>
<Activity className="w-3 h-3 mr-1" />
</Button>
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("深圳")}>
<Search className="w-3 h-3 mr-1" />
</Button>
<Button variant="outline" size="sm" className="text-xs shrink-0 h-7" onClick={() => quickQuery("高价值用户")}>
<TrendingUp className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
{/* 输入框 */}
<div className="p-3 border-t flex-shrink-0">
<div className="flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSend()}
placeholder="输入手机、QQ、身份证、姓名可多条逗号分隔最多前10条..."
className="flex-1 h-10 bg-gray-50 border-0"
/>
<Button
onClick={handleSend}
disabled={loading || !query.trim()}
className="h-10 px-4 bg-gradient-to-r from-blue-500 to-purple-500"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
</div>
</div>
</Card>
{/* 功能模块入口 */}
<div className="grid grid-cols-3 gap-3">
{modules.map((module, i) => (
<Link key={i} href={module.href}>
<Card className="border-0 shadow-sm bg-white/70 cursor-pointer hover:shadow-md transition-all group">
<CardContent className="p-3">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-r ${module.color} flex items-center justify-center mb-2 group-hover:scale-110 transition-transform`}>
<module.icon className="w-5 h-5 text-white" />
</div>
<h3 className="font-medium text-sm text-gray-900">{module.title}</h3>
<p className="text-xs text-gray-500 mt-0.5">{module.desc}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}