chore: 以本地为准,上传全部并替换 GitHub
This commit is contained in:
617
app/ai-agent/channels/page.tsx
Normal file
617
app/ai-agent/channels/page.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "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 { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Settings,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Webhook,
|
||||
Bot,
|
||||
Loader2,
|
||||
Send,
|
||||
Key,
|
||||
Globe,
|
||||
Smartphone,
|
||||
} from "lucide-react"
|
||||
|
||||
// 渠道接口
|
||||
interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
type: 'feishu' | 'wecom' | 'wechat' | 'api' | 'webhook'
|
||||
status: 'connected' | 'disconnected' | 'pending'
|
||||
config: {
|
||||
appId?: string
|
||||
appSecret?: string
|
||||
webhookUrl?: string
|
||||
token?: string
|
||||
encryptKey?: string
|
||||
}
|
||||
stats: {
|
||||
totalMessages: number
|
||||
todayMessages: number
|
||||
lastMessage?: string
|
||||
}
|
||||
createdAt: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 渠道类型配置 - 包含详细配置步骤
|
||||
const CHANNEL_TYPES = {
|
||||
feishu: {
|
||||
name: '飞书',
|
||||
icon: '🪶',
|
||||
color: 'bg-blue-100 text-blue-700',
|
||||
description: '对接飞书机器人,支持私聊和群聊',
|
||||
configFields: ['appId', 'appSecret', 'encryptKey', 'token'],
|
||||
steps: [
|
||||
'1. 登录飞书开放平台 open.feishu.cn',
|
||||
'2. 创建企业自建应用 → 机器人',
|
||||
'3. 获取 App ID 和 App Secret',
|
||||
'4. 配置事件订阅 URL: {apiBaseUrl}/feishu/events',
|
||||
'5. 添加权限: im:message, im:message.group_at_msg',
|
||||
'6. 将机器人添加到目标群聊',
|
||||
'7. @机器人 即可开始对话',
|
||||
],
|
||||
},
|
||||
wecom: {
|
||||
name: '企业微信',
|
||||
icon: '💼',
|
||||
color: 'bg-green-100 text-green-700',
|
||||
description: '对接企业微信应用或机器人',
|
||||
configFields: ['corpId', 'agentId', 'secret', 'token', 'encodingAESKey'],
|
||||
steps: [
|
||||
'1. 登录企业微信管理后台 work.weixin.qq.com',
|
||||
'2. 应用管理 → 自建应用 → 创建应用',
|
||||
'3. 获取 CorpID、AgentId、Secret',
|
||||
'4. 设置接收消息 URL: {apiBaseUrl}/wecom/callback',
|
||||
'5. 配置 Token 和 EncodingAESKey',
|
||||
'6. 开启客户联系、消息推送等权限',
|
||||
'7. 发消息给应用即可触发对话',
|
||||
],
|
||||
},
|
||||
wechat: {
|
||||
name: '微信',
|
||||
icon: '💬',
|
||||
color: 'bg-emerald-100 text-emerald-700',
|
||||
description: '通过存客宝对接微信私域',
|
||||
configFields: ['ckbApiKey', 'ckbSecret'],
|
||||
steps: [
|
||||
'1. 登录存客宝后台 admin.cunkebao.com',
|
||||
'2. 系统设置 → API配置',
|
||||
'3. 获取 API Key 和 Secret',
|
||||
'4. 配置消息回调 URL: {apiBaseUrl}/ckb/webhook',
|
||||
'5. 在卡若AI配置存客宝凭证',
|
||||
'6. 微信好友发消息即可触发AI回复',
|
||||
],
|
||||
},
|
||||
api: {
|
||||
name: 'API接口',
|
||||
icon: '🔌',
|
||||
color: 'bg-purple-100 text-purple-700',
|
||||
description: '标准REST API,供第三方系统调用',
|
||||
configFields: ['apiKey'],
|
||||
steps: [
|
||||
'1. 在本页面生成 API Key',
|
||||
'2. 调用方式: POST {apiBaseUrl}/chat',
|
||||
'3. Header: Authorization: Bearer {API_KEY}',
|
||||
'4. Body: { "message": "你的问题" }',
|
||||
'5. 响应: { "success": true, "response": {...} }',
|
||||
],
|
||||
},
|
||||
webhook: {
|
||||
name: 'Webhook',
|
||||
icon: '🔗',
|
||||
color: 'bg-orange-100 text-orange-700',
|
||||
description: '通用Webhook回调接口',
|
||||
configFields: ['webhookUrl', 'secret'],
|
||||
steps: [
|
||||
'1. 配置接收 Webhook 的目标 URL',
|
||||
'2. 设置签名密钥 (可选)',
|
||||
'3. 神射手会在以下事件时推送:',
|
||||
' - 新用户画像生成',
|
||||
' - 流量包创建完成',
|
||||
' - 报告生成完成',
|
||||
'4. 推送格式: { "event": "...", "data": {...} }',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// 预定义渠道
|
||||
const DEFAULT_CHANNELS: Channel[] = [
|
||||
{
|
||||
id: 'ch_feishu_1',
|
||||
name: '卡若AI飞书机器人',
|
||||
type: 'feishu',
|
||||
status: 'connected',
|
||||
config: {
|
||||
appId: 'cli_a6xxx',
|
||||
appSecret: '********',
|
||||
token: 'karuo_token',
|
||||
},
|
||||
stats: {
|
||||
totalMessages: 12580,
|
||||
todayMessages: 156,
|
||||
lastMessage: '5分钟前',
|
||||
},
|
||||
createdAt: '2024-01-15',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'ch_wecom_1',
|
||||
name: '企业微信-神射手助手',
|
||||
type: 'wecom',
|
||||
status: 'connected',
|
||||
config: {
|
||||
appId: 'ww_xxx',
|
||||
},
|
||||
stats: {
|
||||
totalMessages: 8920,
|
||||
todayMessages: 89,
|
||||
lastMessage: '12分钟前',
|
||||
},
|
||||
createdAt: '2024-02-01',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'ch_wechat_1',
|
||||
name: '存客宝微信通道',
|
||||
type: 'wechat',
|
||||
status: 'connected',
|
||||
config: {},
|
||||
stats: {
|
||||
totalMessages: 45600,
|
||||
todayMessages: 320,
|
||||
lastMessage: '1分钟前',
|
||||
},
|
||||
createdAt: '2024-01-01',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'ch_api_1',
|
||||
name: '神射手API',
|
||||
type: 'api',
|
||||
status: 'connected',
|
||||
config: {
|
||||
apiKey: 'sk-archer-xxx',
|
||||
},
|
||||
stats: {
|
||||
totalMessages: 156000,
|
||||
todayMessages: 2340,
|
||||
lastMessage: '刚刚',
|
||||
},
|
||||
createdAt: '2024-01-01',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const [channels, setChannels] = useState<Channel[]>(DEFAULT_CHANNELS)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [showConfigDialog, setShowConfigDialog] = useState(false)
|
||||
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null)
|
||||
const [newChannelType, setNewChannelType] = useState<keyof typeof CHANNEL_TYPES>('feishu')
|
||||
const [testLoading, setTestLoading] = useState<string | null>(null)
|
||||
|
||||
// API端点基础URL - 动态获取当前域名
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// 获取当前部署的域名
|
||||
const host = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
setApiBaseUrl(`${host}/api/shensheshou`)
|
||||
}, [])
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const testConnection = async (channelId: string) => {
|
||||
setTestLoading(channelId)
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
setTestLoading(null)
|
||||
}
|
||||
|
||||
// 切换启用状态
|
||||
const toggleChannel = (channelId: string) => {
|
||||
setChannels(channels.map(ch =>
|
||||
ch.id === channelId ? { ...ch, enabled: !ch.enabled } : ch
|
||||
))
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <Badge className="bg-green-100 text-green-700"><CheckCircle2 className="h-3 w-3 mr-1" />已连接</Badge>
|
||||
case 'disconnected':
|
||||
return <Badge className="bg-red-100 text-red-700"><XCircle className="h-3 w-3 mr-1" />未连接</Badge>
|
||||
default:
|
||||
return <Badge className="bg-yellow-100 text-yellow-700"><RefreshCw className="h-3 w-3 mr-1" />配置中</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">渠道配置</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">配置飞书、企微、微信等渠道,让AI助手可通过多渠道访问</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加渠道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">接入渠道</p>
|
||||
<p className="text-2xl font-bold">{channels.length}</p>
|
||||
</div>
|
||||
<Webhook className="h-8 w-8 text-blue-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">已连接</p>
|
||||
<p className="text-2xl font-bold text-green-600">{channels.filter(c => c.status === 'connected').length}</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">今日消息</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(channels.reduce((sum, c) => sum + c.stats.todayMessages, 0))}</p>
|
||||
</div>
|
||||
<MessageSquare className="h-8 w-8 text-purple-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">累计消息</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(channels.reduce((sum, c) => sum + c.stats.totalMessages, 0))}</p>
|
||||
</div>
|
||||
<Send className="h-8 w-8 text-orange-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* API接入说明 */}
|
||||
<Card className="border-0 shadow-sm bg-gradient-to-r from-blue-50 to-purple-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">🔗 API接入端点</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">第三方系统可通过以下接口与神射手Skill通信</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-white px-3 py-1.5 rounded text-sm font-mono text-purple-600">
|
||||
POST {apiBaseUrl}/chat
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(`${apiBaseUrl}/chat`)}>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">智能对话</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-white px-3 py-1.5 rounded text-sm font-mono text-purple-600">
|
||||
POST {apiBaseUrl}/query
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(`${apiBaseUrl}/query`)}>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">用户查询</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
API文档
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 渠道列表 */}
|
||||
<div className="space-y-3">
|
||||
{channels.map(channel => {
|
||||
const typeConfig = CHANNEL_TYPES[channel.type]
|
||||
|
||||
return (
|
||||
<Card key={channel.id} className="border-0 shadow-sm bg-white/80 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl ${typeConfig.color.split(' ')[0]} flex items-center justify-center text-2xl`}>
|
||||
{typeConfig.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{channel.name}</h3>
|
||||
<Badge className={typeConfig.color}>{typeConfig.name}</Badge>
|
||||
{getStatusBadge(channel.status)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{typeConfig.description}</p>
|
||||
<div className="flex items-center gap-4 mt-1 text-xs text-gray-500">
|
||||
<span>今日: {formatNumber(channel.stats.todayMessages)} 条</span>
|
||||
<span>累计: {formatNumber(channel.stats.totalMessages)} 条</span>
|
||||
{channel.stats.lastMessage && (
|
||||
<span>最近消息: {channel.stats.lastMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={channel.enabled}
|
||||
onCheckedChange={() => toggleChannel(channel.id)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => testConnection(channel.id)}
|
||||
disabled={testLoading === channel.id}
|
||||
>
|
||||
{testLoading === channel.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedChannel(channel)
|
||||
setShowConfigDialog(true)
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 添加渠道弹窗 */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加渠道</DialogTitle>
|
||||
<DialogDescription>选择要接入的渠道类型</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3 py-4">
|
||||
{Object.entries(CHANNEL_TYPES).map(([key, config]) => (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => setNewChannelType(key as keyof typeof CHANNEL_TYPES)}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
newChannelType === key ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">{config.icon}</div>
|
||||
<h4 className="font-semibold text-gray-900">{config.name}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{config.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>取消</Button>
|
||||
<Button onClick={() => {
|
||||
// 跳转到配置页面
|
||||
setShowAddDialog(false)
|
||||
setShowConfigDialog(true)
|
||||
setSelectedChannel({
|
||||
id: `ch_${newChannelType}_new`,
|
||||
name: '',
|
||||
type: newChannelType,
|
||||
status: 'pending',
|
||||
config: {},
|
||||
stats: { totalMessages: 0, todayMessages: 0 },
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
enabled: false,
|
||||
})
|
||||
}}>
|
||||
下一步:配置
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 配置弹窗 */}
|
||||
<Dialog open={showConfigDialog} onOpenChange={setShowConfigDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedChannel?.id.includes('new') ? '配置新渠道' : '编辑渠道配置'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedChannel && CHANNEL_TYPES[selectedChannel.type]?.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedChannel && (
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 配置步骤指南 */}
|
||||
<div className="p-3 rounded-lg bg-blue-50 border border-blue-100">
|
||||
<h4 className="font-medium text-blue-900 mb-2 flex items-center gap-2">
|
||||
📋 {CHANNEL_TYPES[selectedChannel.type].name} 配置步骤
|
||||
</h4>
|
||||
<ol className="text-xs text-blue-800 space-y-1">
|
||||
{(CHANNEL_TYPES[selectedChannel.type] as any).steps?.map((step: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-1">
|
||||
<span>{step.replace('{apiBaseUrl}', apiBaseUrl || 'https://your-domain.com/api/shensheshou')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>渠道名称</Label>
|
||||
<Input
|
||||
placeholder={`例如:${CHANNEL_TYPES[selectedChannel.type].name}机器人`}
|
||||
defaultValue={selectedChannel.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedChannel.type === 'feishu' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>App ID</Label>
|
||||
<Input placeholder="cli_xxx" defaultValue={selectedChannel.config.appId} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>App Secret</Label>
|
||||
<Input type="password" placeholder="输入App Secret" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Verification Token</Label>
|
||||
<Input placeholder="用于验证请求来源" defaultValue={selectedChannel.config.token} />
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-50 text-sm">
|
||||
<p className="font-medium text-blue-800 mb-1">📋 飞书配置步骤</p>
|
||||
<ol className="text-blue-700 text-xs space-y-1 list-decimal list-inside">
|
||||
<li>在飞书开放平台创建应用</li>
|
||||
<li>添加机器人能力并设置权限</li>
|
||||
<li>配置事件订阅回调地址</li>
|
||||
<li>填入上述配置信息</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedChannel.type === 'wecom' && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>企业ID (CorpId)</Label>
|
||||
<Input placeholder="ww_xxx" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>应用ID (AgentId)</Label>
|
||||
<Input placeholder="1000001" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>应用Secret</Label>
|
||||
<Input type="password" placeholder="输入应用Secret" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedChannel.type === 'wechat' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>存客宝API Key</Label>
|
||||
<Input placeholder="从存客宝获取" />
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-green-50 text-sm">
|
||||
<p className="font-medium text-green-800 mb-1">💡 微信通道说明</p>
|
||||
<p className="text-green-700 text-xs">
|
||||
微信私域通过存客宝SDK接入,需要在存客宝平台配置API密钥
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedChannel.type === 'api' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value="sk-archer-xxx-xxx-xxx" readOnly className="font-mono" />
|
||||
<Button variant="outline" size="icon">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>调用示例</Label>
|
||||
<pre className="p-3 rounded-lg bg-gray-900 text-green-400 text-xs overflow-x-auto">
|
||||
{`curl -X POST ${apiBaseUrl}/chat \\
|
||||
-H "Authorization: Bearer sk-archer-xxx" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"message": "查询用户 13800138000"}'`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedChannel.type === 'webhook' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Webhook回调URL</Label>
|
||||
<Input placeholder="https://your-server.com/webhook" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>签名密钥(可选)</Label>
|
||||
<Input placeholder="用于验证回调请求" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowConfigDialog(false)}>取消</Button>
|
||||
<Button onClick={() => {
|
||||
setShowConfigDialog(false)
|
||||
// 保存配置逻辑
|
||||
}}>
|
||||
保存配置
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -75,73 +75,56 @@ export default function AIAgentChatPage() {
|
||||
setInputValue("")
|
||||
setIsLoading(true)
|
||||
|
||||
// 模拟AI响应
|
||||
setTimeout(() => {
|
||||
let response: any = {
|
||||
try {
|
||||
// 调用真实的AI Chat API
|
||||
const response = await fetch("/api/ai-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: userMessage.content })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
let aiResponse: any = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
// 根据输入内容生成不同类型的响应
|
||||
if (inputValue.includes("高价值") || inputValue.includes("消费") || inputValue.includes("查询")) {
|
||||
response.content = "我已理解您的需求。正在为您查询符合条件的用户..."
|
||||
response.taskType = "NLQ"
|
||||
response.result = {
|
||||
type: "table",
|
||||
title: "高价值用户查询结果",
|
||||
sql: "SELECT * FROM users WHERE total_spending > 10000 AND last_active_date >= DATE_SUB(NOW(), INTERVAL 30 DAY)",
|
||||
data: [
|
||||
{ id: "U001", name: "张三", phone: "138****1234", spending: "¥15,680", lastActive: "2天前" },
|
||||
{ id: "U002", name: "李四", phone: "139****5678", spending: "¥12,340", lastActive: "1天前" },
|
||||
{ id: "U003", name: "王五", phone: "137****9012", spending: "¥11,890", lastActive: "3天前" },
|
||||
],
|
||||
total: 823,
|
||||
}
|
||||
} else if (inputValue.includes("打标") || inputValue.includes("标签")) {
|
||||
response.content = "我将为符合条件的用户打上「流失风险」标签。这是一个AI打标任务,需要您确认后执行。"
|
||||
response.taskType = "TAG"
|
||||
response.result = {
|
||||
type: "confirmation",
|
||||
title: "AI打标任务确认",
|
||||
description: "为最近7天未登录的用户打上「流失风险」标签",
|
||||
affectedCount: 12456,
|
||||
confidence: 0.87,
|
||||
needReview: true,
|
||||
}
|
||||
} else if (inputValue.includes("清洗") || inputValue.includes("异常")) {
|
||||
response.content = "我已扫描用户表,发现以下数据质量问题:"
|
||||
response.taskType = "CLEAN"
|
||||
response.result = {
|
||||
type: "cleaning",
|
||||
title: "数据清洗建议",
|
||||
issues: [
|
||||
{ field: "phone", issue: "格式不规范", count: 234, example: "1381234567 → 138****4567" },
|
||||
{ field: "email", issue: "缺失值", count: 1256, suggestion: "填充默认值或标记" },
|
||||
{ field: "birth_date", issue: "超出范围", count: 45, example: "2099-01-01" },
|
||||
],
|
||||
needReview: true,
|
||||
}
|
||||
} else if (inputValue.includes("报告") || inputValue.includes("分析")) {
|
||||
response.content = "正在为您生成用户增长分析报告..."
|
||||
response.taskType = "REPORT"
|
||||
response.result = {
|
||||
type: "report",
|
||||
title: "用户增长分析报告(2026年1月)",
|
||||
summary: "本月新增用户23,456人,同比增长15.2%;活跃用户856,234人,DAU/MAU比值0.32。",
|
||||
highlights: [
|
||||
"新用户转化率提升至18.5%,环比增长3.2%",
|
||||
"高价值用户占比8.7%,贡献65%的交易额",
|
||||
"用户流失率下降至2.1%,创历史新低",
|
||||
],
|
||||
if (data.success && data.response) {
|
||||
aiResponse.content = data.response.content
|
||||
|
||||
// 如果有数据结果,添加表格展示
|
||||
if (data.response.data && data.response.data.length > 0) {
|
||||
aiResponse.taskType = "NLQ"
|
||||
aiResponse.result = {
|
||||
type: "table",
|
||||
title: "查询结果",
|
||||
data: data.response.data.slice(0, 5).map((item: any, idx: number) => ({
|
||||
id: idx + 1,
|
||||
name: item.name || item.姓名 || "-",
|
||||
phone: item.phone_masked || item.phone || "-",
|
||||
level: item.user_level || item.traffic_pool?.pool_name || "-",
|
||||
score: item.user_evaluation_score || item.rfm_composite_score || "-",
|
||||
})),
|
||||
total: data.response.data.length,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.content = "我理解您的需求。请问您想要:\n1. 查询用户数据\n2. AI打标签\n3. 数据清洗\n4. 生成报告\n\n请选择或详细描述您的需求。"
|
||||
aiResponse.content = `⚠️ ${data.error || "查询失败,请重试"}`
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, response])
|
||||
setMessages((prev) => [...prev, aiResponse])
|
||||
} catch (error: any) {
|
||||
setMessages((prev) => [...prev, {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content: `⚠️ 网络错误: ${error.message}`,
|
||||
timestamp: new Date(),
|
||||
}])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickCommand = (command: string) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
279
app/ai-agent/page.tsx
Normal file
279
app/ai-agent/page.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Bot,
|
||||
MessageSquare,
|
||||
Webhook,
|
||||
Sparkles,
|
||||
Zap,
|
||||
FileText,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Activity,
|
||||
Users,
|
||||
Send,
|
||||
Settings,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
} from "lucide-react"
|
||||
|
||||
interface ChannelStats {
|
||||
name: string
|
||||
icon: string
|
||||
status: 'connected' | 'disconnected'
|
||||
todayMessages: number
|
||||
}
|
||||
|
||||
export default function AIAgentPage() {
|
||||
const [aiStatus, setAiStatus] = useState<'online' | 'offline'>('online')
|
||||
const [stats, setStats] = useState({
|
||||
totalQueries: 156000,
|
||||
todayQueries: 2340,
|
||||
avgLatency: 380,
|
||||
accuracy: 97.5,
|
||||
})
|
||||
|
||||
const channels: ChannelStats[] = [
|
||||
{ name: '飞书', icon: '🪶', status: 'connected', todayMessages: 156 },
|
||||
{ name: '企业微信', icon: '💼', status: 'connected', todayMessages: 89 },
|
||||
{ name: '微信(存客宝)', icon: '💬', status: 'connected', todayMessages: 320 },
|
||||
{ name: 'API接口', icon: '🔌', status: 'connected', todayMessages: 2340 },
|
||||
]
|
||||
|
||||
const API_BASE_URL = 'https://your-domain.com/api/shensheshou'
|
||||
|
||||
const modules = [
|
||||
{
|
||||
title: '智能对话',
|
||||
description: '与AI助手对话,查询用户信息和执行数据分析',
|
||||
icon: MessageSquare,
|
||||
href: '/ai-agent/chat',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
},
|
||||
{
|
||||
title: '渠道配置',
|
||||
description: '配置飞书、企微、微信等渠道接入',
|
||||
icon: Webhook,
|
||||
href: '/ai-agent/channels',
|
||||
color: 'from-green-500 to-emerald-500',
|
||||
},
|
||||
{
|
||||
title: 'AI打标',
|
||||
description: '使用AI自动为用户打标签',
|
||||
icon: Sparkles,
|
||||
href: '/ai-agent/smart-tag',
|
||||
color: 'from-purple-500 to-pink-500',
|
||||
},
|
||||
{
|
||||
title: 'AI清洗',
|
||||
description: '智能数据清洗和格式化',
|
||||
icon: Zap,
|
||||
href: '/ai-agent/data-cleaning',
|
||||
color: 'from-orange-500 to-red-500',
|
||||
},
|
||||
{
|
||||
title: '智能报告',
|
||||
description: '自动生成数据分析报告',
|
||||
icon: FileText,
|
||||
href: '/ai-agent/report',
|
||||
color: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
]
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Agent</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">智能助手,对接飞书/企微/微信等多渠道</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={aiStatus === 'online' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}>
|
||||
<div className={`w-2 h-2 rounded-full ${aiStatus === 'online' ? 'bg-green-500' : 'bg-red-500'} mr-1.5`}></div>
|
||||
{aiStatus === 'online' ? 'AI在线' : 'AI离线'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{stats.avgLatency}ms</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">今日查询</p>
|
||||
<p className="text-2xl font-bold">{stats.todayQueries.toLocaleString()}</p>
|
||||
</div>
|
||||
<Send className="h-8 w-8 text-blue-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">累计查询</p>
|
||||
<p className="text-2xl font-bold">{(stats.totalQueries / 1000).toFixed(0)}K</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-green-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">接入渠道</p>
|
||||
<p className="text-2xl font-bold">{channels.filter(c => c.status === 'connected').length}</p>
|
||||
</div>
|
||||
<Webhook className="h-8 w-8 text-purple-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">准确率</p>
|
||||
<p className="text-2xl font-bold">{stats.accuracy}%</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-orange-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 渠道状态 */}
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Webhook className="h-4 w-4 text-purple-500" />
|
||||
接入渠道状态
|
||||
</span>
|
||||
<Link href="/ai-agent/channels">
|
||||
<Button variant="ghost" size="sm">
|
||||
管理渠道 <ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{channels.map((channel, i) => (
|
||||
<div key={i} className="p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-2xl">{channel.icon}</span>
|
||||
<Badge className={channel.status === 'connected' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}>
|
||||
{channel.status === 'connected' ? '已连接' : '未连接'}
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900">{channel.name}</h4>
|
||||
<p className="text-sm text-gray-500">今日 {channel.todayMessages} 条消息</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API接入说明 */}
|
||||
<Card className="border-0 shadow-sm bg-gradient-to-r from-blue-50 to-purple-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1 flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-purple-500" />
|
||||
神射手 Skill API 接口
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-3">任何系统都可通过以下接口与神射手Skill直接通信</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-white px-3 py-1.5 rounded text-sm font-mono text-purple-600 flex-1">
|
||||
POST {API_BASE_URL}/chat
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(`${API_BASE_URL}/chat`)}>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-white px-3 py-1.5 rounded text-sm font-mono text-purple-600 flex-1">
|
||||
POST {API_BASE_URL}/query?phone=13800138000
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(`${API_BASE_URL}/query`)}>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/ai-agent/channels">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
配置接入
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 功能模块 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{modules.map((module, i) => (
|
||||
<Link key={i} href={module.href}>
|
||||
<Card className="border-0 shadow-sm bg-white/80 hover:shadow-lg transition-all cursor-pointer group h-full">
|
||||
<CardContent className="p-5">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-r ${module.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
|
||||
<module.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 text-lg mb-1">{module.title}</h3>
|
||||
<p className="text-sm text-gray-500">{module.description}</p>
|
||||
<div className="flex items-center gap-1 mt-4 text-sm text-blue-600">
|
||||
<span>进入</span>
|
||||
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">快速接入指南</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-blue-50">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500 text-white flex items-center justify-center font-bold mb-2">1</div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">配置渠道</h4>
|
||||
<p className="text-sm text-gray-600">在渠道配置中添加飞书/企微/微信等渠道</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-green-50">
|
||||
<div className="w-8 h-8 rounded-lg bg-green-500 text-white flex items-center justify-center font-bold mb-2">2</div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">填写密钥</h4>
|
||||
<p className="text-sm text-gray-600">填入对应平台的AppID和Secret</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-purple-50">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500 text-white flex items-center justify-center font-bold mb-2">3</div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">开始对话</h4>
|
||||
<p className="text-sm text-gray-600">在对应平台@机器人即可与神射手对话</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,234 +1,709 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
FileText, Download, Share2, Clock, Eye, Sparkles,
|
||||
BarChart3, Users, TrendingUp, Calendar, Plus, Trash2
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
Calendar,
|
||||
Users,
|
||||
TrendingUp,
|
||||
PieChart,
|
||||
BarChart3,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Sparkles,
|
||||
} from "lucide-react"
|
||||
|
||||
const reportTemplates = [
|
||||
{ id: "1", name: "用户增长分析报告", description: "分析用户增长趋势和渠道效果", icon: TrendingUp },
|
||||
{ id: "2", name: "用户画像洞察报告", description: "深度分析用户特征和行为偏好", icon: Users },
|
||||
{ id: "3", name: "数据质量评估报告", description: "评估数据完整性和准确性", icon: BarChart3 },
|
||||
{ id: "4", name: "营销效果分析报告", description: "评估营销活动ROI和转化效果", icon: TrendingUp },
|
||||
]
|
||||
// 报告规则接口
|
||||
interface ReportRule {
|
||||
id: string
|
||||
name: string
|
||||
templateId: string
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
isDefault: boolean
|
||||
config: {
|
||||
dateRange: string
|
||||
includeCharts: boolean
|
||||
sendTo: string[]
|
||||
format: 'pdf' | 'html' | 'markdown'
|
||||
}
|
||||
promptTemplate: string
|
||||
}
|
||||
|
||||
const recentReports = [
|
||||
// 报告模板
|
||||
interface ReportTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'custom'
|
||||
sections: string[]
|
||||
icon: string
|
||||
}
|
||||
|
||||
// 默认报告规则
|
||||
const DEFAULT_RULES: ReportRule[] = [
|
||||
{
|
||||
id: "r1",
|
||||
name: "2024年1月用户增长分析报告",
|
||||
template: "用户增长分析报告",
|
||||
createdAt: "2024-01-15 14:30",
|
||||
status: "completed",
|
||||
pages: 12,
|
||||
id: 'rule_daily',
|
||||
name: '每日运营日报规则',
|
||||
templateId: 'tpl_daily',
|
||||
schedule: '每日 23:59',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
config: {
|
||||
dateRange: 'today',
|
||||
includeCharts: true,
|
||||
sendTo: ['admin@company.com'],
|
||||
format: 'pdf',
|
||||
},
|
||||
promptTemplate: `生成每日运营日报,包含以下内容:
|
||||
|
||||
1. 核心指标
|
||||
- 总用户数及日增量
|
||||
- 活跃用户数及活跃率
|
||||
- 新增用户数
|
||||
|
||||
2. 用户活跃
|
||||
- 各等级用户活跃情况
|
||||
- 活跃时段分布
|
||||
|
||||
3. 渠道消息
|
||||
- 各渠道消息量统计
|
||||
- 响应时间分析
|
||||
|
||||
4. AI查询统计
|
||||
- 查询次数
|
||||
- 平均响应时间
|
||||
- 热门查询类型
|
||||
|
||||
输出格式:结构化报告,包含数据表格和趋势描述`,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
name: "高价值用户画像洞察",
|
||||
template: "用户画像洞察报告",
|
||||
createdAt: "2024-01-14 10:20",
|
||||
status: "completed",
|
||||
pages: 8,
|
||||
id: 'rule_weekly',
|
||||
name: '周度数据质量规则',
|
||||
templateId: 'tpl_weekly',
|
||||
schedule: '每周日 00:00',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
config: {
|
||||
dateRange: 'week',
|
||||
includeCharts: true,
|
||||
sendTo: ['data@company.com'],
|
||||
format: 'pdf',
|
||||
},
|
||||
promptTemplate: `生成周度数据质量报告:
|
||||
|
||||
1. 质量概览
|
||||
- 数据完整度评分
|
||||
- 数据准确度评分
|
||||
- 本周质量趋势
|
||||
|
||||
2. 清洗统计
|
||||
- 清洗记录数
|
||||
- 格式化字段数
|
||||
- 异常数据处理
|
||||
|
||||
3. 去重效果
|
||||
- 去重前后对比
|
||||
- 重复率统计
|
||||
|
||||
4. 数据完整度
|
||||
- 各字段填充率
|
||||
- 缺失字段分析`,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
name: "Q4数据质量评估",
|
||||
template: "数据质量评估报告",
|
||||
createdAt: "2024-01-10 16:45",
|
||||
status: "generating",
|
||||
pages: 0,
|
||||
id: 'rule_monthly',
|
||||
name: '月度资产报告规则',
|
||||
templateId: 'tpl_monthly',
|
||||
schedule: '每月1日 08:00',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
config: {
|
||||
dateRange: 'month',
|
||||
includeCharts: true,
|
||||
sendTo: ['admin@company.com', 'ceo@company.com'],
|
||||
format: 'pdf',
|
||||
},
|
||||
promptTemplate: `生成月度用户资产报告:
|
||||
|
||||
1. 资产总览
|
||||
- 用户总资产价值
|
||||
- 月度变化趋势
|
||||
- 核心指标汇总
|
||||
|
||||
2. 用户增长
|
||||
- 新增用户分析
|
||||
- 流失用户分析
|
||||
- 净增长情况
|
||||
|
||||
3. RFM分析
|
||||
- RFM评分分布
|
||||
- 各等级用户占比
|
||||
- 价值迁移分析
|
||||
|
||||
4. 流量池分布
|
||||
- 各流量池规模
|
||||
- 月度变化
|
||||
- 转化分析
|
||||
|
||||
5. 标签覆盖
|
||||
- 标签覆盖率
|
||||
- 热门标签TOP10`,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AIReportPage() {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||
const [reportConfig, setReportConfig] = useState({
|
||||
name: "",
|
||||
dateRange: "last_30_days",
|
||||
userSegment: "all",
|
||||
customPrompt: "",
|
||||
})
|
||||
// 预定义报告模板
|
||||
const REPORT_TEMPLATES: ReportTemplate[] = [
|
||||
{
|
||||
id: 'tpl_daily',
|
||||
name: '每日运营日报',
|
||||
description: '包含当日用户活跃、渠道消息、AI查询等实时数据汇总',
|
||||
type: 'daily',
|
||||
sections: ['核心指标', '用户活跃', '渠道消息', 'AI查询统计'],
|
||||
icon: '📊',
|
||||
},
|
||||
{
|
||||
id: 'tpl_weekly',
|
||||
name: '周度数据质量报告',
|
||||
description: '数据清洗、去重、补全等质量指标周度汇总',
|
||||
type: 'weekly',
|
||||
sections: ['质量概览', '清洗统计', '去重效果', '数据完整度'],
|
||||
icon: '📈',
|
||||
},
|
||||
{
|
||||
id: 'tpl_monthly',
|
||||
name: '月度用户资产报告',
|
||||
description: '用户增长、RFM分布、流量池变化等核心资产指标',
|
||||
type: 'monthly',
|
||||
sections: ['资产总览', '用户增长', 'RFM分析', '流量池分布', '标签覆盖'],
|
||||
icon: '📑',
|
||||
},
|
||||
{
|
||||
id: 'tpl_rfm',
|
||||
name: 'RFM分析报告',
|
||||
description: '详细的RFM评分分布和用户价值分层分析',
|
||||
type: 'custom',
|
||||
sections: ['RFM概览', '评分分布', '等级分层', '价值趋势'],
|
||||
icon: '💎',
|
||||
},
|
||||
{
|
||||
id: 'tpl_portrait',
|
||||
name: '用户画像报告',
|
||||
description: '用户画像特征分析,包含地域、年龄、偏好等维度',
|
||||
type: 'custom',
|
||||
sections: ['画像概览', '地域分布', '行为特征', '偏好分析'],
|
||||
icon: '👤',
|
||||
},
|
||||
{
|
||||
id: 'tpl_traffic',
|
||||
name: '流量池分析报告',
|
||||
description: '各流量池用户规模、转化率、价值贡献分析',
|
||||
type: 'custom',
|
||||
sections: ['池规模', '转化漏斗', '价值贡献', '增长趋势'],
|
||||
icon: '🏊',
|
||||
},
|
||||
]
|
||||
|
||||
// 生成的报告
|
||||
interface GeneratedReport {
|
||||
id: string
|
||||
templateId: string
|
||||
title: string
|
||||
generatedAt: string
|
||||
status: 'ready' | 'generating'
|
||||
content?: string
|
||||
metrics: {
|
||||
totalUsers: number
|
||||
activeUsers: number
|
||||
newUsers: number
|
||||
rfmAvg: number
|
||||
}
|
||||
}
|
||||
|
||||
const GENERATED_REPORTS: GeneratedReport[] = [
|
||||
{
|
||||
id: 'rpt_1',
|
||||
templateId: 'tpl_monthly',
|
||||
title: '2024年1月用户资产月报',
|
||||
generatedAt: '2024-02-01 08:00',
|
||||
status: 'ready',
|
||||
metrics: { totalUsers: 2013000000, activeUsers: 450000000, newUsers: 15000000, rfmAvg: 52.3 },
|
||||
},
|
||||
{
|
||||
id: 'rpt_2',
|
||||
templateId: 'tpl_weekly',
|
||||
title: '本周数据质量报告',
|
||||
generatedAt: '2024-01-28 00:00',
|
||||
status: 'ready',
|
||||
metrics: { totalUsers: 2013000000, activeUsers: 320000000, newUsers: 2500000, rfmAvg: 51.8 },
|
||||
},
|
||||
{
|
||||
id: 'rpt_3',
|
||||
templateId: 'tpl_daily',
|
||||
title: '今日运营日报',
|
||||
generatedAt: '2024-01-30 23:59',
|
||||
status: 'ready',
|
||||
metrics: { totalUsers: 2013000000, activeUsers: 45000000, newUsers: 350000, rfmAvg: 52.5 },
|
||||
},
|
||||
]
|
||||
|
||||
export default function ReportPage() {
|
||||
const [reports, setReports] = useState<GeneratedReport[]>(GENERATED_REPORTS)
|
||||
const [rules, setRules] = useState<ReportRule[]>(DEFAULT_RULES)
|
||||
const [activeTab, setActiveTab] = useState("templates")
|
||||
const [generating, setGenerating] = useState<string | null>(null)
|
||||
const [showGenerateDialog, setShowGenerateDialog] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ReportTemplate | null>(null)
|
||||
const [showPreviewDialog, setShowPreviewDialog] = useState(false)
|
||||
const [previewReport, setPreviewReport] = useState<GeneratedReport | null>(null)
|
||||
const [showRuleDialog, setShowRuleDialog] = useState(false)
|
||||
const [selectedRule, setSelectedRule] = useState<ReportRule | null>(null)
|
||||
const [editingPrompt, setEditingPrompt] = useState('')
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// 通过Skill生成报告
|
||||
const generateReport = async (template: ReportTemplate) => {
|
||||
setGenerating(template.id)
|
||||
setShowGenerateDialog(false)
|
||||
|
||||
try {
|
||||
// 调用AI Chat API执行报告生成
|
||||
const response = await fetch('/api/ai-chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: `生成${template.name},包含以下章节:${template.sections.join('、')}`
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 添加新报告到列表
|
||||
const newReport: GeneratedReport = {
|
||||
id: `rpt_${Date.now()}`,
|
||||
templateId: template.id,
|
||||
title: `${template.name} - ${new Date().toLocaleDateString()}`,
|
||||
generatedAt: new Date().toLocaleString(),
|
||||
status: 'ready',
|
||||
content: data.success ? data.response?.content : '报告生成失败',
|
||||
metrics: {
|
||||
totalUsers: 2013000000,
|
||||
activeUsers: 450000000,
|
||||
newUsers: 15000000,
|
||||
rfmAvg: 52.3,
|
||||
},
|
||||
}
|
||||
|
||||
setReports([newReport, ...reports])
|
||||
} catch (error) {
|
||||
console.error('报告生成失败:', error)
|
||||
} finally {
|
||||
setGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const openPreview = (report: GeneratedReport) => {
|
||||
setPreviewReport(report)
|
||||
setShowPreviewDialog(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">智能报告</h1>
|
||||
<p className="text-muted-foreground">AI自动生成数据分析报告,支持自定义模板和提示词</p>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">智能报告</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">AI自动生成数据分析报告,支持多种报告模板</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowGenerateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
生成报告
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
生成报告
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 报告模板 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">报告模板</h2>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{reportTemplates.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template.id)
|
||||
setShowCreateDialog(true)
|
||||
}}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="p-3 rounded-full bg-primary/10 mb-3">
|
||||
<template.icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-medium mb-1">{template.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||
{/* 标签页 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-white/80 backdrop-blur">
|
||||
<TabsTrigger value="templates">报告模板</TabsTrigger>
|
||||
<TabsTrigger value="rules">规则配置</TabsTrigger>
|
||||
<TabsTrigger value="history">历史报告</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 报告模板 */}
|
||||
<TabsContent value="templates" className="mt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{REPORT_TEMPLATES.map(template => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="border-0 shadow-sm bg-white/80 hover:shadow-lg transition-all cursor-pointer group"
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className="text-3xl">{template.icon}</span>
|
||||
<Badge variant="outline">{
|
||||
template.type === 'daily' ? '日报' :
|
||||
template.type === 'weekly' ? '周报' :
|
||||
template.type === 'monthly' ? '月报' : '自定义'
|
||||
}</Badge>
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 text-lg mb-1">{template.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">{template.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{template.sections.map((section, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">{section}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => generateReport(template)}
|
||||
disabled={generating === template.id}
|
||||
>
|
||||
{generating === template.id ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin mr-2" />生成中...</>
|
||||
) : (
|
||||
<><Play className="h-4 w-4 mr-2" />生成报告</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 规则配置 */}
|
||||
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||
{rules.map(rule => {
|
||||
const template = REPORT_TEMPLATES.find(t => t.id === rule.templateId)
|
||||
return (
|
||||
<Card key={rule.id} className="border-0 shadow-sm bg-white/80 backdrop-blur">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-r from-purple-500 to-blue-500 text-2xl">
|
||||
{template?.icon || '📄'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{rule.name}</h3>
|
||||
{rule.isDefault && <Badge variant="outline" className="text-xs">默认规则</Badge>}
|
||||
{rule.enabled ? (
|
||||
<Badge className="bg-green-100 text-green-700">已启用</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gray-100 text-gray-700">已禁用</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{template?.name}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{rule.schedule}
|
||||
</span>
|
||||
<span>格式: {rule.config.format.toUpperCase()}</span>
|
||||
<span>发送: {rule.config.sendTo.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedRule(rule)
|
||||
setEditingPrompt(rule.promptTemplate)
|
||||
setShowRuleDialog(true)
|
||||
}}
|
||||
>
|
||||
编辑规则
|
||||
</Button>
|
||||
<Button
|
||||
variant={rule.enabled ? "outline" : "default"}
|
||||
size="sm"
|
||||
onClick={() => setRules(rules.map(r => r.id === rule.id ? { ...r, enabled: !r.enabled } : r))}
|
||||
>
|
||||
{rule.enabled ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* 历史报告 */}
|
||||
<TabsContent value="history" className="mt-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
{reports.map(report => {
|
||||
const template = REPORT_TEMPLATES.find(t => t.id === report.templateId)
|
||||
|
||||
return (
|
||||
<div key={report.id} className="flex items-center justify-between p-4 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-xl bg-white text-2xl">
|
||||
{template?.icon || '📄'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{report.title}</h3>
|
||||
<div className="flex items-center gap-4 mt-1 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{report.generatedAt}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{formatNumber(report.metrics.totalUsers)} 用户
|
||||
</span>
|
||||
<span>RFM均值: {report.metrics.rfmAvg}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => openPreview(report)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 最近报告 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最近报告</CardTitle>
|
||||
<CardDescription>查看和管理已生成的报告</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentReports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
{/* 生成报告弹窗 */}
|
||||
<Dialog open={showGenerateDialog} onOpenChange={setShowGenerateDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>生成智能报告</DialogTitle>
|
||||
<DialogDescription>选择报告模板,AI将自动生成分析报告</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>报告模板</Label>
|
||||
<Select onValueChange={(v) => setSelectedTemplate(REPORT_TEMPLATES.find(t => t.id === v) || null)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择报告模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_TEMPLATES.map(template => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.icon} {template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedTemplate && (
|
||||
<div className="p-3 rounded-lg bg-purple-50">
|
||||
<p className="text-sm text-gray-600 mb-2">{selectedTemplate.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTemplate.sections.map((section, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">{section}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">{report.name}</h4>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{report.template}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{report.createdAt}
|
||||
</span>
|
||||
{report.status === "completed" && (
|
||||
<span>{report.pages}页</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>数据范围</Label>
|
||||
<Select defaultValue="all">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部数据</SelectItem>
|
||||
<SelectItem value="today">今日</SelectItem>
|
||||
<SelectItem value="week">本周</SelectItem>
|
||||
<SelectItem value="month">本月</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowGenerateDialog(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={() => selectedTemplate && generateReport(selectedTemplate)}
|
||||
disabled={!selectedTemplate}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
开始生成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 报告预览弹窗 */}
|
||||
<Dialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{previewReport?.title}</DialogTitle>
|
||||
<DialogDescription>生成时间: {previewReport?.generatedAt}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{previewReport && (
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border bg-blue-50">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{formatNumber(previewReport.metrics.totalUsers)}</div>
|
||||
<div className="text-xs text-gray-500">总用户</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border bg-green-50">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{formatNumber(previewReport.metrics.activeUsers)}</div>
|
||||
<div className="text-xs text-gray-500">活跃用户</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border bg-orange-50">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">{formatNumber(previewReport.metrics.newUsers)}</div>
|
||||
<div className="text-xs text-gray-500">新增用户</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border bg-purple-50">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{previewReport.metrics.rfmAvg}</div>
|
||||
<div className="text-xs text-gray-500">RFM均值</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 报告内容 */}
|
||||
<Card className="border">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">报告摘要</h4>
|
||||
<div className="prose prose-sm text-gray-600">
|
||||
{previewReport.content || (
|
||||
<div className="space-y-2">
|
||||
<p>本报告基于神射手数据中台生成,涵盖用户资产数字化核心指标。</p>
|
||||
<p><strong>用户规模:</strong> 总用户{formatNumber(previewReport.metrics.totalUsers)},其中活跃用户占比{((previewReport.metrics.activeUsers / previewReport.metrics.totalUsers) * 100).toFixed(1)}%</p>
|
||||
<p><strong>用户价值:</strong> 平均RFM评分{previewReport.metrics.rfmAvg},较上期提升0.5分</p>
|
||||
<p><strong>增长趋势:</strong> 新增用户{formatNumber(previewReport.metrics.newUsers)},环比增长8.2%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPreviewDialog(false)}>关闭</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载PDF
|
||||
</Button>
|
||||
<Button>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
发送报告
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 规则编辑弹窗 */}
|
||||
<Dialog open={showRuleDialog} onOpenChange={setShowRuleDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑报告规则</DialogTitle>
|
||||
<DialogDescription>{selectedRule?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedRule && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>执行时间</Label>
|
||||
<Input value={selectedRule.schedule} readOnly className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>输出格式</Label>
|
||||
<Select defaultValue={selectedRule.config.format}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="html">HTML</SelectItem>
|
||||
<SelectItem value="markdown">Markdown</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{report.status === "generating" ? (
|
||||
<Badge className="bg-blue-100 text-blue-700">
|
||||
<Sparkles className="h-3 w-3 mr-1 animate-pulse" />
|
||||
生成中
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
预览
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
下载
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>发送目标</Label>
|
||||
<Input defaultValue={selectedRule.config.sendTo.join(', ')} placeholder="多个邮箱用逗号分隔" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>报告生成提示词 <span className="text-gray-400 text-xs">(AI根据此提示词生成报告内容)</span></Label>
|
||||
<Textarea
|
||||
value={editingPrompt}
|
||||
onChange={(e) => setEditingPrompt(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建报告弹窗 */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>生成智能报告</DialogTitle>
|
||||
<DialogDescription>选择模板并配置报告参数,AI将自动生成分析报告</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>报告名称</Label>
|
||||
<Input
|
||||
placeholder="输入报告名称"
|
||||
value={reportConfig.name}
|
||||
onChange={(e) => setReportConfig({...reportConfig, name: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>时间范围</Label>
|
||||
<Select
|
||||
value={reportConfig.dateRange}
|
||||
onValueChange={(v) => setReportConfig({...reportConfig, dateRange: v})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="last_7_days">最近7天</SelectItem>
|
||||
<SelectItem value="last_30_days">最近30天</SelectItem>
|
||||
<SelectItem value="last_90_days">最近90天</SelectItem>
|
||||
<SelectItem value="custom">自定义</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>用户群体</Label>
|
||||
<Select
|
||||
value={reportConfig.userSegment}
|
||||
onValueChange={(v) => setReportConfig({...reportConfig, userSegment: v})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部用户</SelectItem>
|
||||
<SelectItem value="high_value">高价值用户</SelectItem>
|
||||
<SelectItem value="new_users">新用户</SelectItem>
|
||||
<SelectItem value="active">活跃用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>自定义分析要求(可选)</Label>
|
||||
<Textarea
|
||||
placeholder="输入额外的分析要求,例如:重点分析转化漏斗、对比上月数据等"
|
||||
value={reportConfig.customPrompt}
|
||||
onChange={(e) => setReportConfig({...reportConfig, customPrompt: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>取消</Button>
|
||||
<Button>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
开始生成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRuleDialog(false)}>取消</Button>
|
||||
<Button onClick={() => {
|
||||
if (selectedRule) {
|
||||
setRules(rules.map(r => r.id === selectedRule.id ? { ...r, promptTemplate: editingPrompt } : r))
|
||||
}
|
||||
setShowRuleDialog(false)
|
||||
}}>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
保存规则
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "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 { Progress } from "@/components/ui/progress"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,523 +22,537 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tags,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Settings,
|
||||
FileText,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Tags,
|
||||
Database,
|
||||
Brain,
|
||||
Edit,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
Bot,
|
||||
Clock,
|
||||
Target,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
// 第四部分:AI Agent - AI打标
|
||||
export default function AISmartTagPage() {
|
||||
// 打标任务接口
|
||||
interface TaggingTask {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
promptTemplate: string
|
||||
sourceDb: string
|
||||
sourceCollection: string
|
||||
targetTags: string[]
|
||||
status: 'idle' | 'running' | 'paused' | 'completed'
|
||||
progress: number
|
||||
stats: {
|
||||
totalRecords: number
|
||||
processedRecords: number
|
||||
taggedRecords: number
|
||||
accuracy: number
|
||||
}
|
||||
createdAt: string
|
||||
lastRunAt?: string
|
||||
}
|
||||
|
||||
// 默认提示词模板
|
||||
const DEFAULT_PROMPTS = {
|
||||
value: {
|
||||
name: '价值类打标',
|
||||
prompt: `你是用户资产数字化专家。请分析以下用户数据,生成价值类标签:
|
||||
|
||||
用户数据: {user_data}
|
||||
|
||||
打标规则:
|
||||
- RFM评分≥90: 输出"高价值用户"
|
||||
- RFM评分≥70: 输出"优质用户"
|
||||
- RFM评分≥50: 输出"普通用户"
|
||||
- RFM评分<50: 输出"待激活用户"
|
||||
- 近30天无活跃且历史RFM≥60: 输出"流失风险"
|
||||
|
||||
输出格式: ["标签1", "标签2", ...]
|
||||
只输出标签数组,不要其他内容。`,
|
||||
},
|
||||
behavior: {
|
||||
name: '行为类打标',
|
||||
prompt: `分析用户行为数据,生成行为类标签:
|
||||
|
||||
用户数据: {user_data}
|
||||
|
||||
打标规则:
|
||||
- 日活跃(每日登录): 输出"高频用户"
|
||||
- 周活跃(每周≥3天): 输出"活跃用户"
|
||||
- 月活跃(每月≥5天): 输出"普通用户"
|
||||
- 30天+未活跃: 输出"沉默用户"
|
||||
- 注册≤7天: 输出"新用户"
|
||||
|
||||
输出格式: ["标签1", "标签2", ...]`,
|
||||
},
|
||||
preference: {
|
||||
name: '偏好类打标',
|
||||
prompt: `分析用户消费和行为数据,生成偏好类标签:
|
||||
|
||||
用户数据: {user_data}
|
||||
|
||||
打标规则:
|
||||
- 电商消费≥5次/月: 输出"电商活跃"
|
||||
- 社交互动≥10次/天: 输出"社交达人"
|
||||
- 浏览金融产品: 输出"金融偏好"
|
||||
- 游戏时长≥2h/天: 输出"游戏玩家"
|
||||
|
||||
输出格式: ["标签1", "标签2", ...]`,
|
||||
},
|
||||
region: {
|
||||
name: '地域类打标',
|
||||
prompt: `分析用户地理位置数据,生成地域类标签:
|
||||
|
||||
用户数据: {user_data}
|
||||
|
||||
打标规则:
|
||||
- 省份为北京/上海/广州/深圳: 输出"一线城市"
|
||||
- 省份为福建且城市为厦门: 输出"厦门本地"
|
||||
- 根据省份输出对应地域标签
|
||||
|
||||
输出格式: ["标签1", "标签2", ...]`,
|
||||
},
|
||||
}
|
||||
|
||||
// 预定义任务
|
||||
const DEFAULT_TASKS: TaggingTask[] = [
|
||||
{
|
||||
id: 'task_1',
|
||||
name: '腾讯QQ用户价值打标',
|
||||
description: '基于RFM评分为QQ用户打价值类标签',
|
||||
promptTemplate: DEFAULT_PROMPTS.value.prompt,
|
||||
sourceDb: 'KR_腾讯',
|
||||
sourceCollection: 'qq_phone',
|
||||
targetTags: ['高价值用户', '优质用户', '普通用户', '待激活用户', '流失风险'],
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
stats: {
|
||||
totalRecords: 705000000,
|
||||
processedRecords: 705000000,
|
||||
taggedRecords: 680000000,
|
||||
accuracy: 96.4,
|
||||
},
|
||||
createdAt: '2024-01-15',
|
||||
lastRunAt: '2024-01-30 14:30',
|
||||
},
|
||||
{
|
||||
id: 'task_2',
|
||||
name: '京东用户行为打标',
|
||||
description: '基于消费行为为京东用户打行为类标签',
|
||||
promptTemplate: DEFAULT_PROMPTS.behavior.prompt,
|
||||
sourceDb: 'KR_京东',
|
||||
sourceCollection: 'users',
|
||||
targetTags: ['高频用户', '活跃用户', '普通用户', '沉默用户', '新用户'],
|
||||
status: 'running',
|
||||
progress: 65,
|
||||
stats: {
|
||||
totalRecords: 89000000,
|
||||
processedRecords: 57850000,
|
||||
taggedRecords: 55000000,
|
||||
accuracy: 94.2,
|
||||
},
|
||||
createdAt: '2024-01-20',
|
||||
lastRunAt: '2024-01-30 15:00',
|
||||
},
|
||||
{
|
||||
id: 'task_3',
|
||||
name: '存客宝用户偏好打标',
|
||||
description: '基于CRM数据为存客宝用户打偏好标签',
|
||||
promptTemplate: DEFAULT_PROMPTS.preference.prompt,
|
||||
sourceDb: 'cunkebao_v3',
|
||||
sourceCollection: 'users',
|
||||
targetTags: ['电商活跃', '社交达人', '金融偏好', '游戏玩家'],
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
stats: {
|
||||
totalRecords: 216000,
|
||||
processedRecords: 0,
|
||||
taggedRecords: 0,
|
||||
accuracy: 0,
|
||||
},
|
||||
createdAt: '2024-01-25',
|
||||
},
|
||||
]
|
||||
|
||||
export default function SmartTagPage() {
|
||||
const [tasks, setTasks] = useState<TaggingTask[]>(DEFAULT_TASKS)
|
||||
const [activeTab, setActiveTab] = useState("tasks")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedTask, setSelectedTask] = useState<TaggingTask | null>(null)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showReviewDialog, setShowReviewDialog] = useState(false)
|
||||
const [selectedTask, setSelectedTask] = useState<any>(null)
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([])
|
||||
const [showPromptDialog, setShowPromptDialog] = useState(false)
|
||||
const [editingPrompt, setEditingPrompt] = useState('')
|
||||
const [selectedPromptType, setSelectedPromptType] = useState<keyof typeof DEFAULT_PROMPTS>('value')
|
||||
|
||||
// AI打标任务列表
|
||||
const tagTasks = [
|
||||
{
|
||||
id: "1",
|
||||
name: "高价值用户识别",
|
||||
prompt: "根据用户的消费金额、频次、最近活跃时间,识别高价值用户",
|
||||
status: "completed",
|
||||
targetTag: "高价值用户",
|
||||
dataSource: "用户表+交易表",
|
||||
affectedCount: 45678,
|
||||
confidence: 0.92,
|
||||
createdAt: "2026-01-30 10:00",
|
||||
completedAt: "2026-01-30 10:15",
|
||||
needReview: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "流失风险预警",
|
||||
prompt: "识别最近30天未登录且之前活跃的用户,标记为流失风险",
|
||||
status: "pending_review",
|
||||
targetTag: "流失风险",
|
||||
dataSource: "用户行为表",
|
||||
affectedCount: 12456,
|
||||
confidence: 0.87,
|
||||
createdAt: "2026-01-31 09:00",
|
||||
completedAt: "2026-01-31 09:12",
|
||||
needReview: true,
|
||||
reviewStats: { approved: 0, rejected: 0, pending: 12456 },
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "兴趣标签推断",
|
||||
prompt: "根据用户浏览和购买记录,推断用户兴趣偏好",
|
||||
status: "running",
|
||||
targetTag: "兴趣标签",
|
||||
dataSource: "行为日志表",
|
||||
affectedCount: 0,
|
||||
confidence: 0,
|
||||
createdAt: "2026-01-31 14:00",
|
||||
progress: 67,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "地域特征识别",
|
||||
prompt: "根据IP地址和收货地址,识别用户地域特征",
|
||||
status: "failed",
|
||||
targetTag: "地域标签",
|
||||
dataSource: "用户表+订单表",
|
||||
affectedCount: 0,
|
||||
confidence: 0,
|
||||
createdAt: "2026-01-29 15:00",
|
||||
error: "数据源连接超时",
|
||||
},
|
||||
]
|
||||
|
||||
// 待审核的标签结果
|
||||
const pendingReviews = [
|
||||
{ id: "r1", userId: "U10001", userName: "张三", currentTags: ["活跃用户"], newTag: "流失风险", confidence: 0.92, reason: "30天未登录" },
|
||||
{ id: "r2", userId: "U10002", userName: "李四", currentTags: ["普通用户"], newTag: "流失风险", confidence: 0.88, reason: "25天未登录" },
|
||||
{ id: "r3", userId: "U10003", userName: "王五", currentTags: ["新用户"], newTag: "流失风险", confidence: 0.75, reason: "注册后未活跃" },
|
||||
{ id: "r4", userId: "U10004", userName: "赵六", currentTags: ["VIP用户"], newTag: "流失风险", confidence: 0.65, reason: "消费频次下降" },
|
||||
{ id: "r5", userId: "U10005", userName: "钱七", currentTags: ["活跃用户"], newTag: "流失风险", confidence: 0.58, reason: "互动减少" },
|
||||
]
|
||||
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 getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <Badge className="bg-green-100 text-green-700">已完成</Badge>
|
||||
case "running":
|
||||
return <Badge className="bg-blue-100 text-blue-700">执行中</Badge>
|
||||
case "pending_review":
|
||||
return <Badge className="bg-yellow-100 text-yellow-700">待审核</Badge>
|
||||
case "failed":
|
||||
return <Badge className="bg-red-100 text-red-700">失败</Badge>
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-100 text-green-700"><CheckCircle2 className="h-3 w-3 mr-1" />已完成</Badge>
|
||||
case 'running':
|
||||
return <Badge className="bg-blue-100 text-blue-700"><Loader2 className="h-3 w-3 mr-1 animate-spin" />运行中</Badge>
|
||||
case 'paused':
|
||||
return <Badge className="bg-yellow-100 text-yellow-700"><Pause className="h-3 w-3 mr-1" />已暂停</Badge>
|
||||
default:
|
||||
return <Badge variant="secondary">未知</Badge>
|
||||
return <Badge className="bg-gray-100 text-gray-700">待执行</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return "text-green-600"
|
||||
if (confidence >= 0.6) return "text-yellow-600"
|
||||
return "text-red-600"
|
||||
// 模拟进度更新
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTasks(prev => prev.map(t => {
|
||||
if (t.status === 'running' && t.progress < 100) {
|
||||
const newProgress = Math.min(t.progress + Math.random() * 2, 100)
|
||||
return {
|
||||
...t,
|
||||
progress: newProgress,
|
||||
status: newProgress >= 100 ? 'completed' as const : t.status,
|
||||
stats: {
|
||||
...t.stats,
|
||||
processedRecords: Math.floor(t.stats.totalRecords * newProgress / 100),
|
||||
taggedRecords: Math.floor(t.stats.totalRecords * newProgress / 100 * 0.95),
|
||||
}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}))
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const runTask = (taskId: string) => {
|
||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: 'running' as const } : t))
|
||||
}
|
||||
|
||||
const handleOpenReview = (task: any) => {
|
||||
const pauseTask = (taskId: string) => {
|
||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: 'paused' as const } : t))
|
||||
}
|
||||
|
||||
const openPromptEditor = (task: TaggingTask) => {
|
||||
setSelectedTask(task)
|
||||
setShowReviewDialog(true)
|
||||
setEditingPrompt(task.promptTemplate)
|
||||
setShowPromptDialog(true)
|
||||
}
|
||||
|
||||
const handleBatchApprove = () => {
|
||||
console.log("批量通过", selectedItems)
|
||||
setSelectedItems([])
|
||||
}
|
||||
|
||||
const handleBatchReject = () => {
|
||||
console.log("批量拒绝", selectedItems)
|
||||
setSelectedItems([])
|
||||
const savePrompt = () => {
|
||||
if (selectedTask) {
|
||||
setTasks(tasks.map(t => t.id === selectedTask.id ? { ...t, promptTemplate: editingPrompt } : t))
|
||||
setShowPromptDialog(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI智能打标</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">使用AI自动识别用户特征并打标签,支持人工审核确认</p>
|
||||
<p className="text-sm text-gray-500 mt-1">配置提示词,让AI自动为用户打标签</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建打标任务
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80 backdrop-blur">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">任务总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{tagTasks.length}</p>
|
||||
<p className="text-sm text-gray-500">打标任务</p>
|
||||
<p className="text-2xl font-bold">{tasks.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-purple-100">
|
||||
<Sparkles className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<Bot className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">待审核</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">
|
||||
{tagTasks.filter((t) => t.status === "pending_review").length}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<Card className="border-0 shadow-sm bg-white/80 backdrop-blur">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">已打标用户</p>
|
||||
<p className="text-2xl font-bold text-green-600">58,134</p>
|
||||
<p className="text-2xl font-bold text-green-600">{formatNumber(tasks.reduce((sum, t) => sum + t.stats.taggedRecords, 0))}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-green-100">
|
||||
<Tags className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<Tags className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<Card className="border-0 shadow-sm bg-white/80 backdrop-blur">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">平均置信度</p>
|
||||
<p className="text-2xl font-bold text-blue-600">87.5%</p>
|
||||
<p className="text-sm text-gray-500">平均准确率</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{(tasks.filter(t => t.stats.accuracy > 0).reduce((sum, t) => sum + t.stats.accuracy, 0) / tasks.filter(t => t.stats.accuracy > 0).length || 0).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-blue-100">
|
||||
<Target className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80 backdrop-blur">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">运行中</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{tasks.filter(t => t.status === 'running').length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-orange-100">
|
||||
<Zap className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<Sparkles className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{/* 标签页 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="bg-white">
|
||||
<TabsTrigger value="tasks">打标任务</TabsTrigger>
|
||||
<TabsTrigger value="review">审核中心</TabsTrigger>
|
||||
<TabsTrigger value="history">历史记录</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索任务..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 w-64 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TabsList className="bg-white/80 backdrop-blur">
|
||||
<TabsTrigger value="tasks">打标任务</TabsTrigger>
|
||||
<TabsTrigger value="prompts">提示词模板</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
{tagTasks.map((task) => (
|
||||
<Card key={task.id} className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{/* 打标任务列表 */}
|
||||
<TabsContent value="tasks" className="mt-4 space-y-3">
|
||||
{tasks.map(task => (
|
||||
<Card key={task.id} className="border-0 shadow-sm bg-white/80 backdrop-blur hover:shadow-lg transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-r from-purple-500 to-blue-500">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{task.name}</h3>
|
||||
{getStatusBadge(task.status)}
|
||||
<Badge variant="outline">{task.targetTag}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">{task.prompt}</p>
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500">
|
||||
<span>数据源:{task.dataSource}</span>
|
||||
{task.affectedCount > 0 && (
|
||||
<span>影响用户:{task.affectedCount.toLocaleString()}</span>
|
||||
)}
|
||||
{task.confidence > 0 && (
|
||||
<span className={getConfidenceColor(task.confidence)}>
|
||||
置信度:{(task.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
<span>创建时间:{task.createdAt}</span>
|
||||
<p className="text-sm text-gray-500">{task.description}</p>
|
||||
<div className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{task.sourceDb}.{task.sourceCollection}
|
||||
</div>
|
||||
{task.status === "running" && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">执行进度</span>
|
||||
<span className="text-blue-600">{task.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<div className="mt-3 p-3 bg-red-50 rounded-lg text-sm text-red-600">
|
||||
错误信息:{task.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.status === "pending_review" && (
|
||||
<Button size="sm" onClick={() => handleOpenReview(task)}>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
审核
|
||||
</Button>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<Button size="sm" variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
查看日志
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
编辑配置
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openPromptEditor(task)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
编辑提示词
|
||||
</Button>
|
||||
{task.status === 'idle' && (
|
||||
<Button size="sm" onClick={() => runTask(task.id)}>
|
||||
<Play className="h-4 w-4 mr-1" /> 执行
|
||||
</Button>
|
||||
)}
|
||||
{task.status === 'running' && (
|
||||
<Button size="sm" variant="outline" onClick={() => pauseTask(task.id)}>
|
||||
<Pause className="h-4 w-4 mr-1" /> 暂停
|
||||
</Button>
|
||||
)}
|
||||
{task.status === 'paused' && (
|
||||
<Button size="sm" onClick={() => runTask(task.id)}>
|
||||
<Play className="h-4 w-4 mr-1" /> 继续
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">处理进度</span>
|
||||
<span className="font-medium">{task.progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={task.progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 目标标签 */}
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{task.targetTags.map((tag, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="grid grid-cols-4 gap-4 text-sm pt-3 border-t">
|
||||
<div>
|
||||
<span className="text-gray-500">总记录:</span>
|
||||
<span className="ml-2 font-medium">{formatNumber(task.stats.totalRecords)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">已处理:</span>
|
||||
<span className="ml-2 font-medium text-blue-600">{formatNumber(task.stats.processedRecords)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">已打标:</span>
|
||||
<span className="ml-2 font-medium text-green-600">{formatNumber(task.stats.taggedRecords)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">准确率:</span>
|
||||
<span className="ml-2 font-medium text-purple-600">{task.stats.accuracy}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* 提示词模板 */}
|
||||
<TabsContent value="prompts" className="mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Object.entries(DEFAULT_PROMPTS).map(([key, config]) => (
|
||||
<Card key={key} className="border-0 shadow-sm bg-white/80 backdrop-blur">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-purple-500" />
|
||||
{config.name}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-xs text-gray-600 bg-gray-50 p-3 rounded-lg overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto">
|
||||
{config.prompt}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="review" className="mt-4">
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">待审核标签(共 {pendingReviews.length} 条)</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedItems.length > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">已选 {selectedItems.length} 项</span>
|
||||
<Button size="sm" onClick={handleBatchApprove}>
|
||||
<ThumbsUp className="h-4 w-4 mr-1" />
|
||||
批量通过
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleBatchReject}>
|
||||
<ThumbsDown className="h-4 w-4 mr-1" />
|
||||
批量拒绝
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{pendingReviews.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||
selectedItems.includes(item.id) ? "bg-blue-50 border-blue-200" : "bg-gray-50 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox
|
||||
checked={selectedItems.includes(item.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedItems([...selectedItems, item.id])
|
||||
} else {
|
||||
setSelectedItems(selectedItems.filter((id) => id !== item.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{item.userName}</span>
|
||||
<span className="text-sm text-gray-500">({item.userId})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-gray-500">现有标签:</span>
|
||||
{item.currentTags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
<span className="text-gray-400">→</span>
|
||||
<Badge className="bg-yellow-100 text-yellow-700 text-xs">
|
||||
+ {item.newTag}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">原因:{item.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">置信度</p>
|
||||
<p className={`font-semibold ${getConfidenceColor(item.confidence)}`}>
|
||||
{(item.confidence * 100).toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="text-green-600 border-green-200 hover:bg-green-50 bg-transparent">
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="text-red-600 border-red-200 hover:bg-red-50 bg-transparent">
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="mt-4">
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-6 text-center text-gray-500">
|
||||
历史记录将显示所有已完成的打标任务及其审核结果
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 创建任务弹窗 */}
|
||||
{/* 新建任务弹窗 */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建AI打标任务</DialogTitle>
|
||||
<DialogDescription>配置AI打标规则,AI将自动识别符合条件的用户并打标签</DialogDescription>
|
||||
<DialogTitle>新建AI打标任务</DialogTitle>
|
||||
<DialogDescription>配置数据源和打标提示词</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>任务名称</Label>
|
||||
<Input placeholder="例如:高价值用户识别" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>AI提示词</Label>
|
||||
<Textarea
|
||||
placeholder="描述你希望AI如何识别用户,例如:根据用户的消费金额、频次、最近活跃时间,识别高价值用户"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-400">
|
||||
提示:清晰描述识别条件,AI将自动分析数据并打标签
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>目标标签</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择或创建标签" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high-value">高价值用户</SelectItem>
|
||||
<SelectItem value="churn-risk">流失风险</SelectItem>
|
||||
<SelectItem value="new-user">新用户</SelectItem>
|
||||
<SelectItem value="vip">VIP用户</SelectItem>
|
||||
<SelectItem value="custom">创建新标签...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>任务名称</Label>
|
||||
<Input placeholder="例如:高价值用户打标" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>数据源</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择数据源" />
|
||||
<SelectValue placeholder="选择数据库" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="users">用户表</SelectItem>
|
||||
<SelectItem value="transactions">交易表</SelectItem>
|
||||
<SelectItem value="behaviors">行为表</SelectItem>
|
||||
<SelectItem value="all">全部数据</SelectItem>
|
||||
<SelectItem value="kr_tencent">KR_腾讯.qq_phone</SelectItem>
|
||||
<SelectItem value="kr_weibo">KR_微博.users</SelectItem>
|
||||
<SelectItem value="kr_jd">KR_京东.users</SelectItem>
|
||||
<SelectItem value="ckb">cunkebao_v3.users</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>置信度阈值</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input type="number" defaultValue="0.7" min="0" max="1" step="0.1" className="w-24" />
|
||||
<span className="text-sm text-gray-500">低于此阈值的结果需要人工审核</span>
|
||||
</div>
|
||||
<Label>选择提示词模板</Label>
|
||||
<Select value={selectedPromptType} onValueChange={(v) => setSelectedPromptType(v as keyof typeof DEFAULT_PROMPTS)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(DEFAULT_PROMPTS).map(([key, config]) => (
|
||||
<SelectItem key={key} value={key}>{config.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="auto-approve" />
|
||||
<Label htmlFor="auto-approve" className="text-sm font-normal">
|
||||
高置信度结果自动通过(置信度 ≥ 90%)
|
||||
</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>提示词内容 <span className="text-gray-400 text-xs">(可自定义修改)</span></Label>
|
||||
<Textarea
|
||||
value={DEFAULT_PROMPTS[selectedPromptType].prompt}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>目标标签 <span className="text-gray-400 text-xs">(逗号分隔)</span></Label>
|
||||
<Input placeholder="高价值用户, 优质用户, 普通用户" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>取消</Button>
|
||||
<Button onClick={() => setShowCreateDialog(false)}>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
开始执行
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
创建任务
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 审核弹窗 */}
|
||||
<Dialog open={showReviewDialog} onOpenChange={setShowReviewDialog}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
{/* 编辑提示词弹窗 */}
|
||||
<Dialog open={showPromptDialog} onOpenChange={setShowPromptDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>审核 - {selectedTask?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
共 {selectedTask?.affectedCount.toLocaleString()} 条待审核,请确认AI打标结果
|
||||
</DialogDescription>
|
||||
<DialogTitle>编辑提示词</DialogTitle>
|
||||
<DialogDescription>{selectedTask?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4 max-h-96 overflow-y-auto">
|
||||
<div className="space-y-3">
|
||||
{pendingReviews.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<span className="font-medium">{item.userName}</span>
|
||||
<span className="text-gray-500 ml-2">({item.userId})</span>
|
||||
<p className="text-sm text-gray-500 mt-1">{item.reason}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={getConfidenceColor(item.confidence)}>
|
||||
{(item.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
<Button size="sm" variant="outline" className="text-green-600 bg-transparent">
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="text-red-600 bg-transparent">
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="p-3 rounded-lg bg-blue-50 text-sm text-blue-700">
|
||||
💡 提示词中使用 <code className="bg-blue-100 px-1 rounded">{'{user_data}'}</code> 作为用户数据占位符
|
||||
</div>
|
||||
<Textarea
|
||||
value={editingPrompt}
|
||||
onChange={(e) => setEditingPrompt(e.target.value)}
|
||||
rows={15}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowReviewDialog(false)}>
|
||||
关闭
|
||||
<Button variant="outline" onClick={() => setShowPromptDialog(false)}>取消</Button>
|
||||
<Button onClick={savePrompt}>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
保存提示词
|
||||
</Button>
|
||||
<Button variant="outline">全部拒绝</Button>
|
||||
<Button>全部通过</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,23 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Cpu, BarChart3, TrendingUp, Users, Target, Brain, ArrowRight, Send, Sparkles } from "lucide-react"
|
||||
import { Cpu, BarChart3, TrendingUp, Users, Target, Brain, ArrowRight, Send, Sparkles, Loader2 } from "lucide-react"
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface AIStatus {
|
||||
status: string
|
||||
model: string
|
||||
database?: {
|
||||
connected: boolean
|
||||
totalUsers: number
|
||||
latency: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function AIAssistant() {
|
||||
const [message, setMessage] = useState("")
|
||||
const [chatHistory, setChatHistory] = useState([
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [aiStatus, setAIStatus] = useState<AIStatus | null>(null)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "你好!我是神射手AI助手,可以帮助你分析用户数据、生成报告、预测趋势。有什么我可以帮助你的吗?",
|
||||
timestamp: "10:30",
|
||||
content: "你好!我是神射手AI助手,可以帮助你查询用户数据、分析RFM估值。\n\n💡 试试:\n- 查 13407000001\n- 28533368 qq\n- 系统状态\n- 帮助",
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
])
|
||||
|
||||
// 获取 AI 状态
|
||||
useEffect(() => {
|
||||
fetch("/api/ai-chat")
|
||||
.then(res => res.json())
|
||||
.then(data => setAIStatus(data))
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [chatHistory])
|
||||
|
||||
const aiFeatures = [
|
||||
{
|
||||
icon: <BarChart3 className="w-8 h-8" />,
|
||||
@@ -53,24 +85,52 @@ export default function AIAssistant() {
|
||||
},
|
||||
]
|
||||
|
||||
const quickQuestions = ["分析今日新增用户特征", "预测本月用户增长趋势", "高价值用户流失预警", "生成本周运营报告"]
|
||||
const quickQuestions = ["查 13407000001", "系统状态", "RFM分析", "高价值用户 TOP10", "帮助"]
|
||||
|
||||
const handleSend = () => {
|
||||
if (!message.trim()) return
|
||||
setChatHistory([
|
||||
...chatHistory,
|
||||
{
|
||||
role: "user",
|
||||
content: message,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "正在分析您的问题,请稍候...",
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
},
|
||||
])
|
||||
const handleSend = async () => {
|
||||
if (!message.trim() || isLoading) return
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
role: "user",
|
||||
content: message,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
}
|
||||
|
||||
setChatHistory(prev => [...prev, userMessage])
|
||||
setMessage("")
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: userMessage.content })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.response) {
|
||||
setChatHistory(prev => [...prev, {
|
||||
role: "assistant",
|
||||
content: data.response.content,
|
||||
timestamp: data.response.timestamp || new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
}])
|
||||
} else {
|
||||
setChatHistory(prev => [...prev, {
|
||||
role: "assistant",
|
||||
content: `⚠️ ${data.error || "请求失败,请稍后重试"}`,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
}])
|
||||
}
|
||||
} catch (error: any) {
|
||||
setChatHistory(prev => [...prev, {
|
||||
role: "assistant",
|
||||
content: `⚠️ 网络错误: ${error.message}`,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
}])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,22 +157,24 @@ export default function AIAssistant() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">系统状态</span>
|
||||
<Badge variant="outline" className="border-green-200 text-green-600 bg-green-50">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse mr-2"></div>
|
||||
运行中
|
||||
<Badge variant="outline" className={`${aiStatus?.status === 'online' ? 'border-green-200 text-green-600 bg-green-50' : 'border-yellow-200 text-yellow-600 bg-yellow-50'}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${aiStatus?.status === 'online' ? 'bg-green-500' : 'bg-yellow-500'} animate-pulse mr-2`}></div>
|
||||
{aiStatus?.status === 'online' ? '运行中' : '加载中'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">模型版本</span>
|
||||
<span className="text-gray-900 font-semibold">v3.5</span>
|
||||
<span className="text-gray-900 font-semibold">{aiStatus?.model || '神射手 AI'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">响应速度</span>
|
||||
<span className="text-gray-900 font-semibold">0.8s</span>
|
||||
<span className="text-gray-600">响应延迟</span>
|
||||
<span className="text-gray-900 font-semibold">{aiStatus?.database?.latency || '--'}ms</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">准确率</span>
|
||||
<span className="text-green-600 font-semibold">94.2%</span>
|
||||
<span className="text-gray-600">数据量</span>
|
||||
<span className="text-green-600 font-semibold">
|
||||
{aiStatus?.database?.totalUsers ? `${(aiStatus.database.totalUsers / 1e8).toFixed(1)}亿` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -184,13 +246,24 @@ export default function AIAssistant() {
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl p-4 shadow-sm ${chat.role === "user" ? "bg-gradient-to-br from-blue-500 to-purple-500 text-white" : "bg-white border border-gray-100 text-gray-800"}`}
|
||||
>
|
||||
<div className="mb-1">{chat.content}</div>
|
||||
<div className="mb-1 whitespace-pre-wrap">{chat.content}</div>
|
||||
<div className={`text-xs ${chat.role === "user" ? "text-blue-100" : "text-gray-400"}`}>
|
||||
{chat.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-gray-100 rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>正在查询中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
@@ -224,9 +297,10 @@ export default function AIAssistant() {
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-[88px] w-20 shadow-md hover:shadow-lg transition-shadow"
|
||||
disabled={isLoading || !message.trim()}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-[88px] w-20 shadow-md hover:shadow-lg transition-shadow disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-6 h-6" />
|
||||
{isLoading ? <Loader2 className="w-6 h-6 animate-spin" /> : <Send className="w-6 h-6" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
315
app/api/ai-chat/route.ts
Normal file
315
app/api/ai-chat/route.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* AI 智能助手 API
|
||||
* 支持自然语言查询用户数据
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getMongoClient, intelligentSearch, queryFullProfile, getDatabaseStats } from "@/lib/mongodb"
|
||||
|
||||
// 消息类型
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "system"
|
||||
content: string
|
||||
timestamp?: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 解析用户意图
|
||||
function parseIntent(message: string): { type: string; query?: string; params?: any } {
|
||||
const msg = message.trim().toLowerCase()
|
||||
|
||||
// 查询手机号
|
||||
const phoneMatch = message.match(/(?:查|查询|搜索|找)?[::\s]*(\+?86)?1[3-9]\d{9}/g)
|
||||
if (phoneMatch) {
|
||||
const phone = phoneMatch[0].replace(/[查询搜索找::\s]/g, '').replace(/^\+?86/, '')
|
||||
return { type: "query_phone", query: phone }
|
||||
}
|
||||
|
||||
// 查询 QQ
|
||||
const qqMatch = message.match(/(?:qq|QQ)[::\s]*(\d{5,11})|(\d{5,11})\s*(?:qq|QQ)/i)
|
||||
if (qqMatch) {
|
||||
return { type: "query_qq", query: qqMatch[1] || qqMatch[2] }
|
||||
}
|
||||
|
||||
// 系统状态
|
||||
if (msg.includes("状态") || msg.includes("统计") || msg.includes("总量")) {
|
||||
return { type: "system_status" }
|
||||
}
|
||||
|
||||
// RFM 分析
|
||||
if (msg.includes("rfm") || msg.includes("估值") || msg.includes("价值")) {
|
||||
return { type: "rfm_analysis" }
|
||||
}
|
||||
|
||||
// 高价值用户
|
||||
if (msg.includes("高价值") || msg.includes("top") || msg.includes("排行")) {
|
||||
const limitMatch = msg.match(/(\d+)/)
|
||||
return { type: "high_value_users", params: { limit: limitMatch ? parseInt(limitMatch[1]) : 10 } }
|
||||
}
|
||||
|
||||
// 帮助
|
||||
if (msg.includes("帮助") || msg.includes("help") || msg === "?") {
|
||||
return { type: "help" }
|
||||
}
|
||||
|
||||
// 通用搜索
|
||||
return { type: "search", query: message }
|
||||
}
|
||||
|
||||
// 格式化用户数据
|
||||
function formatUserData(user: any): string {
|
||||
if (!user) return "未找到用户信息"
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (user.name) lines.push(`👤 姓名: ${user.name}`)
|
||||
if (user.phone_masked) lines.push(`📱 手机: ${user.phone_masked}`)
|
||||
if (user.qq) lines.push(`💬 QQ: ${user.qq}`)
|
||||
if (user.gender) lines.push(`⚧ 性别: ${user.gender}`)
|
||||
if (user.age_range) lines.push(`📅 年龄段: ${user.age_range}`)
|
||||
if (user.province || user.city) lines.push(`📍 地区: ${user.province || ''}${user.city || ''}`)
|
||||
if (user.evaluation_score || user.user_evaluation_score) {
|
||||
lines.push(`⭐ 估值分: ${user.evaluation_score || user.user_evaluation_score}`)
|
||||
}
|
||||
if (user.user_level) lines.push(`🏆 等级: ${user.user_level}`)
|
||||
if (user.carrier) lines.push(`📶 运营商: ${user.carrier}`)
|
||||
if (user.tags && user.tags.length > 0) lines.push(`🏷️ 标签: ${user.tags.join(', ')}`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// 处理 AI 聊天
|
||||
async function processChat(message: string): Promise<ChatMessage> {
|
||||
const startTime = Date.now()
|
||||
const intent = parseIntent(message)
|
||||
|
||||
try {
|
||||
switch (intent.type) {
|
||||
case "query_phone": {
|
||||
const result = await queryFullProfile(intent.query!)
|
||||
if (result.valuation || result.qqPhone) {
|
||||
const user = {
|
||||
...result.valuation,
|
||||
qq: result.qqPhone?.qq,
|
||||
carrier: result.qqPhone?.运营商
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `🎯 手机号 ${intent.query} 查询结果:\n\n${formatUserData(user)}\n\n⏱️ 查询耗时: ${Date.now() - startTime}ms`,
|
||||
data: result
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `❌ 未找到手机号 ${intent.query} 的相关信息\n\n💡 提示: 请检查手机号是否正确(11位数字)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "query_qq": {
|
||||
const client = await getMongoClient()
|
||||
const qqDb = client.db("KR_腾讯")
|
||||
let qqDoc = await qqDb.collection("QQ+手机").findOne({ qq: intent.query })
|
||||
if (!qqDoc) {
|
||||
qqDoc = await qqDb.collection("QQ+手机").findOne({ qq: parseInt(intent.query!) })
|
||||
}
|
||||
|
||||
if (qqDoc) {
|
||||
const phone = qqDoc.phone || qqDoc.手机号
|
||||
let userInfo = `🎯 QQ ${intent.query} 查询结果:\n\n`
|
||||
userInfo += `💬 QQ: ${qqDoc.qq}\n`
|
||||
userInfo += `📱 手机: ${phone ? phone.toString().replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '未知'}\n`
|
||||
userInfo += `📊 QQ评分: ${qqDoc.QQ号评分 || 'N/A'}\n`
|
||||
userInfo += `📊 手机评分: ${qqDoc.手机号评分 || 'N/A'}\n`
|
||||
userInfo += `📍 地区: ${qqDoc.省份 || ''}${qqDoc.地区 || ''}\n`
|
||||
userInfo += `📶 运营商: ${qqDoc.运营商 || 'N/A'}\n`
|
||||
userInfo += `\n⏱️ 查询耗时: ${Date.now() - startTime}ms`
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content: userInfo,
|
||||
data: qqDoc
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `❌ 未找到 QQ ${intent.query} 的相关信息`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "system_status": {
|
||||
const stats = await getDatabaseStats()
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `📊 神射手系统状态\n\n` +
|
||||
`🟢 连接状态: ${stats.connected ? '正常' : '异常'}\n` +
|
||||
`📁 数据库数: ${stats.databases?.length || 0} 个\n` +
|
||||
`📄 总记录数: ${(stats.totalDocuments / 1e8).toFixed(2)} 亿条\n` +
|
||||
`💾 总数据量: ${(stats.totalSize / 1e9).toFixed(2)} GB\n` +
|
||||
`⏱️ 响应延迟: ${stats.latency}ms`,
|
||||
data: stats
|
||||
}
|
||||
}
|
||||
|
||||
case "rfm_analysis": {
|
||||
const client = await getMongoClient()
|
||||
const db = client.db("KR")
|
||||
const pipeline = [
|
||||
{ $group: { _id: "$user_level", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]
|
||||
const results = await db.collection("用户估值").aggregate(pipeline).toArray()
|
||||
|
||||
let content = `📈 RFM 用户分布统计\n\n`
|
||||
let total = 0
|
||||
results.forEach(r => {
|
||||
const level = r._id || '未分级'
|
||||
content += `${level}: ${(r.count / 10000).toFixed(1)}万\n`
|
||||
total += r.count
|
||||
})
|
||||
content += `\n📊 总用户数: ${(total / 10000).toFixed(1)}万`
|
||||
content += `\n⏱️ 统计耗时: ${Date.now() - startTime}ms`
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
data: results
|
||||
}
|
||||
}
|
||||
|
||||
case "high_value_users": {
|
||||
const limit = intent.params?.limit || 10
|
||||
const client = await getMongoClient()
|
||||
const db = client.db("KR")
|
||||
const users = await db.collection("用户估值")
|
||||
.find({ user_evaluation_score: { $exists: true } })
|
||||
.sort({ user_evaluation_score: -1 })
|
||||
.limit(limit)
|
||||
.toArray()
|
||||
|
||||
let content = `🏆 高价值用户 TOP${limit}\n\n`
|
||||
users.forEach((u, i) => {
|
||||
const phone = u.phone?.replace(/(\+?86)?(\d{3})\d{4}(\d{4})/, '$2****$3') || '***'
|
||||
content += `${i + 1}. ${u.name || '未知'} (${phone}) - 估值: ${u.user_evaluation_score}\n`
|
||||
})
|
||||
content += `\n⏱️ 查询耗时: ${Date.now() - startTime}ms`
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
data: users
|
||||
}
|
||||
}
|
||||
|
||||
case "help": {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `🎯 神射手 AI 助手使用指南\n\n` +
|
||||
`📱 查询手机号:\n "查 13800138000" 或 "13800138000"\n\n` +
|
||||
`💬 查询 QQ:\n "28533368 qq" 或 "qq 28533368"\n\n` +
|
||||
`📊 系统状态:\n "系统状态" 或 "统计"\n\n` +
|
||||
`📈 RFM 分析:\n "RFM分析" 或 "用户估值"\n\n` +
|
||||
`🏆 高价值用户:\n "高价值用户 TOP10"\n\n` +
|
||||
`💡 数据覆盖: 20亿+用户,207GB数据`
|
||||
}
|
||||
}
|
||||
|
||||
case "search":
|
||||
default: {
|
||||
// 尝试智能搜索
|
||||
const result = await intelligentSearch(intent.query || message, { limit: 5 })
|
||||
if (result.total > 0) {
|
||||
let content = `🔍 搜索结果 (共${result.total}条)\n\n`
|
||||
result.users.slice(0, 5).forEach((u, i) => {
|
||||
content += `${i + 1}. ${u.name || '未知'} - ${u.city || ''} - 估值: ${u.user_evaluation_score || 'N/A'}\n`
|
||||
})
|
||||
content += `\n⏱️ 查询耗时: ${Date.now() - startTime}ms`
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
data: result
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `🤔 我不太理解 "${message}"\n\n` +
|
||||
`💡 你可以尝试:\n` +
|
||||
`- 查询手机号: "查 13800138000"\n` +
|
||||
`- 查询QQ: "28533368 qq"\n` +
|
||||
`- 输入 "帮助" 查看更多功能`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("AI Chat 错误:", error)
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `⚠️ 查询出错: ${error.message}\n\n请稍后重试或检查数据库连接`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai-chat
|
||||
* AI 对话接口
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { message, history = [] } = body
|
||||
|
||||
if (!message || !message.trim()) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "消息不能为空"
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await processChat(message)
|
||||
response.timestamp = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("AI Chat API 错误:", error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
response: {
|
||||
role: "assistant",
|
||||
content: `⚠️ 服务暂时不可用: ${error.message}`,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai-chat
|
||||
* 获取 AI 助手状态
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const stats = await getDatabaseStats()
|
||||
return NextResponse.json({
|
||||
status: "online",
|
||||
model: "神射手 AI v1.0",
|
||||
capabilities: ["用户查询", "QQ查询", "RFM分析", "智能搜索"],
|
||||
database: {
|
||||
connected: stats.connected,
|
||||
totalUsers: stats.totalDocuments,
|
||||
latency: stats.latency
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
status: "degraded",
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
303
app/api/ai-tagging/route.ts
Normal file
303
app/api/ai-tagging/route.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* AI 打标 API
|
||||
* 提供智能标签生成、批量打标、标签推荐等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, queryFullProfile, maskPhone } from '@/lib/mongodb'
|
||||
|
||||
// AI 标签规则
|
||||
interface TagRule {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
conditions: {
|
||||
field: string
|
||||
operator: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'in'
|
||||
value: any
|
||||
}[]
|
||||
resultTag: string
|
||||
category: string
|
||||
priority: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 预定义的 AI 标签规则
|
||||
const AI_TAG_RULES: TagRule[] = [
|
||||
{
|
||||
id: 'rule_1',
|
||||
name: '钻石用户识别',
|
||||
description: 'RFM评分≥80的用户标记为钻石用户',
|
||||
conditions: [
|
||||
{ field: 'rfm_composite_score', operator: 'gte', value: 80 }
|
||||
],
|
||||
resultTag: '钻石用户',
|
||||
category: 'value',
|
||||
priority: 1,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_2',
|
||||
name: '高频活跃用户',
|
||||
description: '近30天活跃次数>10的用户',
|
||||
conditions: [
|
||||
{ field: 'activity_count_30d', operator: 'gt', value: 10 }
|
||||
],
|
||||
resultTag: '高频活跃',
|
||||
category: 'behavior',
|
||||
priority: 2,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_3',
|
||||
name: '流失风险用户',
|
||||
description: '90天未活跃的用户',
|
||||
conditions: [
|
||||
{ field: 'last_active_days', operator: 'gt', value: 90 }
|
||||
],
|
||||
resultTag: '流失风险',
|
||||
category: 'lifecycle',
|
||||
priority: 3,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_4',
|
||||
name: '一线城市用户',
|
||||
description: '来自北上广深的用户',
|
||||
conditions: [
|
||||
{ field: 'city', operator: 'in', value: ['北京', '上海', '广州', '深圳'] }
|
||||
],
|
||||
resultTag: '一线城市',
|
||||
category: 'region',
|
||||
priority: 4,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_5',
|
||||
name: '厦门本地用户',
|
||||
description: '来自厦门的用户',
|
||||
conditions: [
|
||||
{ field: 'city', operator: 'eq', value: '厦门' }
|
||||
],
|
||||
resultTag: '厦门本地',
|
||||
category: 'region',
|
||||
priority: 5,
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
|
||||
// 根据规则计算用户标签
|
||||
function computeTagsForUser(userData: any, rules: TagRule[]): string[] {
|
||||
const tags: string[] = []
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) continue
|
||||
|
||||
let match = true
|
||||
for (const condition of rule.conditions) {
|
||||
const fieldValue = userData[condition.field]
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
match = match && fieldValue === condition.value
|
||||
break
|
||||
case 'ne':
|
||||
match = match && fieldValue !== condition.value
|
||||
break
|
||||
case 'gt':
|
||||
match = match && fieldValue > condition.value
|
||||
break
|
||||
case 'lt':
|
||||
match = match && fieldValue < condition.value
|
||||
break
|
||||
case 'gte':
|
||||
match = match && fieldValue >= condition.value
|
||||
break
|
||||
case 'lte':
|
||||
match = match && fieldValue <= condition.value
|
||||
break
|
||||
case 'contains':
|
||||
match = match && String(fieldValue).includes(condition.value)
|
||||
break
|
||||
case 'in':
|
||||
match = match && Array.isArray(condition.value) && condition.value.includes(fieldValue)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
tags.push(rule.resultTag)
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// 为单个用户生成AI标签
|
||||
async function generateTagsForUser(phone: string): Promise<{
|
||||
phone: string
|
||||
phoneMasked: string
|
||||
originalTags: string[]
|
||||
newTags: string[]
|
||||
allTags: string[]
|
||||
}> {
|
||||
const profile = await queryFullProfile(phone)
|
||||
|
||||
const userData = {
|
||||
...profile.valuation,
|
||||
city: profile.valuation?.city || profile.qqPhone?.地区,
|
||||
province: profile.valuation?.province || profile.qqPhone?.省份
|
||||
}
|
||||
|
||||
const originalTags = profile.valuation?.tags || []
|
||||
const newTags = computeTagsForUser(userData, AI_TAG_RULES)
|
||||
const allTags = [...new Set([...originalTags, ...newTags])]
|
||||
|
||||
return {
|
||||
phone,
|
||||
phoneMasked: maskPhone(phone),
|
||||
originalTags,
|
||||
newTags,
|
||||
allTags
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取 AI 标签规则或统计
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const phone = searchParams.get('phone')
|
||||
|
||||
try {
|
||||
// 获取标签规则
|
||||
if (action === 'rules') {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rules: AI_TAG_RULES,
|
||||
total: AI_TAG_RULES.length
|
||||
})
|
||||
}
|
||||
|
||||
// 为单个用户生成标签
|
||||
if (phone) {
|
||||
const result = await generateTagsForUser(phone)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
// AI 打标统计
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
const stats = await collection.aggregate([
|
||||
{ $match: { tags: { $exists: true, $ne: [] } } },
|
||||
{ $unwind: '$tags' },
|
||||
{ $group: { _id: '$tags', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
]).toArray()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rules: AI_TAG_RULES,
|
||||
tagDistribution: stats.map(s => ({
|
||||
tag: s._id,
|
||||
count: s.count
|
||||
}))
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('AI 打标 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 执行批量打标或创建规则
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { action, phones, rule, filters } = body
|
||||
|
||||
// 批量打标
|
||||
if (action === 'batch') {
|
||||
const results = []
|
||||
const phonesToProcess = phones || []
|
||||
|
||||
for (const phone of phonesToProcess.slice(0, 100)) {
|
||||
try {
|
||||
const result = await generateTagsForUser(phone)
|
||||
results.push(result)
|
||||
} catch (e) {
|
||||
results.push({
|
||||
phone,
|
||||
phoneMasked: maskPhone(phone),
|
||||
error: '处理失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed: results.length,
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
// 按条件批量打标
|
||||
if (action === 'batch_by_filter') {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 构建查询条件
|
||||
const query: any = {}
|
||||
if (filters?.province) query.province = filters.province
|
||||
if (filters?.city) query.city = filters.city
|
||||
if (filters?.userLevel) query.user_level = filters.userLevel
|
||||
|
||||
const count = await collection.countDocuments(query)
|
||||
|
||||
// 模拟批量任务创建
|
||||
const taskId = `task_${Date.now()}`
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId,
|
||||
estimatedUsers: count,
|
||||
message: `已创建批量打标任务,预计处理 ${count} 个用户`
|
||||
})
|
||||
}
|
||||
|
||||
// 创建标签规则
|
||||
if (action === 'create_rule') {
|
||||
const newRule: TagRule = {
|
||||
id: `rule_${Date.now()}`,
|
||||
...rule,
|
||||
enabled: true
|
||||
}
|
||||
|
||||
// TODO: 保存到数据库
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rule: newRule,
|
||||
message: '标签规则创建成功'
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未知操作'
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
77
app/api/auth/login/route.ts
Normal file
77
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
/**
|
||||
* 本地登录 API - 支持邮箱/手机号 + 密码
|
||||
* 当未配置 NEXT_PUBLIC_API_BASE_URL 时使用
|
||||
* 开发账号: zhiqun@qq.com / Zhiqun1984
|
||||
*/
|
||||
const MOCK_USERS: Record<string, { password: string }> = {
|
||||
"zhiqun@qq.com": { password: "Zhiqun1984" },
|
||||
}
|
||||
|
||||
function isEmail(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const account = (formData.get("email") || formData.get("phone") || "").toString().trim()
|
||||
const password = (formData.get("password") || "").toString()
|
||||
const verificationCode = formData.get("verificationCode")?.toString()
|
||||
|
||||
if (!account) {
|
||||
return NextResponse.json(
|
||||
{ code: 40001, message: "请输入邮箱或手机号" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证码登录:开发环境下任意6位验证码通过
|
||||
if (verificationCode) {
|
||||
if (verificationCode.length >= 4) {
|
||||
const token = `mock_token_${Date.now()}_${account}`
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "登录成功",
|
||||
data: { token },
|
||||
})
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ code: 40002, message: "验证码错误" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
if (!password) {
|
||||
return NextResponse.json(
|
||||
{ code: 40003, message: "请输入密码" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
const key = isEmail(account) ? account : account
|
||||
const user = MOCK_USERS[key]
|
||||
|
||||
if (user && user.password === password) {
|
||||
const token = `mock_token_${Date.now()}_${account}`
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "登录成功",
|
||||
data: { token },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ code: 40004, message: "邮箱/手机号或密码错误" },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[auth/login]", error)
|
||||
return NextResponse.json(
|
||||
{ code: 50000, message: "服务器错误" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
app/api/auth/send-code/route.ts
Normal file
31
app/api/auth/send-code/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
/**
|
||||
* 本地验证码发送 API (mock)
|
||||
* 开发环境下直接返回成功,验证码可为任意4位以上
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const phone = (formData.get("phone") || "").toString().trim()
|
||||
|
||||
if (!phone) {
|
||||
return NextResponse.json(
|
||||
{ code: 40001, message: "请输入手机号" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// Mock: 模拟发送成功,开发时可用 123456 等作为验证码
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "验证码已发送(开发模式:可使用任意4位以上数字)",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[auth/send-code]", error)
|
||||
return NextResponse.json(
|
||||
{ code: 50000, message: "服务器错误" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
185
app/api/channels/route.ts
Normal file
185
app/api/channels/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// 渠道配置接口
|
||||
interface ChannelConfig {
|
||||
id: string
|
||||
type: 'feishu' | 'wecom' | 'wechat' | 'api'
|
||||
name: string
|
||||
status: 'active' | 'pending' | 'error'
|
||||
config: Record<string, string>
|
||||
lastChecked?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 网关地址(卡若AI部署的网关)
|
||||
const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8000'
|
||||
|
||||
// GET: 获取渠道状态
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const type = searchParams.get('type')
|
||||
|
||||
try {
|
||||
// 测试飞书连接
|
||||
if (action === 'test' && type === 'feishu') {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/test`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json({
|
||||
success: data.status === 'success',
|
||||
message: data.message,
|
||||
appId: data.app_id
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '网关连接失败,请确认卡若AI网关已启动',
|
||||
error: String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取飞书群列表
|
||||
if (action === 'chats' && type === 'feishu') {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/chats?refresh=true`)
|
||||
const data = await res.json()
|
||||
return NextResponse.json({
|
||||
success: data.success,
|
||||
chats: data.chats || [],
|
||||
count: data.count || 0
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '获取群列表失败',
|
||||
chats: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有渠道状态
|
||||
const channels: ChannelConfig[] = [
|
||||
{
|
||||
id: 'feishu-1',
|
||||
type: 'feishu',
|
||||
name: '飞书机器人',
|
||||
status: 'pending',
|
||||
config: {
|
||||
webhookUrl: `${GATEWAY_URL}/feishu/webhook`,
|
||||
testUrl: `${GATEWAY_URL}/feishu/test`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'wecom-1',
|
||||
type: 'wecom',
|
||||
name: '企业微信应用',
|
||||
status: 'pending',
|
||||
config: {
|
||||
webhookUrl: `${GATEWAY_URL}/wecom/callback`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'api-1',
|
||||
type: 'api',
|
||||
name: 'REST API',
|
||||
status: 'active',
|
||||
config: {
|
||||
endpoint: '/api/ai-chat',
|
||||
method: 'POST'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
channels,
|
||||
gateway: GATEWAY_URL
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 发送消息到渠道
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { action, type, ...params } = body
|
||||
|
||||
// 发送消息到飞书群
|
||||
if (action === 'send' && type === 'feishu') {
|
||||
const { chatName, content } = params
|
||||
|
||||
if (!chatName || !content) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '缺少参数: chatName, content'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/send_message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_name: chatName,
|
||||
content: content
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '发送失败',
|
||||
error: String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 发送会议纪要
|
||||
if (action === 'send_minutes' && type === 'feishu') {
|
||||
const { minutesUrl, chatName, useLlm = true } = params
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/send_minutes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
minutes_url: minutesUrl,
|
||||
chat_name: chatName,
|
||||
use_llm: useLlm
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '发送会议纪要失败',
|
||||
error: String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未知操作'
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
317
app/api/cleaning-rules/route.ts
Normal file
317
app/api/cleaning-rules/route.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 清洗规则 API
|
||||
* 提供数据清洗规则管理、执行清洗任务等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, maskPhone } from '@/lib/mongodb'
|
||||
|
||||
// 清洗规则接口
|
||||
interface CleaningRule {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: 'format' | 'dedup' | 'validate' | 'enrich' | 'mask'
|
||||
targetField: string
|
||||
config: {
|
||||
pattern?: string
|
||||
replacement?: string
|
||||
validation?: string
|
||||
enrichSource?: string
|
||||
maskType?: 'phone' | 'email' | 'idcard' | 'name'
|
||||
}
|
||||
priority: number
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
lastRunAt?: string
|
||||
processedCount?: number
|
||||
}
|
||||
|
||||
// 预定义清洗规则
|
||||
const CLEANING_RULES: CleaningRule[] = [
|
||||
{
|
||||
id: 'rule_phone_format',
|
||||
name: '手机号格式标准化',
|
||||
description: '将各种格式的手机号统一为11位标准格式',
|
||||
type: 'format',
|
||||
targetField: 'phone',
|
||||
config: {
|
||||
pattern: '^(\\+?86)?([1][3-9]\\d{9})$',
|
||||
replacement: '$2'
|
||||
},
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-31',
|
||||
processedCount: 1500000
|
||||
},
|
||||
{
|
||||
id: 'rule_phone_dedup',
|
||||
name: '手机号去重',
|
||||
description: '按手机号去除重复记录,保留最新数据',
|
||||
type: 'dedup',
|
||||
targetField: 'phone',
|
||||
config: {},
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-30',
|
||||
processedCount: 50000
|
||||
},
|
||||
{
|
||||
id: 'rule_phone_validate',
|
||||
name: '手机号有效性验证',
|
||||
description: '验证手机号是否符合中国大陆手机号规则',
|
||||
type: 'validate',
|
||||
targetField: 'phone',
|
||||
config: {
|
||||
validation: '^1[3-9]\\d{9}$'
|
||||
},
|
||||
priority: 3,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-31',
|
||||
processedCount: 2000000000
|
||||
},
|
||||
{
|
||||
id: 'rule_phone_mask',
|
||||
name: '手机号脱敏',
|
||||
description: '对外展示时隐藏手机号中间4位',
|
||||
type: 'mask',
|
||||
targetField: 'phone',
|
||||
config: {
|
||||
maskType: 'phone'
|
||||
},
|
||||
priority: 4,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
processedCount: 0
|
||||
},
|
||||
{
|
||||
id: 'rule_province_enrich',
|
||||
name: '省份信息补全',
|
||||
description: '根据手机号归属地补全省份信息',
|
||||
type: 'enrich',
|
||||
targetField: 'province',
|
||||
config: {
|
||||
enrichSource: 'phone_location'
|
||||
},
|
||||
priority: 5,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-29',
|
||||
processedCount: 800000
|
||||
},
|
||||
{
|
||||
id: 'rule_email_format',
|
||||
name: '邮箱格式标准化',
|
||||
description: '邮箱地址转小写并去除空格',
|
||||
type: 'format',
|
||||
targetField: 'email',
|
||||
config: {
|
||||
pattern: '\\s+',
|
||||
replacement: ''
|
||||
},
|
||||
priority: 6,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
processedCount: 100000
|
||||
}
|
||||
]
|
||||
|
||||
// 执行清洗规则
|
||||
async function executeCleaningRule(ruleId: string, limit: number = 1000): Promise<{
|
||||
processed: number
|
||||
cleaned: number
|
||||
errors: number
|
||||
samples: any[]
|
||||
}> {
|
||||
const rule = CLEANING_RULES.find(r => r.id === ruleId)
|
||||
if (!rule) throw new Error('规则不存在')
|
||||
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 模拟清洗执行
|
||||
const samples = await collection.find({})
|
||||
.limit(10)
|
||||
.project({ phone: 1, province: 1, city: 1 })
|
||||
.toArray()
|
||||
|
||||
return {
|
||||
processed: limit,
|
||||
cleaned: Math.floor(limit * 0.95),
|
||||
errors: Math.floor(limit * 0.01),
|
||||
samples: samples.map(s => ({
|
||||
...s,
|
||||
phone: maskPhone(s.phone),
|
||||
cleaningApplied: rule.name
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取清洗规则列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const type = searchParams.get('type')
|
||||
const id = searchParams.get('id')
|
||||
|
||||
try {
|
||||
// 获取规则详情
|
||||
if (id) {
|
||||
const rule = CLEANING_RULES.find(r => r.id === id)
|
||||
if (!rule) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '规则不存在'
|
||||
}, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rule
|
||||
})
|
||||
}
|
||||
|
||||
// 获取规则统计
|
||||
if (action === 'stats') {
|
||||
const stats = {
|
||||
totalRules: CLEANING_RULES.length,
|
||||
enabledRules: CLEANING_RULES.filter(r => r.enabled).length,
|
||||
totalProcessed: CLEANING_RULES.reduce((sum, r) => sum + (r.processedCount || 0), 0),
|
||||
rulesByType: {
|
||||
format: CLEANING_RULES.filter(r => r.type === 'format').length,
|
||||
dedup: CLEANING_RULES.filter(r => r.type === 'dedup').length,
|
||||
validate: CLEANING_RULES.filter(r => r.type === 'validate').length,
|
||||
enrich: CLEANING_RULES.filter(r => r.type === 'enrich').length,
|
||||
mask: CLEANING_RULES.filter(r => r.type === 'mask').length
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
}
|
||||
|
||||
// 获取规则列表
|
||||
let rules = [...CLEANING_RULES]
|
||||
|
||||
if (type) {
|
||||
rules = rules.filter(r => r.type === type)
|
||||
}
|
||||
|
||||
rules.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rules,
|
||||
total: rules.length
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('清洗规则 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 创建规则或执行清洗
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { action, ruleId, rule, limit } = body
|
||||
|
||||
// 执行清洗
|
||||
if (action === 'execute') {
|
||||
if (!ruleId) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '请指定要执行的规则'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await executeCleaningRule(ruleId, limit || 1000)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
// 批量执行所有启用的规则
|
||||
if (action === 'execute_all') {
|
||||
const enabledRules = CLEANING_RULES.filter(r => r.enabled)
|
||||
const results = []
|
||||
|
||||
for (const r of enabledRules) {
|
||||
try {
|
||||
const result = await executeCleaningRule(r.id, limit || 100)
|
||||
results.push({
|
||||
ruleId: r.id,
|
||||
ruleName: r.name,
|
||||
...result
|
||||
})
|
||||
} catch (e: any) {
|
||||
results.push({
|
||||
ruleId: r.id,
|
||||
ruleName: r.name,
|
||||
error: e.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
executed: results.length,
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
// 创建规则
|
||||
if (action === 'create') {
|
||||
if (!rule?.name || !rule?.type || !rule?.targetField) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '规则名称、类型和目标字段为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const newRule: CleaningRule = {
|
||||
id: `rule_${Date.now()}`,
|
||||
...rule,
|
||||
priority: CLEANING_RULES.length + 1,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
processedCount: 0
|
||||
}
|
||||
|
||||
// TODO: 保存到数据库
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rule: newRule,
|
||||
message: '清洗规则创建成功'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新规则状态
|
||||
if (action === 'toggle') {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `规则 ${ruleId} 状态已更新`
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未知操作'
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
286
app/api/crowd-pools/route.ts
Normal file
286
app/api/crowd-pools/route.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient } from '@/lib/mongodb'
|
||||
|
||||
// 项目定义(基于真实数据库)
|
||||
const PROJECTS = {
|
||||
ckb: {
|
||||
id: 'ckb',
|
||||
name: '存客宝',
|
||||
database: 'KR_存客宝',
|
||||
collection: '用户资产统一视图',
|
||||
color: 'blue',
|
||||
icon: 'Users'
|
||||
},
|
||||
dlm: {
|
||||
id: 'dlm',
|
||||
name: '点了码',
|
||||
database: 'KR_点了码',
|
||||
collection: '用户资产统一视图',
|
||||
color: 'green',
|
||||
icon: 'QrCode'
|
||||
},
|
||||
weibo: {
|
||||
id: 'weibo',
|
||||
name: '微博',
|
||||
database: 'KR_微博',
|
||||
collection: '微博uid+手机',
|
||||
color: 'orange',
|
||||
icon: 'Globe'
|
||||
},
|
||||
qq: {
|
||||
id: 'qq',
|
||||
name: 'QQ社交',
|
||||
database: 'KR_腾讯',
|
||||
collection: 'QQ+手机',
|
||||
color: 'purple',
|
||||
icon: 'MessageCircle'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取项目列表及统计
|
||||
async function getProjectStats() {
|
||||
const client = await getMongoClient()
|
||||
const stats = []
|
||||
|
||||
for (const [key, project] of Object.entries(PROJECTS)) {
|
||||
try {
|
||||
const db = client.db(project.database)
|
||||
const count = await db.collection(project.collection).estimatedDocumentCount()
|
||||
stats.push({
|
||||
...project,
|
||||
userCount: count,
|
||||
status: 'active'
|
||||
})
|
||||
} catch {
|
||||
stats.push({
|
||||
...project,
|
||||
userCount: 0,
|
||||
status: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// 获取项目下的流量池/标签分类
|
||||
async function getProjectPools(projectId: string) {
|
||||
const client = await getMongoClient()
|
||||
const project = PROJECTS[projectId as keyof typeof PROJECTS]
|
||||
|
||||
if (!project) {
|
||||
return { error: '项目不存在' }
|
||||
}
|
||||
|
||||
const db = client.db(project.database)
|
||||
const collection = db.collection(project.collection)
|
||||
|
||||
// 根据项目类型获取不同的分组字段
|
||||
let pools: any[] = []
|
||||
|
||||
if (projectId === 'ckb') {
|
||||
// 存客宝按流量池分组
|
||||
const poolStats = await collection.aggregate([
|
||||
{ $sample: { size: 50000 } },
|
||||
{ $group: {
|
||||
_id: '$traffic_pool.pool_name',
|
||||
count: { $sum: 1 },
|
||||
avgScore: { $avg: '$user_evaluation_score' }
|
||||
}},
|
||||
{ $sort: { count: -1 } }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 按标签分组
|
||||
const tagStats = await collection.aggregate([
|
||||
{ $sample: { size: 50000 } },
|
||||
{ $unwind: '$unified_tags' },
|
||||
{ $group: { _id: '$unified_tags', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
const total = await collection.estimatedDocumentCount()
|
||||
const ratio = total / 50000
|
||||
|
||||
pools = [
|
||||
...poolStats.filter(p => p._id).map(p => ({
|
||||
id: `pool_${p._id}`,
|
||||
name: p._id,
|
||||
type: 'pool',
|
||||
count: Math.round(p.count * ratio),
|
||||
avgScore: Math.round(p.avgScore || 0)
|
||||
})),
|
||||
...tagStats.filter(t => t._id).map(t => ({
|
||||
id: `tag_${t._id}`,
|
||||
name: t._id,
|
||||
type: 'tag',
|
||||
count: Math.round(t.count * ratio)
|
||||
}))
|
||||
]
|
||||
} else if (projectId === 'dlm') {
|
||||
// 点了码按角色标签分组
|
||||
const roleStats = await collection.aggregate([
|
||||
{ $unwind: { path: '$角色标签', preserveNullAndEmptyArrays: true } },
|
||||
{ $group: { _id: '$角色标签', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 按用户等级分组
|
||||
const levelStats = await collection.aggregate([
|
||||
{ $group: { _id: '$用户等级', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
pools = [
|
||||
...roleStats.filter(r => r._id).map(r => ({
|
||||
id: `role_${r._id}`,
|
||||
name: r._id,
|
||||
type: 'role',
|
||||
count: r.count
|
||||
})),
|
||||
...levelStats.filter(l => l._id).map(l => ({
|
||||
id: `level_${l._id}`,
|
||||
name: `${l._id}级用户`,
|
||||
type: 'level',
|
||||
count: l.count
|
||||
}))
|
||||
]
|
||||
} else if (projectId === 'qq') {
|
||||
// QQ按省份分组
|
||||
const provinceStats = await collection.aggregate([
|
||||
{ $sample: { size: 100000 } },
|
||||
{ $match: { '省份': { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$省份', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
const total = await collection.estimatedDocumentCount()
|
||||
const ratio = total / 100000
|
||||
|
||||
pools = provinceStats.map(p => ({
|
||||
id: `province_${p._id}`,
|
||||
name: p._id,
|
||||
type: 'province',
|
||||
count: Math.round(p.count * ratio)
|
||||
}))
|
||||
} else if (projectId === 'weibo') {
|
||||
// 微博简单统计
|
||||
const total = await collection.estimatedDocumentCount()
|
||||
pools = [{
|
||||
id: 'weibo_all',
|
||||
name: '微博用户',
|
||||
type: 'all',
|
||||
count: total
|
||||
}]
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
pools,
|
||||
totalPools: pools.length
|
||||
}
|
||||
}
|
||||
|
||||
// 获取流量池内的用户列表
|
||||
async function getPoolUsers(projectId: string, poolId: string, page: number = 1, limit: number = 20) {
|
||||
const client = await getMongoClient()
|
||||
const project = PROJECTS[projectId as keyof typeof PROJECTS]
|
||||
|
||||
if (!project) {
|
||||
return { error: '项目不存在' }
|
||||
}
|
||||
|
||||
const db = client.db(project.database)
|
||||
const collection = db.collection(project.collection)
|
||||
|
||||
// 解析poolId构建查询条件
|
||||
let query: any = {}
|
||||
const [type, ...nameParts] = poolId.split('_')
|
||||
const name = nameParts.join('_')
|
||||
|
||||
console.log('Pool query:', { type, name, projectId, poolId })
|
||||
|
||||
if (type === 'pool') {
|
||||
query['traffic_pool.pool_name'] = name
|
||||
} else if (type === 'tag') {
|
||||
query['unified_tags'] = name
|
||||
} else if (type === 'role') {
|
||||
query['角色标签'] = name
|
||||
} else if (type === 'level') {
|
||||
query['用户等级'] = name.replace('级用户', '')
|
||||
} else if (type === 'province') {
|
||||
query['省份'] = name
|
||||
}
|
||||
|
||||
// 如果没有匹配条件,尝试直接用name搜索
|
||||
if (Object.keys(query).length === 0 || type === 'all') {
|
||||
query = {}
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
collection.find(query)
|
||||
.project({
|
||||
phone_masked: 1,
|
||||
name: 1,
|
||||
'core_profile.name': 1,
|
||||
user_evaluation_score: 1,
|
||||
unified_tags: 1,
|
||||
'角色标签': 1,
|
||||
'用户等级': 1,
|
||||
'rfm_scores.user_level': 1,
|
||||
created_at: 1
|
||||
})
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
collection.countDocuments(query)
|
||||
])
|
||||
|
||||
return {
|
||||
users: users.map(u => ({
|
||||
id: u._id.toString(),
|
||||
phone: u.phone_masked || '未知',
|
||||
name: u.name || u.core_profile?.name || '未知用户',
|
||||
score: u.user_evaluation_score || 0,
|
||||
level: u.rfm_scores?.user_level || u['用户等级'] || '-',
|
||||
tags: u.unified_tags || u['角色标签'] || [],
|
||||
createdAt: u.created_at
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'projects'
|
||||
const projectId = searchParams.get('projectId')
|
||||
const poolId = searchParams.get('poolId')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
|
||||
try {
|
||||
if (action === 'projects') {
|
||||
const stats = await getProjectStats()
|
||||
return NextResponse.json({ success: true, projects: stats })
|
||||
}
|
||||
|
||||
if (action === 'pools' && projectId) {
|
||||
const pools = await getProjectPools(projectId)
|
||||
return NextResponse.json({ success: true, ...pools })
|
||||
}
|
||||
|
||||
if (action === 'users' && projectId && poolId) {
|
||||
const users = await getPoolUsers(projectId, poolId, page)
|
||||
return NextResponse.json({ success: true, ...users })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '无效的操作' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('流量池API错误:', error)
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
297
app/api/data-sources/route.ts
Normal file
297
app/api/data-sources/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 数据源管理 API
|
||||
* 提供数据源列表、连接状态检测、同步管理等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, getDatabaseStats } from '@/lib/mongodb'
|
||||
|
||||
// 数据源接口定义
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
nameCn: string // 中文名称
|
||||
description: string // 功能描述
|
||||
type: 'mongodb' | 'mysql' | 'api' | 'webhook'
|
||||
status: 'connected' | 'disconnected' | 'warning'
|
||||
host?: string
|
||||
database?: string
|
||||
endpoint?: string
|
||||
lastSync: string
|
||||
recordCount: number
|
||||
syncFrequency: string
|
||||
collections?: number
|
||||
tables?: number
|
||||
latency?: number
|
||||
dataCategory?: string // 数据分类
|
||||
targetCollection?: string // 目标集合(数据中台分配)
|
||||
}
|
||||
|
||||
// 数据库中文名称和描述映射
|
||||
const DB_DESCRIPTIONS: Record<string, { nameCn: string; description: string; category: string }> = {
|
||||
'KR': { nameCn: '核心用户库', description: '用户估值、RFM评分、统一画像', category: '用户画像' },
|
||||
'KR_KR': { nameCn: '扩展用户库', description: '多源用户数据汇聚', category: '用户数据' },
|
||||
'KR_Linkedln': { nameCn: '领英数据库', description: '职业社交平台用户数据', category: '社交数据' },
|
||||
'KR_京东': { nameCn: '京东用户库', description: '京东电商用户消费数据', category: '电商数据' },
|
||||
'KR_人才库': { nameCn: '人才数据库', description: '招聘平台人才信息', category: '人力资源' },
|
||||
'KR_企业': { nameCn: '企业信息库', description: '企业工商注册信息', category: '企业数据' },
|
||||
'KR_企业名录': { nameCn: '企业名录', description: '企业联系方式和经营信息', category: '企业数据' },
|
||||
'KR_卡若私域': { nameCn: '卡若私域库', description: '私域运营用户数据', category: '私域数据' },
|
||||
'KR_商城': { nameCn: '商城用户库', description: '电商平台用户交易数据', category: '电商数据' },
|
||||
'KR_国外': { nameCn: '海外用户库', description: '海外平台用户数据', category: '国际数据' },
|
||||
'KR_存客宝': { nameCn: '存客宝CRM', description: '私域CRM用户资产数据', category: '私域数据' },
|
||||
'KR_存客宝_四表重构KR_KR版': { nameCn: '存客宝重构版', description: '存客宝数据统一重构', category: '私域数据' },
|
||||
'KR_微博': { nameCn: '微博用户库', description: '微博UID与手机号关联', category: '社交数据' },
|
||||
'KR_快递': { nameCn: '快递信息库', description: '快递收发地址信息', category: '物流数据' },
|
||||
'KR_户口': { nameCn: '户籍信息库', description: '户籍地址信息', category: '身份数据' },
|
||||
'KR_手机': { nameCn: '手机号库', description: '手机号归属地和运营商', category: '基础数据' },
|
||||
'KR_投资': { nameCn: '投资信息库', description: '投资理财用户数据', category: '金融数据' },
|
||||
'KR_淘宝': { nameCn: '淘宝用户库', description: '淘宝电商用户数据', category: '电商数据' },
|
||||
'KR_游戏': { nameCn: '游戏用户库', description: '游戏平台用户数据', category: '娱乐数据' },
|
||||
'KR_点了码': { nameCn: '点了码商户库', description: '点了码商户和用户统一视图', category: '商业数据' },
|
||||
'KR_腾讯': { nameCn: '腾讯社交库', description: 'QQ号与手机号关联数据', category: '社交数据' },
|
||||
'KR_酒店': { nameCn: '酒店住宿库', description: '酒店入住记录信息', category: '消费数据' },
|
||||
'KR_顺丰': { nameCn: '顺丰快递库', description: '顺丰快递收发信息', category: '物流数据' },
|
||||
'KR_魔兽世界': { nameCn: '魔兽世界库', description: '魔兽世界玩家数据', category: '游戏数据' },
|
||||
}
|
||||
|
||||
// 获取 MongoDB 数据源列表
|
||||
async function getMongoDBSources(): Promise<DataSource[]> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const dbList = await client.db().admin().listDatabases()
|
||||
const sources: DataSource[] = []
|
||||
|
||||
for (const dbInfo of dbList.databases) {
|
||||
if (dbInfo.name.startsWith('KR')) {
|
||||
const db = client.db(dbInfo.name)
|
||||
const collections = await db.listCollections().toArray()
|
||||
|
||||
let totalDocs = 0
|
||||
for (const coll of collections) {
|
||||
const count = await db.collection(coll.name).estimatedDocumentCount()
|
||||
totalDocs += count
|
||||
}
|
||||
|
||||
// 获取中文名称和描述
|
||||
const dbMeta = DB_DESCRIPTIONS[dbInfo.name] || {
|
||||
nameCn: dbInfo.name,
|
||||
description: `${dbInfo.name} 数据库`,
|
||||
category: '其他'
|
||||
}
|
||||
|
||||
sources.push({
|
||||
id: `mongo_${dbInfo.name}`,
|
||||
name: dbInfo.name,
|
||||
nameCn: dbMeta.nameCn,
|
||||
description: dbMeta.description,
|
||||
type: 'mongodb',
|
||||
status: 'connected',
|
||||
host: process.env.MONGODB_URI?.split('@')[1]?.split('/')[0] || 'localhost:27017',
|
||||
database: dbInfo.name,
|
||||
lastSync: '实时',
|
||||
recordCount: totalDocs,
|
||||
syncFrequency: '实时',
|
||||
collections: collections.length,
|
||||
latency: 0,
|
||||
dataCategory: dbMeta.category,
|
||||
targetCollection: 'KR.用户估值' // 默认目标集合
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
} catch (error) {
|
||||
console.error('获取 MongoDB 数据源失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 检测数据源连接状态
|
||||
async function checkConnectionStatus(): Promise<{
|
||||
mongodb: { connected: boolean; latency: number }
|
||||
mysql: { connected: boolean; latency: number }
|
||||
}> {
|
||||
const startTime = Date.now()
|
||||
let mongoConnected = false
|
||||
let mongoLatency = 0
|
||||
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db().admin().ping()
|
||||
mongoConnected = true
|
||||
mongoLatency = Date.now() - startTime
|
||||
} catch {
|
||||
mongoLatency = Date.now() - startTime
|
||||
}
|
||||
|
||||
return {
|
||||
mongodb: { connected: mongoConnected, latency: mongoLatency },
|
||||
mysql: { connected: false, latency: 0 } // MySQL 暂未实现
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取数据源列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
// 检查连接状态
|
||||
if (action === 'status') {
|
||||
const status = await checkConnectionStatus()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status
|
||||
})
|
||||
}
|
||||
|
||||
// 获取数据库统计
|
||||
if (action === 'stats') {
|
||||
const stats = await getDatabaseStats()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
}
|
||||
|
||||
// 获取完整数据源列表
|
||||
const mongoSources = await getMongoDBSources()
|
||||
|
||||
// 计算统计信息
|
||||
const stats = await getDatabaseStats()
|
||||
|
||||
// 预定义的外部数据源(MySQL、API 等)
|
||||
const externalSources: DataSource[] = [
|
||||
{
|
||||
id: 'mysql_ckb',
|
||||
name: 'cunkebao_v3',
|
||||
nameCn: '存客宝MySQL主库',
|
||||
description: '存客宝CRM系统MySQL数据库,包含客户、订单、营销数据',
|
||||
type: 'mysql',
|
||||
status: 'warning',
|
||||
host: 'cdb-xxx.gz.tencentcdb.com:10050',
|
||||
database: 'cunkebao_v3',
|
||||
lastSync: '需要配置连接',
|
||||
recordCount: 0,
|
||||
syncFrequency: '增量同步',
|
||||
tables: 45,
|
||||
dataCategory: '私域数据',
|
||||
targetCollection: 'KR_存客宝.用户资产统一视图'
|
||||
},
|
||||
{
|
||||
id: 'mysql_dlm',
|
||||
name: 'dianlema',
|
||||
nameCn: '点了码MySQL库',
|
||||
description: '点了码商户系统,包含商户、用户、交易数据',
|
||||
type: 'mysql',
|
||||
status: 'warning',
|
||||
host: 'cdb-xxx.gz.tencentcdb.com:14413',
|
||||
database: 'dianlema',
|
||||
lastSync: '需要配置连接',
|
||||
recordCount: 0,
|
||||
syncFrequency: '增量同步',
|
||||
tables: 28,
|
||||
dataCategory: '商业数据',
|
||||
targetCollection: 'KR_点了码.用户资产统一视图'
|
||||
},
|
||||
{
|
||||
id: 'api_weibo',
|
||||
name: 'weibo_api',
|
||||
nameCn: '微博开放API',
|
||||
description: '微博开放平台API,获取用户公开信息和热点数据',
|
||||
type: 'api',
|
||||
status: 'disconnected',
|
||||
endpoint: 'https://api.weibo.com/2',
|
||||
lastSync: '未配置',
|
||||
recordCount: 0,
|
||||
syncFrequency: '按需调用',
|
||||
dataCategory: '社交数据',
|
||||
targetCollection: 'KR_微博.微博uid+手机'
|
||||
},
|
||||
{
|
||||
id: 'webhook_feishu',
|
||||
name: 'feishu_webhook',
|
||||
nameCn: '飞书机器人',
|
||||
description: '飞书机器人Webhook,接收对话消息并触发查询',
|
||||
type: 'webhook',
|
||||
status: 'connected',
|
||||
endpoint: '/api/feishu/webhook',
|
||||
lastSync: '实时',
|
||||
recordCount: 0,
|
||||
syncFrequency: '实时',
|
||||
dataCategory: '消息通道'
|
||||
}
|
||||
]
|
||||
|
||||
const allSources = [...mongoSources, ...externalSources]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sources: allSources,
|
||||
summary: {
|
||||
total: allSources.length,
|
||||
connected: allSources.filter(s => s.status === 'connected').length,
|
||||
warning: allSources.filter(s => s.status === 'warning').length,
|
||||
disconnected: allSources.filter(s => s.status === 'disconnected').length,
|
||||
totalRecords: stats.totalDocuments,
|
||||
totalSize: stats.totalSize,
|
||||
latency: stats.latency
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('数据源 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
sources: [],
|
||||
summary: {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
warning: 0,
|
||||
disconnected: 0,
|
||||
totalRecords: 0,
|
||||
totalSize: 0,
|
||||
latency: 0
|
||||
}
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 测试数据源连接
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { type, config } = body
|
||||
|
||||
if (type === 'mongodb') {
|
||||
const { MongoClient } = await import('mongodb')
|
||||
const uri = config.uri || `mongodb://${config.username}:${config.password}@${config.host}/?authSource=admin`
|
||||
|
||||
const client = new MongoClient(uri, {
|
||||
serverSelectionTimeoutMS: 5000
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
await client.db().admin().ping()
|
||||
await client.close()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'MongoDB 连接成功'
|
||||
})
|
||||
}
|
||||
|
||||
// 其他数据源类型暂未实现
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `${type} 类型暂未支持`
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,195 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient } from '@/lib/mongodb'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const database = searchParams.get("database")
|
||||
|
||||
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 })
|
||||
// 获取所有数据库结构
|
||||
async function getDatabaseStructure() {
|
||||
const client = await getMongoClient()
|
||||
const admin = client.db().admin()
|
||||
|
||||
// 获取数据库列表
|
||||
const dbList = await admin.listDatabases()
|
||||
const krDatabases = dbList.databases.filter(db => db.name.startsWith('KR'))
|
||||
|
||||
const structure = []
|
||||
|
||||
for (const dbInfo of krDatabases.slice(0, 10)) { // 限制前10个
|
||||
try {
|
||||
const db = client.db(dbInfo.name)
|
||||
const collections = await db.listCollections().toArray()
|
||||
|
||||
// 获取每个集合的字段示例
|
||||
const collectionDetails = []
|
||||
for (const col of collections.slice(0, 5)) { // 每个库最多5个集合
|
||||
try {
|
||||
const sample = await db.collection(col.name).findOne()
|
||||
const fields = sample ? Object.keys(sample).filter(k => k !== '_id').slice(0, 10) : []
|
||||
const count = await db.collection(col.name).estimatedDocumentCount()
|
||||
|
||||
collectionDetails.push({
|
||||
name: col.name,
|
||||
fields,
|
||||
count
|
||||
})
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
structure.push({
|
||||
database: dbInfo.name,
|
||||
sizeGB: (dbInfo.sizeOnDisk / 1024 / 1024 / 1024).toFixed(2),
|
||||
collections: collectionDetails
|
||||
})
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return structure
|
||||
}
|
||||
|
||||
// 生成血缘节点
|
||||
function generateLineageNodes(structure: any[]) {
|
||||
const nodes: any[] = []
|
||||
const connections: any[] = []
|
||||
let yOffset = 50
|
||||
|
||||
// 数据源节点 (左侧)
|
||||
const colors = [
|
||||
'from-blue-400 to-blue-600',
|
||||
'from-orange-400 to-orange-600',
|
||||
'from-green-400 to-green-600',
|
||||
'from-red-400 to-red-600',
|
||||
'from-purple-400 to-purple-600',
|
||||
]
|
||||
|
||||
structure.forEach((db, i) => {
|
||||
const mainCol = db.collections[0]
|
||||
if (!mainCol) return
|
||||
|
||||
nodes.push({
|
||||
id: `source_${db.database}`,
|
||||
type: 'source',
|
||||
name: db.database.replace('KR_', ''),
|
||||
database: db.database,
|
||||
collection: mainCol.name,
|
||||
fields: mainCol.fields.slice(0, 5),
|
||||
x: 50,
|
||||
y: yOffset,
|
||||
color: colors[i % colors.length],
|
||||
count: mainCol.count,
|
||||
sizeGB: db.sizeGB
|
||||
})
|
||||
yOffset += 140
|
||||
})
|
||||
|
||||
// AI引擎节点 (中间)
|
||||
nodes.push({
|
||||
id: 'transform_ai',
|
||||
type: 'transform',
|
||||
name: 'AI标签引擎',
|
||||
fields: ['phone_norm', 'qq_norm', 'rfm_score', 'user_level', 'tags'],
|
||||
x: 400,
|
||||
y: 150,
|
||||
color: 'from-violet-400 to-violet-600'
|
||||
})
|
||||
|
||||
nodes.push({
|
||||
id: 'transform_clean',
|
||||
type: 'transform',
|
||||
name: '数据清洗',
|
||||
fields: ['unique_id', 'merged_data', 'quality'],
|
||||
x: 400,
|
||||
y: 350,
|
||||
color: 'from-yellow-400 to-yellow-600'
|
||||
})
|
||||
|
||||
// 目标节点 (右侧)
|
||||
nodes.push({
|
||||
id: 'target_valuation',
|
||||
type: 'target',
|
||||
name: '用户估值',
|
||||
database: 'KR',
|
||||
collection: '用户估值',
|
||||
fields: ['phone', 'qq', 'rfm_score', 'user_level', 'tags'],
|
||||
x: 750,
|
||||
y: 200,
|
||||
color: 'from-emerald-400 to-emerald-600'
|
||||
})
|
||||
|
||||
nodes.push({
|
||||
id: 'target_portrait',
|
||||
type: 'target',
|
||||
name: '用户画像',
|
||||
database: 'KR',
|
||||
collection: '用户画像',
|
||||
fields: ['user_id', 'portrait', 'behavior'],
|
||||
x: 750,
|
||||
y: 400,
|
||||
color: 'from-cyan-400 to-cyan-600'
|
||||
})
|
||||
|
||||
// 自动生成连接
|
||||
structure.forEach(db => {
|
||||
const sourceId = `source_${db.database}`
|
||||
const mainCol = db.collections[0]
|
||||
if (!mainCol) return
|
||||
|
||||
// 连接到AI引擎
|
||||
if (mainCol.fields.includes('phone') || mainCol.fields.includes('手机')) {
|
||||
connections.push({
|
||||
id: `conn_${sourceId}_phone`,
|
||||
sourceNode: sourceId,
|
||||
sourceField: mainCol.fields.includes('phone') ? 'phone' : '手机',
|
||||
targetNode: 'transform_ai',
|
||||
targetField: 'phone_norm'
|
||||
})
|
||||
}
|
||||
if (mainCol.fields.includes('qq') || mainCol.fields.includes('QQ')) {
|
||||
connections.push({
|
||||
id: `conn_${sourceId}_qq`,
|
||||
sourceNode: sourceId,
|
||||
sourceField: mainCol.fields.includes('qq') ? 'qq' : 'QQ',
|
||||
targetNode: 'transform_ai',
|
||||
targetField: 'qq_norm'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// AI引擎到目标
|
||||
connections.push({
|
||||
id: 'conn_ai_valuation',
|
||||
sourceNode: 'transform_ai',
|
||||
sourceField: 'rfm_score',
|
||||
targetNode: 'target_valuation',
|
||||
targetField: 'rfm_score'
|
||||
})
|
||||
connections.push({
|
||||
id: 'conn_clean_portrait',
|
||||
sourceNode: 'transform_clean',
|
||||
sourceField: 'merged_data',
|
||||
targetNode: 'target_portrait',
|
||||
targetField: 'portrait'
|
||||
})
|
||||
|
||||
return { nodes, connections }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'structure'
|
||||
|
||||
try {
|
||||
const structure = await getDatabaseStructure()
|
||||
|
||||
if (action === 'lineage') {
|
||||
const lineage = generateLineageNodes(structure)
|
||||
return NextResponse.json({ success: true, ...lineage })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, databases: structure })
|
||||
} catch (error) {
|
||||
console.error("数据库结构查询失败:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "数据库结构查询失败", error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
)
|
||||
console.error('数据库结构查询失败:', error)
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
325
app/api/monitoring/route.ts
Normal file
325
app/api/monitoring/route.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient } from '@/lib/mongodb'
|
||||
|
||||
// 获取数据库详细状态
|
||||
async function getDatabaseStats() {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const adminDb = client.db('admin')
|
||||
|
||||
// 获取服务器状态
|
||||
const serverStatus = await adminDb.command({ serverStatus: 1 })
|
||||
|
||||
// 获取所有KR_开头的数据库
|
||||
const dbList = await adminDb.admin().listDatabases()
|
||||
const krDatabases = dbList.databases.filter((db: any) =>
|
||||
db.name.startsWith('KR')
|
||||
)
|
||||
|
||||
// 统计各数据库信息
|
||||
const databaseDetails = await Promise.all(
|
||||
krDatabases.slice(0, 10).map(async (db: any) => {
|
||||
try {
|
||||
const database = client.db(db.name)
|
||||
const stats = await database.command({ dbStats: 1 })
|
||||
const collections = await database.listCollections().toArray()
|
||||
|
||||
return {
|
||||
name: db.name,
|
||||
sizeGB: (db.sizeOnDisk / (1024 * 1024 * 1024)).toFixed(2),
|
||||
collections: collections.length,
|
||||
objects: stats.objects || 0,
|
||||
indexes: stats.indexes || 0,
|
||||
avgObjSize: stats.avgObjSize || 0
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
name: db.name,
|
||||
sizeGB: (db.sizeOnDisk / (1024 * 1024 * 1024)).toFixed(2),
|
||||
collections: 0,
|
||||
objects: 0,
|
||||
indexes: 0,
|
||||
error: String(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
server: {
|
||||
version: serverStatus.version,
|
||||
uptime: serverStatus.uptime,
|
||||
uptimeHours: Math.floor(serverStatus.uptime / 3600),
|
||||
host: serverStatus.host,
|
||||
connections: {
|
||||
current: serverStatus.connections?.current || 0,
|
||||
available: serverStatus.connections?.available || 0,
|
||||
totalCreated: serverStatus.connections?.totalCreated || 0
|
||||
},
|
||||
memory: {
|
||||
resident: serverStatus.mem?.resident || 0,
|
||||
virtual: serverStatus.mem?.virtual || 0,
|
||||
mapped: serverStatus.mem?.mapped || 0
|
||||
},
|
||||
network: {
|
||||
bytesIn: serverStatus.network?.bytesIn || 0,
|
||||
bytesOut: serverStatus.network?.bytesOut || 0,
|
||||
numRequests: serverStatus.network?.numRequests || 0
|
||||
},
|
||||
opcounters: {
|
||||
insert: serverStatus.opcounters?.insert || 0,
|
||||
query: serverStatus.opcounters?.query || 0,
|
||||
update: serverStatus.opcounters?.update || 0,
|
||||
delete: serverStatus.opcounters?.delete || 0
|
||||
}
|
||||
},
|
||||
databases: {
|
||||
total: krDatabases.length,
|
||||
totalSizeGB: krDatabases.reduce((sum: number, db: any) =>
|
||||
sum + db.sizeOnDisk / (1024 * 1024 * 1024), 0
|
||||
).toFixed(2),
|
||||
details: databaseDetails
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
server: null,
|
||||
databases: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取健康检查
|
||||
async function getHealthStatus() {
|
||||
const services = []
|
||||
|
||||
// 检查MongoDB
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const start = Date.now()
|
||||
await client.db('admin').command({ ping: 1 })
|
||||
const latency = Date.now() - start
|
||||
|
||||
services.push({
|
||||
name: 'MongoDB',
|
||||
status: latency < 100 ? 'healthy' : latency < 500 ? 'degraded' : 'unhealthy',
|
||||
latency: `${latency}ms`,
|
||||
message: latency < 100 ? '运行正常' : '响应较慢'
|
||||
})
|
||||
} catch (e) {
|
||||
services.push({
|
||||
name: 'MongoDB',
|
||||
status: 'unhealthy',
|
||||
latency: '-',
|
||||
message: String(e)
|
||||
})
|
||||
}
|
||||
|
||||
// 检查卡若AI网关
|
||||
try {
|
||||
const gatewayUrl = process.env.GATEWAY_URL || 'http://localhost:8000'
|
||||
const start = Date.now()
|
||||
const res = await fetch(`${gatewayUrl}/health`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
const latency = Date.now() - start
|
||||
|
||||
services.push({
|
||||
name: '卡若AI网关',
|
||||
status: res.ok ? 'healthy' : 'degraded',
|
||||
latency: `${latency}ms`,
|
||||
message: res.ok ? '运行正常' : '响应异常'
|
||||
})
|
||||
} catch (e) {
|
||||
services.push({
|
||||
name: '卡若AI网关',
|
||||
status: 'unhealthy',
|
||||
latency: '-',
|
||||
message: '无法连接'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查飞书服务
|
||||
try {
|
||||
const gatewayUrl = process.env.GATEWAY_URL || 'http://localhost:8000'
|
||||
const res = await fetch(`${gatewayUrl}/feishu/test`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
services.push({
|
||||
name: '飞书机器人',
|
||||
status: data.status === 'success' ? 'healthy' : 'degraded',
|
||||
latency: '-',
|
||||
message: data.message || '未知状态'
|
||||
})
|
||||
} catch (e) {
|
||||
services.push({
|
||||
name: '飞书机器人',
|
||||
status: 'unhealthy',
|
||||
latency: '-',
|
||||
message: '未配置或无法连接'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
services,
|
||||
overall: services.every(s => s.status === 'healthy') ? 'healthy' :
|
||||
services.some(s => s.status === 'unhealthy') ? 'unhealthy' : 'degraded'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取告警信息
|
||||
async function getAlerts() {
|
||||
// 从数据库状态生成告警
|
||||
const alerts = []
|
||||
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const adminDb = client.db('admin')
|
||||
const serverStatus = await adminDb.command({ serverStatus: 1 })
|
||||
|
||||
// 检查连接数
|
||||
const connCurrent = serverStatus.connections?.current || 0
|
||||
const connAvailable = serverStatus.connections?.available || 0
|
||||
if (connCurrent > connAvailable * 0.8) {
|
||||
alerts.push({
|
||||
id: 'conn-high',
|
||||
type: 'warning',
|
||||
message: `MongoDB连接数较高 (${connCurrent}/${connAvailable})`,
|
||||
time: new Date().toISOString(),
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查内存
|
||||
const memResident = serverStatus.mem?.resident || 0
|
||||
if (memResident > 8000) { // 8GB
|
||||
alerts.push({
|
||||
id: 'mem-high',
|
||||
type: 'warning',
|
||||
message: `MongoDB内存使用较高 (${(memResident/1024).toFixed(1)}GB)`,
|
||||
time: new Date().toISOString(),
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
alerts.push({
|
||||
id: 'mongo-error',
|
||||
type: 'error',
|
||||
message: `MongoDB连接失败: ${String(e)}`,
|
||||
time: new Date().toISOString(),
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
// 默认告警(如果没有问题)
|
||||
if (alerts.length === 0) {
|
||||
alerts.push({
|
||||
id: 'all-ok',
|
||||
type: 'info',
|
||||
message: '系统运行正常,无告警',
|
||||
time: new Date().toISOString(),
|
||||
status: 'resolved'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alerts,
|
||||
activeCount: alerts.filter(a => a.status === 'active').length
|
||||
}
|
||||
}
|
||||
|
||||
// 获取业务指标
|
||||
async function getBusinessMetrics() {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
|
||||
// 从KR.用户估值获取统计
|
||||
const krDb = client.db('KR')
|
||||
const userCollection = krDb.collection('用户估值')
|
||||
|
||||
const totalUsers = await userCollection.estimatedDocumentCount()
|
||||
|
||||
// 获取用户等级分布(采样)
|
||||
const levelStats = await userCollection.aggregate([
|
||||
{ $sample: { size: 10000 } },
|
||||
{ $match: { user_level: { $exists: true } } },
|
||||
{ $group: { _id: '$user_level', count: { $sum: 1 } } }
|
||||
], { maxTimeMS: 5000 }).toArray()
|
||||
|
||||
// 计算各等级占比
|
||||
const levelDistribution = levelStats.map(l => ({
|
||||
level: l._id || '未知',
|
||||
count: l.count,
|
||||
percentage: ((l.count / 10000) * 100).toFixed(1)
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metrics: {
|
||||
totalUsers,
|
||||
totalUsersFormatted: totalUsers >= 1000000000
|
||||
? `${(totalUsers/1000000000).toFixed(2)}B`
|
||||
: `${(totalUsers/1000000).toFixed(1)}M`,
|
||||
levelDistribution,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'databases':
|
||||
return NextResponse.json(await getDatabaseStats())
|
||||
|
||||
case 'health':
|
||||
return NextResponse.json(await getHealthStatus())
|
||||
|
||||
case 'alerts':
|
||||
return NextResponse.json(await getAlerts())
|
||||
|
||||
case 'metrics':
|
||||
return NextResponse.json(await getBusinessMetrics())
|
||||
|
||||
default:
|
||||
// 返回综合状态
|
||||
const [dbStats, health, alerts, metrics] = await Promise.all([
|
||||
getDatabaseStats(),
|
||||
getHealthStatus(),
|
||||
getAlerts(),
|
||||
getBusinessMetrics()
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
database: dbStats,
|
||||
health,
|
||||
alerts,
|
||||
metrics
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
768
app/api/open-api/route.ts
Normal file
768
app/api/open-api/route.ts
Normal file
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* 神射手开放API接口
|
||||
*
|
||||
* 提供给第三方系统(存客宝、点了码等)调用的开放接口
|
||||
* 支持数据流入、数据查询、标签完善、批量处理等功能
|
||||
*
|
||||
* @author 神射手团队
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface Partner {
|
||||
id: string
|
||||
name: string
|
||||
apiKey: string
|
||||
apiSecret: string
|
||||
status: 'active' | 'inactive' | 'pending'
|
||||
permissions: {
|
||||
dataIngest: boolean
|
||||
dataQuery: boolean
|
||||
tagEnrich: boolean
|
||||
batchProcess: boolean
|
||||
}
|
||||
quotaConfig: {
|
||||
dailyLimit: number
|
||||
monthlyLimit: number
|
||||
rateLimit: number
|
||||
}
|
||||
}
|
||||
|
||||
interface APIResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
code?: number
|
||||
}
|
||||
|
||||
// ==================== 模拟数据存储 ====================
|
||||
|
||||
// 模拟接入方数据(实际应存储在数据库中)
|
||||
const PARTNERS: Map<string, Partner> = new Map([
|
||||
['sk-ckb-xxxxxxxxxx', {
|
||||
id: 'ckb_001',
|
||||
name: '存客宝',
|
||||
apiKey: 'sk-ckb-xxxxxxxxxx',
|
||||
apiSecret: 'sec-ckb-xxxxxxxxxx',
|
||||
status: 'active',
|
||||
permissions: {
|
||||
dataIngest: true,
|
||||
dataQuery: true,
|
||||
tagEnrich: true,
|
||||
batchProcess: true,
|
||||
},
|
||||
quotaConfig: {
|
||||
dailyLimit: 10000,
|
||||
monthlyLimit: 300000,
|
||||
rateLimit: 100,
|
||||
},
|
||||
}],
|
||||
['sk-dlm-xxxxxxxxxx', {
|
||||
id: 'dlm_001',
|
||||
name: '点了码',
|
||||
apiKey: 'sk-dlm-xxxxxxxxxx',
|
||||
apiSecret: 'sec-dlm-xxxxxxxxxx',
|
||||
status: 'active',
|
||||
permissions: {
|
||||
dataIngest: true,
|
||||
dataQuery: true,
|
||||
tagEnrich: false,
|
||||
batchProcess: false,
|
||||
},
|
||||
quotaConfig: {
|
||||
dailyLimit: 5000,
|
||||
monthlyLimit: 150000,
|
||||
rateLimit: 50,
|
||||
},
|
||||
}],
|
||||
])
|
||||
|
||||
// 模拟调用计数(实际应使用Redis)
|
||||
const CALL_COUNTS: Map<string, { daily: number; monthly: number }> = new Map()
|
||||
|
||||
// 模拟批量任务存储
|
||||
const BATCH_TASKS: Map<string, {
|
||||
id: string
|
||||
partnerId: string
|
||||
type: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
progress: number
|
||||
total: number
|
||||
processed: number
|
||||
failed: number
|
||||
createdAt: Date
|
||||
completedAt?: Date
|
||||
}> = new Map()
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 验证API请求
|
||||
*/
|
||||
function validateRequest(request: NextRequest): { valid: boolean; partner?: Partner; error?: string } {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const apiSecret = request.headers.get('X-API-Secret')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return { valid: false, error: '缺少Authorization头' }
|
||||
}
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '')
|
||||
const partner = PARTNERS.get(apiKey)
|
||||
|
||||
if (!partner) {
|
||||
return { valid: false, error: '无效的API Key' }
|
||||
}
|
||||
|
||||
if (partner.status !== 'active') {
|
||||
return { valid: false, error: '接入方已被禁用' }
|
||||
}
|
||||
|
||||
// 验证API Secret(可选,增强安全性)
|
||||
if (apiSecret && apiSecret !== partner.apiSecret) {
|
||||
return { valid: false, error: 'API Secret验证失败' }
|
||||
}
|
||||
|
||||
// 检查配额
|
||||
const counts = CALL_COUNTS.get(partner.id) || { daily: 0, monthly: 0 }
|
||||
if (counts.daily >= partner.quotaConfig.dailyLimit) {
|
||||
return { valid: false, error: '已达到每日调用限额' }
|
||||
}
|
||||
if (counts.monthly >= partner.quotaConfig.monthlyLimit) {
|
||||
return { valid: false, error: '已达到每月调用限额' }
|
||||
}
|
||||
|
||||
// 更新调用计数
|
||||
CALL_COUNTS.set(partner.id, {
|
||||
daily: counts.daily + 1,
|
||||
monthly: counts.monthly + 1,
|
||||
})
|
||||
|
||||
return { valid: true, partner }
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式
|
||||
*/
|
||||
function isValidPhone(phone: string): boolean {
|
||||
return /^1[3-9]\d{9}$/.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏处理
|
||||
*/
|
||||
function maskPhone(phone: string): string {
|
||||
if (!phone || phone.length !== 11) return phone
|
||||
return phone.slice(0, 3) + '****' + phone.slice(7)
|
||||
}
|
||||
|
||||
function maskName(name: string): string {
|
||||
if (!name) return name
|
||||
if (name.length <= 1) return name
|
||||
return name[0] + '*'.repeat(name.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟用户画像数据
|
||||
*/
|
||||
function getMockUserProfile(phone: string) {
|
||||
// 模拟从数据库获取用户画像
|
||||
const hash = crypto.createHash('md5').update(phone).digest('hex')
|
||||
const seed = parseInt(hash.slice(0, 8), 16)
|
||||
|
||||
const levels = ['S', 'A', 'B', 'C', 'D']
|
||||
const pools = ['钻石池', '黄金池', '白银池', '青铜池', '潜力池']
|
||||
const tagOptions = [
|
||||
'高价值用户', '优质用户', '电商活跃', '社交达人', '一线城市',
|
||||
'二线城市', '高频用户', '沉默用户', '新用户', '流失风险',
|
||||
'高消费', '低消费', '金融偏好', '科技爱好者', '时尚达人'
|
||||
]
|
||||
|
||||
const levelIndex = seed % 5
|
||||
const r = 1 + (seed % 5)
|
||||
const f = 1 + ((seed >> 4) % 5)
|
||||
const m = 1 + ((seed >> 8) % 5)
|
||||
const rfmScore = Math.round(r * 0.3 + f * 0.3 + m * 0.4) * 20
|
||||
|
||||
// 随机选择3-5个标签
|
||||
const numTags = 3 + (seed % 3)
|
||||
const tags: string[] = []
|
||||
for (let i = 0; i < numTags; i++) {
|
||||
const tagIndex = (seed + i * 7) % tagOptions.length
|
||||
if (!tags.includes(tagOptions[tagIndex])) {
|
||||
tags.push(tagOptions[tagIndex])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phone: maskPhone(phone),
|
||||
name: maskName('张三'),
|
||||
tags,
|
||||
rfm: { r, f, m, score: rfmScore },
|
||||
level: levels[levelIndex],
|
||||
traffic_pool: pools[levelIndex],
|
||||
data_sources: ['KR_存客宝', 'KR_腾讯'],
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟AI标签完善
|
||||
*/
|
||||
function enrichUserTags(phone: string, existingTags: string[] = []) {
|
||||
const profile = getMockUserProfile(phone)
|
||||
const allTags = [...new Set([...existingTags, ...profile.tags])]
|
||||
const newTags = allTags.filter(t => !existingTags.includes(t))
|
||||
|
||||
return {
|
||||
newTags,
|
||||
allTags,
|
||||
rfm_score: profile.rfm.score,
|
||||
level: profile.level,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== API处理函数 ====================
|
||||
|
||||
/**
|
||||
* 处理数据流入
|
||||
*/
|
||||
async function handleIngest(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataIngest) {
|
||||
return { success: false, error: '无数据流入权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, name, source, tags = [], extra = {} } = body
|
||||
|
||||
if (!phone) {
|
||||
return { success: false, error: '缺少phone参数', code: 400 }
|
||||
}
|
||||
|
||||
if (!isValidPhone(phone)) {
|
||||
return { success: false, error: '手机号格式错误', code: 400 }
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return { success: false, error: '缺少source参数', code: 400 }
|
||||
}
|
||||
|
||||
// 模拟数据处理和标签完善
|
||||
const enrichResult = enrichUserTags(phone, tags)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
userId: `usr_${crypto.randomBytes(8).toString('hex')}`,
|
||||
enriched: true,
|
||||
originalTags: tags,
|
||||
newTags: enrichResult.newTags,
|
||||
allTags: enrichResult.allTags,
|
||||
rfm_score: enrichResult.rfm_score,
|
||||
user_level: enrichResult.level,
|
||||
source,
|
||||
processedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量数据流入
|
||||
*/
|
||||
async function handleBatchIngest(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataIngest) {
|
||||
return { success: false, error: '无数据流入权限', code: 403 }
|
||||
}
|
||||
|
||||
if (!partner.permissions.batchProcess) {
|
||||
return { success: false, error: '无批量处理权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { users = [], source, async: isAsync = false } = body
|
||||
|
||||
if (!Array.isArray(users) || users.length === 0) {
|
||||
return { success: false, error: '缺少users数组', code: 400 }
|
||||
}
|
||||
|
||||
if (users.length > 1000) {
|
||||
return { success: false, error: '单次最多支持1000条数据', code: 400 }
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return { success: false, error: '缺少source参数', code: 400 }
|
||||
}
|
||||
|
||||
if (isAsync) {
|
||||
// 创建异步任务
|
||||
const taskId = `task_${crypto.randomBytes(8).toString('hex')}`
|
||||
BATCH_TASKS.set(taskId, {
|
||||
id: taskId,
|
||||
partnerId: partner.id,
|
||||
type: 'ingest',
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
total: users.length,
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// 模拟异步处理(实际应使用队列)
|
||||
setTimeout(() => {
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
if (task) {
|
||||
task.status = 'completed'
|
||||
task.progress = 100
|
||||
task.processed = users.length
|
||||
task.completedAt = new Date()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
total: users.length,
|
||||
processed: 0,
|
||||
status: 'processing',
|
||||
estimatedTime: `${Math.ceil(users.length / 100)}秒`,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// 同步处理
|
||||
const results = users.map((user: { phone: string; name?: string; tags?: string[] }) => {
|
||||
if (!user.phone || !isValidPhone(user.phone)) {
|
||||
return { phone: user.phone, success: false, error: '手机号格式错误' }
|
||||
}
|
||||
const enrichResult = enrichUserTags(user.phone, user.tags || [])
|
||||
return {
|
||||
phone: maskPhone(user.phone),
|
||||
success: true,
|
||||
tags: enrichResult.allTags,
|
||||
level: enrichResult.level,
|
||||
}
|
||||
})
|
||||
|
||||
const successful = results.filter((r: { success: boolean }) => r.success).length
|
||||
const failed = results.length - successful
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: users.length,
|
||||
processed: successful,
|
||||
failed,
|
||||
results,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户查询
|
||||
*/
|
||||
async function handleQuery(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataQuery) {
|
||||
return { success: false, error: '无数据查询权限', code: 403 }
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const phone = searchParams.get('phone')
|
||||
const qq = searchParams.get('qq')
|
||||
const fields = searchParams.get('fields')?.split(',')
|
||||
|
||||
if (!phone && !qq) {
|
||||
return { success: false, error: '缺少phone或qq参数', code: 400 }
|
||||
}
|
||||
|
||||
// 优先使用手机号查询
|
||||
const queryPhone = phone || `138${Math.floor(Math.random() * 100000000).toString().padStart(8, '0')}`
|
||||
|
||||
if (phone && !isValidPhone(phone)) {
|
||||
return { success: false, error: '手机号格式错误', code: 400 }
|
||||
}
|
||||
|
||||
const profile = getMockUserProfile(queryPhone)
|
||||
|
||||
// 根据fields参数过滤返回字段
|
||||
if (fields && fields.length > 0) {
|
||||
const filteredProfile: Record<string, any> = {}
|
||||
fields.forEach(field => {
|
||||
if (field in profile) {
|
||||
filteredProfile[field] = (profile as Record<string, any>)[field]
|
||||
}
|
||||
})
|
||||
filteredProfile.phone = profile.phone // 始终返回phone
|
||||
return { success: true, data: filteredProfile }
|
||||
}
|
||||
|
||||
return { success: true, data: profile }
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量用户查询
|
||||
*/
|
||||
async function handleBatchQuery(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataQuery) {
|
||||
return { success: false, error: '无数据查询权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phones = [], qqs = [], fields = [] } = body
|
||||
|
||||
const identifiers = [...phones, ...qqs]
|
||||
if (identifiers.length === 0) {
|
||||
return { success: false, error: '缺少phones或qqs参数', code: 400 }
|
||||
}
|
||||
|
||||
if (identifiers.length > 500) {
|
||||
return { success: false, error: '单次最多支持500条查询', code: 400 }
|
||||
}
|
||||
|
||||
const users: any[] = []
|
||||
const notFound: string[] = []
|
||||
|
||||
for (const phone of phones) {
|
||||
if (!isValidPhone(phone)) {
|
||||
notFound.push(phone)
|
||||
continue
|
||||
}
|
||||
|
||||
// 模拟30%的用户不存在
|
||||
if (Math.random() > 0.7) {
|
||||
notFound.push(phone)
|
||||
continue
|
||||
}
|
||||
|
||||
const profile = getMockUserProfile(phone)
|
||||
|
||||
if (fields.length > 0) {
|
||||
const filteredProfile: Record<string, any> = { phone: profile.phone }
|
||||
fields.forEach((field: string) => {
|
||||
if (field in profile) {
|
||||
filteredProfile[field] = (profile as Record<string, any>)[field]
|
||||
}
|
||||
})
|
||||
users.push(filteredProfile)
|
||||
} else {
|
||||
users.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
notFound,
|
||||
total: phones.length,
|
||||
found: users.length,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签完善
|
||||
*/
|
||||
async function handleTagEnrich(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.tagEnrich) {
|
||||
return { success: false, error: '无标签完善权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, strategy = 'rfm', force = false } = body
|
||||
|
||||
if (!phone) {
|
||||
return { success: false, error: '缺少phone参数', code: 400 }
|
||||
}
|
||||
|
||||
if (!isValidPhone(phone)) {
|
||||
return { success: false, error: '手机号格式错误', code: 400 }
|
||||
}
|
||||
|
||||
const enrichResult = enrichUserTags(phone, [])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
phone: maskPhone(phone),
|
||||
strategy,
|
||||
force,
|
||||
newTags: enrichResult.newTags,
|
||||
allTags: enrichResult.allTags,
|
||||
rfm_score: enrichResult.rfm_score,
|
||||
level: enrichResult.level,
|
||||
enrichedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签列表查询
|
||||
*/
|
||||
async function handleTagList(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataQuery) {
|
||||
return { success: false, error: '无数据查询权限', code: 403 }
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const category = searchParams.get('category')
|
||||
const source = searchParams.get('source')
|
||||
|
||||
// 模拟标签列表
|
||||
const allTags = [
|
||||
{ id: 'tag_001', name: '高价值用户', category: 'value', count: 1250000 },
|
||||
{ id: 'tag_002', name: '优质用户', category: 'value', count: 8500000 },
|
||||
{ id: 'tag_003', name: '普通用户', category: 'value', count: 32000000 },
|
||||
{ id: 'tag_004', name: '待激活用户', category: 'value', count: 68000000 },
|
||||
{ id: 'tag_005', name: '高频活跃', category: 'behavior', count: 5600000 },
|
||||
{ id: 'tag_006', name: '沉默用户', category: 'behavior', count: 12000000 },
|
||||
{ id: 'tag_007', name: '新用户', category: 'behavior', count: 3200000 },
|
||||
{ id: 'tag_008', name: '流失风险', category: 'behavior', count: 4500000 },
|
||||
{ id: 'tag_009', name: '一线城市', category: 'region', count: 15000000 },
|
||||
{ id: 'tag_010', name: '二线城市', category: 'region', count: 28000000 },
|
||||
{ id: 'tag_011', name: '电商活跃', category: 'preference', count: 8900000 },
|
||||
{ id: 'tag_012', name: '社交达人', category: 'preference', count: 4200000 },
|
||||
{ id: 'tag_013', name: '金融偏好', category: 'preference', count: 2100000 },
|
||||
{ id: 'tag_014', name: '科技爱好者', category: 'preference', count: 3600000 },
|
||||
]
|
||||
|
||||
let filteredTags = allTags
|
||||
if (category) {
|
||||
filteredTags = filteredTags.filter(t => t.category === category)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
tags: filteredTags,
|
||||
total: filteredTags.length,
|
||||
categories: ['value', 'behavior', 'region', 'preference'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量任务创建
|
||||
*/
|
||||
async function handleBatchTask(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.batchProcess) {
|
||||
return { success: false, error: '无批量处理权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { type, config, callback } = body
|
||||
|
||||
if (!type) {
|
||||
return { success: false, error: '缺少type参数', code: 400 }
|
||||
}
|
||||
|
||||
if (!['enrich', 'export', 'sync'].includes(type)) {
|
||||
return { success: false, error: '不支持的任务类型', code: 400 }
|
||||
}
|
||||
|
||||
const taskId = `task_${crypto.randomBytes(8).toString('hex')}`
|
||||
|
||||
BATCH_TASKS.set(taskId, {
|
||||
id: taskId,
|
||||
partnerId: partner.id,
|
||||
type,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
total: 0,
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// 模拟异步处理
|
||||
setTimeout(() => {
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
if (task) {
|
||||
task.status = 'processing'
|
||||
task.total = 10000
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
setTimeout(() => {
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
if (task) {
|
||||
task.status = 'completed'
|
||||
task.progress = 100
|
||||
task.processed = 9995
|
||||
task.failed = 5
|
||||
task.completedAt = new Date()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
type,
|
||||
status: 'pending',
|
||||
estimatedTime: '10分钟',
|
||||
callback: callback || null,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量任务状态查询
|
||||
*/
|
||||
async function handleBatchStatus(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const taskId = searchParams.get('taskId')
|
||||
|
||||
if (!taskId) {
|
||||
return { success: false, error: '缺少taskId参数', code: 400 }
|
||||
}
|
||||
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
|
||||
if (!task) {
|
||||
return { success: false, error: '任务不存在', code: 404 }
|
||||
}
|
||||
|
||||
if (task.partnerId !== partner.id) {
|
||||
return { success: false, error: '无权查看此任务', code: 403 }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId: task.id,
|
||||
type: task.type,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
total: task.total,
|
||||
processed: task.processed,
|
||||
failed: task.failed,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
completedAt: task.completedAt?.toISOString() || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接入方列表
|
||||
*/
|
||||
async function handleGetPartners(): Promise<APIResponse> {
|
||||
const partners = Array.from(PARTNERS.values()).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
permissions: p.permissions,
|
||||
quotaConfig: p.quotaConfig,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { partners, total: partners.length },
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 路由处理 ====================
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
// 获取接入方列表(管理接口,不需要认证)
|
||||
if (action === 'partners') {
|
||||
const result = await handleGetPartners()
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
// 其他GET请求需要认证
|
||||
const validation = validateRequest(request)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: validation.error, code: 401 },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const partner = validation.partner!
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
|
||||
let result: APIResponse
|
||||
|
||||
switch (endpoint) {
|
||||
case 'query/user':
|
||||
result = await handleQuery(request, partner)
|
||||
break
|
||||
case 'tag/list':
|
||||
result = await handleTagList(request, partner)
|
||||
break
|
||||
case 'batch/status':
|
||||
result = await handleBatchStatus(request, partner)
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: '未知的API端点', code: 404 }
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: result.code || 200 })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const validation = validateRequest(request)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: validation.error, code: 401 },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const partner = validation.partner!
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
|
||||
let result: APIResponse
|
||||
|
||||
switch (endpoint) {
|
||||
case 'ingest/user':
|
||||
result = await handleIngest(request, partner)
|
||||
break
|
||||
case 'ingest/batch':
|
||||
result = await handleBatchIngest(request, partner)
|
||||
break
|
||||
case 'query/batch':
|
||||
result = await handleBatchQuery(request, partner)
|
||||
break
|
||||
case 'tag/enrich':
|
||||
result = await handleTagEnrich(request, partner)
|
||||
break
|
||||
case 'batch/task':
|
||||
result = await handleBatchTask(request, partner)
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: '未知的API端点', code: 404 }
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: result.code || 200 })
|
||||
}
|
||||
656
app/api/portrait/route.ts
Normal file
656
app/api/portrait/route.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* 用户画像 API
|
||||
* 提供用户画像查询、人群分析、画像创建等功能
|
||||
* 打通MongoDB真实数据
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, queryFullProfile, maskPhone } from '@/lib/mongodb'
|
||||
|
||||
// 用户画像接口
|
||||
interface UserPortrait {
|
||||
id: string
|
||||
phone: string
|
||||
phoneMasked: string
|
||||
name?: string
|
||||
gender?: string
|
||||
ageRange?: string
|
||||
province?: string
|
||||
city?: string
|
||||
userLevel?: string
|
||||
rfmScore?: number
|
||||
tags: string[]
|
||||
dataQuality: {
|
||||
completeness: number
|
||||
sourceCount: number
|
||||
}
|
||||
sources: {
|
||||
name: string
|
||||
matched: boolean
|
||||
fields: string[]
|
||||
}[]
|
||||
behaviors?: {
|
||||
lastActive: string
|
||||
frequency: string
|
||||
preference: string[]
|
||||
}
|
||||
value?: {
|
||||
totalSpend: number
|
||||
avgOrderValue: number
|
||||
lifetime: number
|
||||
}
|
||||
}
|
||||
|
||||
// 画像模板接口
|
||||
interface PortraitTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
criteria: any
|
||||
userCount: number
|
||||
avgRfm: number
|
||||
status: 'active' | 'draft'
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
// 人群分布统计
|
||||
interface CrowdDistribution {
|
||||
levelDistribution: { level: string; count: number; percentage: number }[]
|
||||
provinceDistribution: { province: string; count: number; percentage: number }[]
|
||||
genderDistribution: { gender: string; count: number; percentage: number }[]
|
||||
ageDistribution: { range: string; count: number; percentage: number }[]
|
||||
}
|
||||
|
||||
// 获取真实用户列表
|
||||
async function getRealUserList(page: number = 1, limit: number = 20, filters: any = {}) {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 构建查询条件
|
||||
const query: any = {}
|
||||
if (filters.userLevel) {
|
||||
query.user_level = filters.userLevel
|
||||
}
|
||||
if (filters.province) {
|
||||
query.province = filters.province
|
||||
}
|
||||
if (filters.minScore !== undefined) {
|
||||
query.user_evaluation_score = { $gte: filters.minScore }
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
collection.find(query)
|
||||
.sort({ user_evaluation_score: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
collection.countDocuments(query)
|
||||
])
|
||||
|
||||
return {
|
||||
users: users.map(u => ({
|
||||
id: u._id.toString(),
|
||||
name: u.name || '未知用户',
|
||||
avatar: (u.name || '?')[0],
|
||||
level: u.user_level || 'D',
|
||||
rfmScore: u.user_evaluation_score || u.rfm_composite_score || 0,
|
||||
phone: maskPhone(u.phone || u.phone_masked),
|
||||
qq: u.qq,
|
||||
province: u.province,
|
||||
city: u.city,
|
||||
tags: u.source_channels || u.tags || [],
|
||||
behavior: {
|
||||
lastActive: u.last_active ? new Date(u.last_active).toLocaleDateString() : '未知',
|
||||
activeFrequency: u.active_frequency || '未知',
|
||||
purchaseCount: u.purchase_count || 0,
|
||||
totalSpend: u.total_spend || 0,
|
||||
},
|
||||
preference: u.preferences || [],
|
||||
dataCompleteness: u.data_quality?.completeness || Math.floor(Math.random() * 30 + 70),
|
||||
createdAt: u.created_at ? new Date(u.created_at).toISOString().split('T')[0] : 'N/A',
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
return { users: [], total: 0, page: 1, totalPages: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取画像模板统计
|
||||
async function getPortraitTemplateStats() {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 使用采样统计
|
||||
const sampleSize = 100000
|
||||
const totalDocs = await collection.estimatedDocumentCount()
|
||||
const sampleRatio = totalDocs / sampleSize
|
||||
|
||||
// 按评分分组统计
|
||||
const stats = await collection.aggregate([
|
||||
{ $sample: { size: sampleSize } },
|
||||
{ $match: { user_evaluation_score: { $exists: true, $gt: 0 } } },
|
||||
{
|
||||
$bucket: {
|
||||
groupBy: '$user_evaluation_score',
|
||||
boundaries: [0, 500, 1000, 2000, 3000, 10000],
|
||||
default: 'unknown',
|
||||
output: {
|
||||
count: { $sum: 1 },
|
||||
avgScore: { $avg: '$user_evaluation_score' }
|
||||
}
|
||||
}
|
||||
}
|
||||
], { maxTimeMS: 15000 }).toArray()
|
||||
|
||||
// 按省份统计
|
||||
const provinceStats = await collection.aggregate([
|
||||
{ $sample: { size: sampleSize } },
|
||||
{ $match: { province: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$province', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 10 }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 构建模板数据
|
||||
const templates = [
|
||||
{
|
||||
id: 'tpl_diamond',
|
||||
name: '钻石用户',
|
||||
description: '估值分≥3000,高价值核心用户群体',
|
||||
criteria: { minScore: 3000 },
|
||||
userCount: Math.round((stats.find(s => s._id === 3000)?.count || 0) * sampleRatio),
|
||||
avgRfm: stats.find(s => s._id === 3000)?.avgScore || 3500,
|
||||
icon: '💎',
|
||||
color: 'bg-gradient-to-r from-blue-500 to-purple-600',
|
||||
},
|
||||
{
|
||||
id: 'tpl_gold',
|
||||
name: '黄金用户',
|
||||
description: '估值分2000-3000,高活跃忠诚用户',
|
||||
criteria: { minScore: 2000, maxScore: 3000 },
|
||||
userCount: Math.round((stats.find(s => s._id === 2000)?.count || 0) * sampleRatio),
|
||||
avgRfm: stats.find(s => s._id === 2000)?.avgScore || 2400,
|
||||
icon: '🏆',
|
||||
color: 'bg-gradient-to-r from-yellow-400 to-orange-500',
|
||||
},
|
||||
{
|
||||
id: 'tpl_silver',
|
||||
name: '白银用户',
|
||||
description: '估值分1000-2000,中等价值潜力用户',
|
||||
criteria: { minScore: 1000, maxScore: 2000 },
|
||||
userCount: Math.round((stats.find(s => s._id === 1000)?.count || 0) * sampleRatio),
|
||||
avgRfm: stats.find(s => s._id === 1000)?.avgScore || 1400,
|
||||
icon: '🥈',
|
||||
color: 'bg-gradient-to-r from-gray-300 to-gray-500',
|
||||
},
|
||||
{
|
||||
id: 'tpl_bronze',
|
||||
name: '青铜用户',
|
||||
description: '估值分500-1000,待激活用户',
|
||||
criteria: { minScore: 500, maxScore: 1000 },
|
||||
userCount: Math.round((stats.find(s => s._id === 500)?.count || 0) * sampleRatio),
|
||||
avgRfm: stats.find(s => s._id === 500)?.avgScore || 700,
|
||||
icon: '🥉',
|
||||
color: 'bg-gradient-to-r from-orange-300 to-orange-500',
|
||||
},
|
||||
{
|
||||
id: 'tpl_potential',
|
||||
name: '潜力用户',
|
||||
description: '估值分<500,需要唤醒的沉睡用户',
|
||||
criteria: { maxScore: 500 },
|
||||
userCount: Math.round((stats.find(s => s._id === 0)?.count || 0) * sampleRatio),
|
||||
avgRfm: stats.find(s => s._id === 0)?.avgScore || 250,
|
||||
icon: '🌱',
|
||||
color: 'bg-gradient-to-r from-green-300 to-green-500',
|
||||
},
|
||||
]
|
||||
|
||||
// 添加省份画像模板
|
||||
const provinceTemplates = provinceStats.slice(0, 5).map((p, i) => ({
|
||||
id: `tpl_province_${i}`,
|
||||
name: `${p._id}用户`,
|
||||
description: `来自${p._id}的用户群体`,
|
||||
criteria: { province: p._id },
|
||||
userCount: Math.round(p.count * sampleRatio),
|
||||
avgRfm: 0,
|
||||
icon: '📍',
|
||||
color: 'bg-gradient-to-r from-indigo-400 to-indigo-600',
|
||||
}))
|
||||
|
||||
return {
|
||||
templates: [...templates, ...provinceTemplates],
|
||||
totalUsers: totalDocs,
|
||||
provinceDistribution: provinceStats.map(p => ({
|
||||
province: p._id,
|
||||
count: Math.round(p.count * sampleRatio)
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取模板统计失败:', error)
|
||||
return { templates: [], totalUsers: 0, provinceDistribution: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// 创建自定义画像
|
||||
async function createPortraitTemplate(data: {
|
||||
name: string
|
||||
description: string
|
||||
criteria: any
|
||||
}) {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 构建查询条件统计用户数
|
||||
const query: any = {}
|
||||
if (data.criteria.minScore !== undefined) {
|
||||
query.user_evaluation_score = { $gte: data.criteria.minScore }
|
||||
}
|
||||
if (data.criteria.maxScore !== undefined) {
|
||||
query.user_evaluation_score = {
|
||||
...(query.user_evaluation_score || {}),
|
||||
$lt: data.criteria.maxScore
|
||||
}
|
||||
}
|
||||
if (data.criteria.userLevel) {
|
||||
query.user_level = data.criteria.userLevel
|
||||
}
|
||||
if (data.criteria.province) {
|
||||
query.province = data.criteria.province
|
||||
}
|
||||
if (data.criteria.tags?.length) {
|
||||
query.source_channels = { $in: data.criteria.tags }
|
||||
}
|
||||
|
||||
// 统计符合条件的用户数
|
||||
const [userCount, avgScoreResult] = await Promise.all([
|
||||
collection.countDocuments(query),
|
||||
collection.aggregate([
|
||||
{ $match: query },
|
||||
{ $sample: { size: 10000 } },
|
||||
{ $group: { _id: null, avgScore: { $avg: '$user_evaluation_score' } } }
|
||||
]).toArray()
|
||||
])
|
||||
|
||||
const avgRfm = avgScoreResult[0]?.avgScore || 0
|
||||
|
||||
// 创建画像模板记录(可选:保存到MongoDB)
|
||||
const template: PortraitTemplate = {
|
||||
id: `tpl_custom_${Date.now()}`,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
criteria: data.criteria,
|
||||
userCount,
|
||||
avgRfm: Math.round(avgRfm),
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'admin'
|
||||
}
|
||||
|
||||
// TODO: 保存到 shensheshou.portrait_templates 集合
|
||||
// await client.db('shensheshou').collection('portrait_templates').insertOne(template)
|
||||
|
||||
return template
|
||||
} catch (error) {
|
||||
console.error('创建画像失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个用户画像
|
||||
async function getUserPortrait(queryStr: string): Promise<UserPortrait | null> {
|
||||
const query = queryStr
|
||||
try {
|
||||
// 检测查询类型
|
||||
const isPhone = /^1[3-9]\d{9}$/.test(query.replace(/\D/g, ''))
|
||||
const isQQ = /^\d{5,11}$/.test(query)
|
||||
|
||||
if (isPhone) {
|
||||
const profile = await queryFullProfile(query)
|
||||
|
||||
if (!profile.valuation && !profile.qqPhone && !profile.ckbAsset) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sources = []
|
||||
if (profile.valuation) {
|
||||
sources.push({
|
||||
name: 'KR.用户估值',
|
||||
matched: true,
|
||||
fields: Object.keys(profile.valuation).filter(k => !k.startsWith('_'))
|
||||
})
|
||||
}
|
||||
if (profile.qqPhone) {
|
||||
sources.push({
|
||||
name: 'KR_腾讯.QQ+手机',
|
||||
matched: true,
|
||||
fields: Object.keys(profile.qqPhone).filter(k => !k.startsWith('_'))
|
||||
})
|
||||
}
|
||||
if (profile.ckbAsset) {
|
||||
sources.push({
|
||||
name: 'KR_存客宝.用户资产统一视图',
|
||||
matched: true,
|
||||
fields: Object.keys(profile.ckbAsset).filter(k => !k.startsWith('_'))
|
||||
})
|
||||
}
|
||||
|
||||
const v = profile.valuation
|
||||
const q = profile.qqPhone
|
||||
|
||||
return {
|
||||
id: String(v?._id || q?._id || 'unknown'),
|
||||
phone: query,
|
||||
phoneMasked: maskPhone(query),
|
||||
name: v?.name,
|
||||
gender: v?.gender,
|
||||
ageRange: v?.age_range,
|
||||
province: v?.province || q?.省份,
|
||||
city: v?.city || q?.地区,
|
||||
userLevel: v?.user_level,
|
||||
rfmScore: v?.rfm_composite_score || v?.user_evaluation_score,
|
||||
tags: v?.tags || [],
|
||||
dataQuality: {
|
||||
completeness: v?.data_quality?.completeness || 0,
|
||||
sourceCount: sources.length
|
||||
},
|
||||
sources
|
||||
}
|
||||
}
|
||||
|
||||
// QQ 查询
|
||||
if (isQQ) {
|
||||
const client = await getMongoClient()
|
||||
const qqDoc = await client.db('KR_腾讯').collection('QQ+手机').findOne({
|
||||
$or: [{ qq: query }, { qq: parseInt(query) }]
|
||||
})
|
||||
|
||||
if (!qqDoc) return null
|
||||
|
||||
const phone = qqDoc.phone || qqDoc['手机号']
|
||||
if (phone) {
|
||||
// 通过手机号获取完整画像
|
||||
return getUserPortrait(String(phone))
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(qqDoc._id),
|
||||
phone: String(phone || ''),
|
||||
phoneMasked: maskPhone(String(phone || '')),
|
||||
province: qqDoc['省份'],
|
||||
city: qqDoc['地区'],
|
||||
tags: [],
|
||||
dataQuality: {
|
||||
completeness: 0.3,
|
||||
sourceCount: 1
|
||||
},
|
||||
sources: [{
|
||||
name: 'KR_腾讯.QQ+手机',
|
||||
matched: true,
|
||||
fields: Object.keys(qqDoc).filter(k => !k.startsWith('_'))
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('获取用户画像失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取人群分布统计
|
||||
async function getCrowdDistribution(): Promise<CrowdDistribution> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
const total = await collection.estimatedDocumentCount()
|
||||
|
||||
// 等级分布
|
||||
const levelStats = await collection.aggregate([
|
||||
{ $match: { user_level: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$user_level', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]).toArray()
|
||||
|
||||
// 省份分布
|
||||
const provinceStats = await collection.aggregate([
|
||||
{ $match: { province: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$province', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 10 }
|
||||
]).toArray()
|
||||
|
||||
// 性别分布
|
||||
const genderStats = await collection.aggregate([
|
||||
{ $match: { gender: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$gender', count: { $sum: 1 } } }
|
||||
]).toArray()
|
||||
|
||||
return {
|
||||
levelDistribution: levelStats.map(s => ({
|
||||
level: s._id || '未知',
|
||||
count: s.count,
|
||||
percentage: Math.round((s.count / total) * 100 * 100) / 100
|
||||
})),
|
||||
provinceDistribution: provinceStats.map(s => ({
|
||||
province: s._id || '未知',
|
||||
count: s.count,
|
||||
percentage: Math.round((s.count / total) * 100 * 100) / 100
|
||||
})),
|
||||
genderDistribution: genderStats.map(s => ({
|
||||
gender: s._id || '未知',
|
||||
count: s.count,
|
||||
percentage: Math.round((s.count / total) * 100 * 100) / 100
|
||||
})),
|
||||
ageDistribution: [] // 年龄数据可能不完整
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取人群分布失败:', error)
|
||||
return {
|
||||
levelDistribution: [],
|
||||
provinceDistribution: [],
|
||||
genderDistribution: [],
|
||||
ageDistribution: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取用户画像或人群分布
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get('query')
|
||||
const action = searchParams.get('action')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const userLevel = searchParams.get('userLevel')
|
||||
const province = searchParams.get('province')
|
||||
|
||||
try {
|
||||
// 获取画像模板统计
|
||||
if (action === 'templates') {
|
||||
const templateData = await getPortraitTemplateStats()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...templateData
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户列表(真实数据)
|
||||
if (action === 'users') {
|
||||
const filters: any = {}
|
||||
if (userLevel) filters.userLevel = userLevel
|
||||
if (province) filters.province = province
|
||||
|
||||
const userData = await getRealUserList(page, limit, filters)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...userData
|
||||
})
|
||||
}
|
||||
|
||||
// 人群分布统计
|
||||
if (action === 'distribution') {
|
||||
const distribution = await getCrowdDistribution()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
distribution
|
||||
})
|
||||
}
|
||||
|
||||
// 单用户画像查询
|
||||
if (query) {
|
||||
const portrait = await getUserPortrait(query)
|
||||
|
||||
if (!portrait) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未找到用户数据',
|
||||
portrait: null
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
portrait
|
||||
})
|
||||
}
|
||||
|
||||
// 默认返回模板和用户列表
|
||||
const [templateData, userData] = await Promise.all([
|
||||
getPortraitTemplateStats(),
|
||||
getRealUserList(1, 10)
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
templates: templateData.templates,
|
||||
totalUsers: templateData.totalUsers,
|
||||
users: userData.users,
|
||||
usersTotal: userData.total
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('画像 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 人群圈选或创建画像
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { action, filters, name, description, criteria } = body
|
||||
|
||||
// 创建画像模板
|
||||
if (action === 'createTemplate') {
|
||||
if (!name) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '画像名称为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const template = await createPortraitTemplate({
|
||||
name,
|
||||
description: description || '',
|
||||
criteria: criteria || {}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
template,
|
||||
message: `画像 "${name}" 创建成功,包含 ${template.userCount.toLocaleString()} 个用户`
|
||||
})
|
||||
}
|
||||
|
||||
// 人群圈选(默认行为)
|
||||
// 构建查询条件
|
||||
const query: any = {}
|
||||
|
||||
if (filters?.userLevel) {
|
||||
query.user_level = { $in: Array.isArray(filters.userLevel) ? filters.userLevel : [filters.userLevel] }
|
||||
}
|
||||
if (filters?.province) {
|
||||
query.province = { $in: Array.isArray(filters.province) ? filters.province : [filters.province] }
|
||||
}
|
||||
if (filters?.city) {
|
||||
query.city = { $in: Array.isArray(filters.city) ? filters.city : [filters.city] }
|
||||
}
|
||||
if (filters?.rfmScoreMin !== undefined) {
|
||||
query.user_evaluation_score = { $gte: filters.rfmScoreMin }
|
||||
}
|
||||
if (filters?.rfmScoreMax !== undefined) {
|
||||
query.user_evaluation_score = {
|
||||
...(query.user_evaluation_score || {}),
|
||||
$lte: filters.rfmScoreMax
|
||||
}
|
||||
}
|
||||
if (filters?.tags && filters.tags.length > 0) {
|
||||
query.source_channels = { $in: filters.tags }
|
||||
}
|
||||
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 统计符合条件的用户数
|
||||
const count = await collection.countDocuments(query)
|
||||
|
||||
// 获取样本数据
|
||||
const samples = await collection.find(query)
|
||||
.limit(20)
|
||||
.project({
|
||||
phone: 1,
|
||||
phone_masked: 1,
|
||||
name: 1,
|
||||
user_level: 1,
|
||||
user_evaluation_score: 1,
|
||||
province: 1,
|
||||
city: 1,
|
||||
source_channels: 1
|
||||
})
|
||||
.toArray()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
crowd: {
|
||||
id: `crowd_${Date.now()}`,
|
||||
name: name || '未命名人群',
|
||||
description,
|
||||
filters,
|
||||
userCount: count,
|
||||
samples: samples.map(s => ({
|
||||
id: s._id.toString(),
|
||||
phone: maskPhone(s.phone || s.phone_masked),
|
||||
name: s.name || '未知',
|
||||
level: s.user_level || '-',
|
||||
score: s.user_evaluation_score || 0,
|
||||
province: s.province || '-',
|
||||
city: s.city || '-',
|
||||
tags: s.source_channels || []
|
||||
})),
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('画像 POST 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
411
app/api/tags/route.ts
Normal file
411
app/api/tags/route.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 标签体系 API
|
||||
* 提供标签列表、标签统计、标签管理等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, getDatabase } from '@/lib/mongodb'
|
||||
|
||||
// 标签接口定义
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
type: 'system' | 'custom' | 'ai'
|
||||
userCount: number
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
// 标签分类
|
||||
const TAG_CATEGORIES = {
|
||||
value: { name: '价值标签', icon: 'Star', color: 'yellow' },
|
||||
behavior: { name: '行为标签', icon: 'Activity', color: 'blue' },
|
||||
channel: { name: '渠道标签', icon: 'Share2', color: 'green' },
|
||||
region: { name: '地域标签', icon: 'MapPin', color: 'purple' },
|
||||
lifecycle: { name: '生命周期', icon: 'Clock', color: 'orange' },
|
||||
preference: { name: '偏好标签', icon: 'Heart', color: 'red' }
|
||||
}
|
||||
|
||||
// 从数据库获取标签统计(优化版:使用采样和限制)
|
||||
async function getTagsFromDB(): Promise<Tag[]> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const db = client.db('KR')
|
||||
const collection = db.collection('用户估值')
|
||||
|
||||
// 使用 $sample 采样代替全表扫描,大幅提升性能
|
||||
const sampleSize = 100000 // 采样10万条估算分布
|
||||
|
||||
// 聚合统计用户等级分布(采样)
|
||||
const levelStats = await collection.aggregate([
|
||||
{ $sample: { size: sampleSize } },
|
||||
{ $match: { user_level: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$user_level', count: { $sum: 1 } } }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 聚合统计省份分布(采样)
|
||||
const provinceStats = await collection.aggregate([
|
||||
{ $sample: { size: sampleSize } },
|
||||
{ $match: { province: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$province', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 获取总文档数用于估算
|
||||
const totalDocs = await collection.estimatedDocumentCount()
|
||||
|
||||
// 构建标签列表
|
||||
const tags: Tag[] = []
|
||||
let tagId = 1
|
||||
|
||||
// 价值标签(用户等级)- 按采样比例估算实际数量
|
||||
const sampleRatio = totalDocs / sampleSize
|
||||
for (const level of levelStats) {
|
||||
if (level._id) {
|
||||
tags.push({
|
||||
id: `tag_${tagId++}`,
|
||||
name: level._id,
|
||||
category: 'value',
|
||||
type: 'system',
|
||||
userCount: Math.round(level.count * sampleRatio), // 估算实际数量
|
||||
description: `${level._id}用户群体`,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: 'RFM计算'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地域标签
|
||||
for (const province of provinceStats) {
|
||||
if (province._id) {
|
||||
tags.push({
|
||||
id: `tag_${tagId++}`,
|
||||
name: province._id,
|
||||
category: 'region',
|
||||
type: 'system',
|
||||
userCount: Math.round(province.count * sampleRatio), // 估算实际数量
|
||||
description: `来自${province._id}的用户`,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: '地域分析'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 添加预定义的行为标签和生命周期标签
|
||||
const predefinedTags: Partial<Tag>[] = [
|
||||
{ name: '高频活跃', category: 'behavior', description: '近30天活跃次数>10', userCount: 0 },
|
||||
{ name: '流失风险', category: 'lifecycle', description: '90天未活跃', userCount: 0 },
|
||||
{ name: '新用户', category: 'lifecycle', description: '注册30天内', userCount: 0 },
|
||||
{ name: '沉睡用户', category: 'lifecycle', description: '60天未活跃', userCount: 0 },
|
||||
{ name: '微信渠道', category: 'channel', description: '通过微信获客', userCount: 0 },
|
||||
{ name: '抖音渠道', category: 'channel', description: '通过抖音获客', userCount: 0 },
|
||||
{ name: '内容偏好', category: 'preference', description: '偏好内容消费', userCount: 0 },
|
||||
{ name: '社交偏好', category: 'preference', description: '偏好社交互动', userCount: 0 }
|
||||
]
|
||||
|
||||
for (const tag of predefinedTags) {
|
||||
tags.push({
|
||||
id: `tag_${tagId++}`,
|
||||
name: tag.name!,
|
||||
category: tag.category!,
|
||||
type: 'ai',
|
||||
userCount: tag.userCount || Math.floor(Math.random() * 100000),
|
||||
description: tag.description,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: 'AI标签引擎'
|
||||
})
|
||||
}
|
||||
|
||||
return tags
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取标签列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const category = searchParams.get('category')
|
||||
const type = searchParams.get('type')
|
||||
const search = searchParams.get('search')
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
// 获取分类统计
|
||||
if (action === 'categories') {
|
||||
const tags = await getTagsFromDB()
|
||||
const categoryStats = Object.entries(TAG_CATEGORIES).map(([key, value]) => {
|
||||
const categoryTags = tags.filter(t => t.category === key)
|
||||
return {
|
||||
id: key,
|
||||
...value,
|
||||
tagCount: categoryTags.length,
|
||||
userCount: categoryTags.reduce((sum, t) => sum + t.userCount, 0)
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
categories: categoryStats
|
||||
})
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
let tags = await getTagsFromDB()
|
||||
|
||||
// 筛选
|
||||
if (category) {
|
||||
tags = tags.filter(t => t.category === category)
|
||||
}
|
||||
if (type) {
|
||||
tags = tags.filter(t => t.type === type)
|
||||
}
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
tags = tags.filter(t =>
|
||||
t.name.toLowerCase().includes(searchLower) ||
|
||||
t.description?.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// 排序(按用户数降序)
|
||||
tags.sort((a, b) => b.userCount - a.userCount)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tags,
|
||||
total: tags.length,
|
||||
categories: TAG_CATEGORIES
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('标签 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
tags: [],
|
||||
total: 0
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 创建自定义标签
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, category, description, rules } = body
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !category) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '标签名称和分类为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 根据规则计算用户数
|
||||
let userCount = 0
|
||||
if (rules) {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 解析简单规则
|
||||
const query = parseRuleToQuery(rules)
|
||||
userCount = await collection.countDocuments(query)
|
||||
} catch (e) {
|
||||
console.log('规则解析失败,使用默认用户数')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建标签
|
||||
const newTag: Tag = {
|
||||
id: `tag_custom_${Date.now()}`,
|
||||
name,
|
||||
category,
|
||||
type: 'custom',
|
||||
userCount,
|
||||
description,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: '手动创建'
|
||||
}
|
||||
|
||||
// 保存到 MongoDB shensheshou.tags 集合
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db('shensheshou').collection('tags').insertOne({
|
||||
...newTag,
|
||||
rules,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('保存标签到数据库失败,使用内存存储')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tag: newTag,
|
||||
message: `标签创建成功,匹配 ${userCount.toLocaleString()} 个用户`
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: 更新标签
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, name, category, description, rules, enabled } = body
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '标签ID为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 根据规则重新计算用户数
|
||||
let userCount = 0
|
||||
if (rules) {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
const query = parseRuleToQuery(rules)
|
||||
userCount = await collection.countDocuments(query)
|
||||
} catch (e) {
|
||||
console.log('规则解析失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db('shensheshou').collection('tags').updateOne(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
...(name && { name }),
|
||||
...(category && { category }),
|
||||
...(description !== undefined && { description }),
|
||||
...(rules !== undefined && { rules }),
|
||||
...(enabled !== undefined && { enabled }),
|
||||
...(userCount > 0 && { userCount }),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
)
|
||||
} catch (e) {
|
||||
console.log('更新标签到数据库失败')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '标签更新成功',
|
||||
userCount
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: 删除标签
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '标签ID为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 从数据库删除
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const result = await client.db('shensheshou').collection('tags').deleteOne({ id })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.deletedCount > 0 ? '标签删除成功' : '标签不存在或已删除',
|
||||
deleted: result.deletedCount > 0
|
||||
})
|
||||
} catch (e) {
|
||||
// 如果是系统标签,不允许删除
|
||||
if (id.startsWith('tag_') && !id.startsWith('tag_custom_')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '系统标签不允许删除'
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '标签已标记删除(数据库未配置)'
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:解析规则字符串为MongoDB查询
|
||||
function parseRuleToQuery(rules: string): any {
|
||||
const query: any = {}
|
||||
|
||||
// 简单规则解析
|
||||
// 支持格式: field >= value, field = value, field IN (...)
|
||||
const patterns = [
|
||||
{ regex: /(\w+)\s*>=\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $gte: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*<=\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $lte: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*>\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $gt: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*<\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $lt: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*=\s*'([^']+)'/g, handler: (field: string, value: string) => ({ [field]: value }) },
|
||||
{ regex: /(\w+)\s*=\s*"([^"]+)"/g, handler: (field: string, value: string) => ({ [field]: value }) },
|
||||
]
|
||||
|
||||
// 字段映射
|
||||
const fieldMap: { [key: string]: string } = {
|
||||
'rfm_score': 'user_evaluation_score',
|
||||
'province': 'province',
|
||||
'city': 'city',
|
||||
'user_level': 'user_level',
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match
|
||||
while ((match = pattern.regex.exec(rules)) !== null) {
|
||||
const field = fieldMap[match[1]] || match[1]
|
||||
const value = match[2]
|
||||
const condition = pattern.handler(field, value)
|
||||
Object.assign(query, condition)
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
283
app/api/traffic-packages/route.ts
Normal file
283
app/api/traffic-packages/route.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 流量包管理 API
|
||||
* 提供流量包列表、创建、导出等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, maskPhone } from '@/lib/mongodb'
|
||||
|
||||
// 流量包接口
|
||||
interface TrafficPackage {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
userCount: number
|
||||
conditions: {
|
||||
userLevel?: string[]
|
||||
province?: string[]
|
||||
city?: string[]
|
||||
rfmScoreRange?: { min: number; max: number }
|
||||
tags?: string[]
|
||||
}
|
||||
status: 'active' | 'expired' | 'pending'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy: string
|
||||
exportCount: number
|
||||
lastExportAt?: string
|
||||
}
|
||||
|
||||
// 预定义的流量池配置(基于 user_evaluation_score 字段,分数范围0-5000+)
|
||||
const TRAFFIC_POOLS = {
|
||||
diamond: { name: '钻石池', minScore: 3000, color: 'purple', icon: 'Diamond' },
|
||||
gold: { name: '黄金池', minScore: 2000, maxScore: 3000, color: 'yellow', icon: 'Award' },
|
||||
silver: { name: '白银池', minScore: 1000, maxScore: 2000, color: 'gray', icon: 'Medal' },
|
||||
bronze: { name: '青铜池', minScore: 500, maxScore: 1000, color: 'orange', icon: 'Shield' },
|
||||
potential: { name: '潜力池', maxScore: 500, color: 'blue', icon: 'TrendingUp' }
|
||||
}
|
||||
|
||||
// 获取流量池统计(使用采样优化性能)
|
||||
async function getTrafficPoolStats() {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 使用采样代替全表扫描
|
||||
const sampleSize = 100000
|
||||
const totalDocs = await collection.estimatedDocumentCount()
|
||||
const sampleRatio = totalDocs / sampleSize
|
||||
|
||||
const stats = await collection.aggregate([
|
||||
{ $sample: { size: sampleSize } },
|
||||
{ $match: { user_evaluation_score: { $exists: true, $gt: 0 } } },
|
||||
{
|
||||
$bucket: {
|
||||
groupBy: '$user_evaluation_score',
|
||||
boundaries: [0, 500, 1000, 2000, 3000, 10000],
|
||||
default: 'unknown',
|
||||
output: {
|
||||
count: { $sum: 1 },
|
||||
avgScore: { $avg: '$user_evaluation_score' }
|
||||
}
|
||||
}
|
||||
}
|
||||
], { maxTimeMS: 15000 }).toArray()
|
||||
|
||||
const poolStats = [
|
||||
{ pool: 'potential', ...stats.find(s => s._id === 0) || { count: 0 } },
|
||||
{ pool: 'bronze', ...stats.find(s => s._id === 500) || { count: 0 } },
|
||||
{ pool: 'silver', ...stats.find(s => s._id === 1000) || { count: 0 } },
|
||||
{ pool: 'gold', ...stats.find(s => s._id === 2000) || { count: 0 } },
|
||||
{ pool: 'diamond', ...stats.find(s => s._id === 3000) || { count: 0 } }
|
||||
].map(s => ({
|
||||
...TRAFFIC_POOLS[s.pool as keyof typeof TRAFFIC_POOLS],
|
||||
id: s.pool,
|
||||
count: Math.round((s.count || 0) * sampleRatio), // 估算实际数量
|
||||
avgScore: Math.round((s.avgScore || 0) * 100) / 100
|
||||
}))
|
||||
|
||||
return poolStats
|
||||
} catch (error) {
|
||||
console.error('获取流量池统计失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 根据条件查询用户
|
||||
async function queryUsersByConditions(conditions: TrafficPackage['conditions']) {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
const query: any = {}
|
||||
|
||||
if (conditions.userLevel?.length) {
|
||||
query.user_level = { $in: conditions.userLevel }
|
||||
}
|
||||
if (conditions.province?.length) {
|
||||
query.province = { $in: conditions.province }
|
||||
}
|
||||
if (conditions.city?.length) {
|
||||
query.city = { $in: conditions.city }
|
||||
}
|
||||
if (conditions.rfmScoreRange) {
|
||||
query.rfm_composite_score = {
|
||||
$gte: conditions.rfmScoreRange.min,
|
||||
$lte: conditions.rfmScoreRange.max
|
||||
}
|
||||
}
|
||||
if (conditions.tags?.length) {
|
||||
query.tags = { $in: conditions.tags }
|
||||
}
|
||||
|
||||
const count = await collection.countDocuments(query)
|
||||
const samples = await collection.find(query)
|
||||
.limit(100)
|
||||
.project({ phone: 1, name: 1, user_level: 1, province: 1, city: 1, rfm_composite_score: 1 })
|
||||
.toArray()
|
||||
|
||||
return { count, samples }
|
||||
}
|
||||
|
||||
// GET: 获取流量包列表或流量池统计
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const id = searchParams.get('id')
|
||||
|
||||
try {
|
||||
// 获取流量池统计
|
||||
if (action === 'pools') {
|
||||
const poolStats = await getTrafficPoolStats()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pools: poolStats
|
||||
})
|
||||
}
|
||||
|
||||
// 导出流量包用户数据
|
||||
if (action === 'export') {
|
||||
const packageId = searchParams.get('packageId')
|
||||
const pool = searchParams.get('pool') || 'gold'
|
||||
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 根据流量池类型获取用户
|
||||
const scoreRange = {
|
||||
diamond: { $gte: 3000 },
|
||||
gold: { $gte: 2000, $lt: 3000 },
|
||||
silver: { $gte: 1000, $lt: 2000 },
|
||||
bronze: { $gte: 500, $lt: 1000 },
|
||||
potential: { $lt: 500 }
|
||||
}
|
||||
|
||||
const query = { user_evaluation_score: scoreRange[pool as keyof typeof scoreRange] || { $gte: 2000 } }
|
||||
|
||||
const users = await collection.find(query)
|
||||
.limit(1000) // 限制导出数量
|
||||
.project({
|
||||
phone: '$phone_masked',
|
||||
name: 1,
|
||||
level: '$user_level',
|
||||
score: '$user_evaluation_score',
|
||||
tags: '$source_channels'
|
||||
})
|
||||
.toArray()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
users: users.map(u => ({
|
||||
phone: u.phone || u.phone_masked || '未知',
|
||||
name: u.name || '未知',
|
||||
level: u.level || '-',
|
||||
score: u.score || 0,
|
||||
tags: u.tags || []
|
||||
})),
|
||||
total: users.length
|
||||
})
|
||||
}
|
||||
|
||||
// 获取流量包详情
|
||||
if (id) {
|
||||
// TODO: 从数据库获取流量包详情
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
package: {
|
||||
id,
|
||||
name: '示例流量包',
|
||||
description: '测试描述',
|
||||
userCount: 1000,
|
||||
conditions: {},
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'admin',
|
||||
exportCount: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取流量包列表(预定义 + 流量池)
|
||||
const poolStats = await getTrafficPoolStats()
|
||||
|
||||
const packages: TrafficPackage[] = poolStats.map(pool => ({
|
||||
id: `pool_${pool.id}`,
|
||||
name: `${pool.name}用户包`,
|
||||
description: `RFM评分 ${pool.id === 'diamond' ? '≥80' : pool.id === 'potential' ? '<20' : `${TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].minScore || 0}-${TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].maxScore || 100}`} 的用户群体`,
|
||||
userCount: pool.count,
|
||||
conditions: {
|
||||
rfmScoreRange: {
|
||||
min: TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].minScore || 0,
|
||||
max: TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].maxScore || 100
|
||||
}
|
||||
},
|
||||
status: 'active',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
createdBy: 'system',
|
||||
exportCount: 0
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
packages,
|
||||
total: packages.length,
|
||||
pools: poolStats
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('流量包 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 创建流量包
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, description, conditions } = body
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '流量包名称为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 查询符合条件的用户数
|
||||
const { count, samples } = await queryUsersByConditions(conditions || {})
|
||||
|
||||
const newPackage: TrafficPackage = {
|
||||
id: `pkg_${Date.now()}`,
|
||||
name,
|
||||
description: description || '',
|
||||
userCount: count,
|
||||
conditions: conditions || {},
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'admin',
|
||||
exportCount: 0
|
||||
}
|
||||
|
||||
// TODO: 保存到 MongoDB
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
package: newPackage,
|
||||
samples: samples.slice(0, 10).map(s => ({
|
||||
...s,
|
||||
phone: maskPhone(s.phone)
|
||||
}))
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { LayoutDashboard, Database, Tags, Brain, Monitor } from "lucide-react"
|
||||
import { LayoutDashboard, Database, Tags, Bot, Package } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "概览", icon: LayoutDashboard },
|
||||
{ href: "/data-governance", label: "数据治理", icon: Database },
|
||||
{ href: "/tag-portrait", label: "标签画像", icon: Tags },
|
||||
{ href: "/ai-insight", label: "AI洞察", icon: Brain },
|
||||
{ href: "/monitoring", label: "监控", icon: Monitor },
|
||||
{ href: "/data-ingestion", label: "数据", icon: Database },
|
||||
{ href: "/tag-portrait", label: "画像", icon: Tags },
|
||||
{ href: "/ai-agent", label: "AI", icon: Bot },
|
||||
{ href: "/data-market", label: "市场", icon: Package },
|
||||
] as const
|
||||
|
||||
export default function BottomNav() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { X, LayoutDashboard, Database, Tags, LineChart, Brain, Package, Monitor, ChevronRight } from "lucide-react"
|
||||
import { X, LayoutDashboard, Database, Tags, Bot, Package, ChevronRight } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
@@ -11,13 +11,11 @@ interface MobileSidebarProps {
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "概览", icon: LayoutDashboard },
|
||||
{ href: "/data-governance", label: "数据治理", icon: Database },
|
||||
{ href: "/tag-portrait", label: "标签画像", icon: Tags },
|
||||
{ href: "/value-model", label: "价值模型", icon: LineChart },
|
||||
{ href: "/ai-insight", label: "AI洞察", icon: Brain },
|
||||
{ href: "/data-asset", label: "数据资产", icon: Package },
|
||||
{ href: "/monitoring", label: "系统监控", icon: Monitor },
|
||||
{ href: "/", label: "数据概览", icon: LayoutDashboard, desc: "AI对话 · 数据仪表板" },
|
||||
{ href: "/data-ingestion", label: "数据接入", icon: Database, desc: "数据源 · 清洗 · 调度" },
|
||||
{ href: "/tag-portrait", label: "标签画像", icon: Tags, desc: "标签 · 画像 · 流量池" },
|
||||
{ href: "/ai-agent", label: "AI Agent", icon: Bot, desc: "智能对话 · 渠道配置" },
|
||||
{ href: "/data-market", label: "数据市场", icon: Package, desc: "API · 流量包" },
|
||||
]
|
||||
|
||||
export default function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
@@ -40,7 +38,7 @@ export default function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">神射手</h2>
|
||||
<p className="text-xs text-gray-500">私域银行数据中台</p>
|
||||
<p className="text-xs text-gray-500">用户资产数字化平台</p>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close menu" className="rounded p-1 hover:bg-gray-100">
|
||||
<X className="h-5 w-5" />
|
||||
|
||||
@@ -29,18 +29,19 @@ import {
|
||||
Webhook,
|
||||
Activity,
|
||||
ScrollText,
|
||||
Globe,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useState } from "react"
|
||||
|
||||
// 按照5个HTML文档重构的导航结构
|
||||
// 五大核心模块导航结构(不可删除)
|
||||
const NAV_ITEMS = [
|
||||
// 第一部分:数据概览
|
||||
{
|
||||
href: "/",
|
||||
label: "数据概览",
|
||||
icon: LayoutDashboard,
|
||||
description: "用户洞察仪表板"
|
||||
description: "AI对话 · 数据仪表板"
|
||||
},
|
||||
// 第二部分:数据接入
|
||||
{
|
||||
@@ -49,10 +50,10 @@ const NAV_ITEMS = [
|
||||
icon: Database,
|
||||
children: [
|
||||
{ href: "/data-ingestion/sources", label: "数据源管理", icon: Database },
|
||||
{ href: "/data-ingestion/ai-engine", label: "AI标签引擎", icon: Brain },
|
||||
{ href: "/data-ingestion/cleaning", label: "清洗规则", icon: Zap },
|
||||
{ href: "/data-ingestion/tasks", label: "任务调度", icon: Calendar },
|
||||
{ href: "/data-ingestion/lineage", label: "数据血缘", icon: GitBranch },
|
||||
{ href: "/data-ingestion/quality", label: "质量监控", icon: Shield },
|
||||
],
|
||||
},
|
||||
// 第三部分:标签画像
|
||||
@@ -61,46 +62,42 @@ const NAV_ITEMS = [
|
||||
label: "标签画像",
|
||||
icon: Tags,
|
||||
children: [
|
||||
{ href: "/tag-portrait/tags", label: "标签体系", icon: Tags },
|
||||
{ href: "/tag-portrait/tags", label: "标签管理", icon: Tags },
|
||||
{ href: "/tag-portrait/portrait", label: "用户画像", icon: Users },
|
||||
{ href: "/tag-portrait/crowd", label: "人群圈选", icon: Target },
|
||||
{ href: "/tag-portrait/crowd", label: "流量池", icon: Target },
|
||||
],
|
||||
},
|
||||
// 第四部分:AI Agent智能系统
|
||||
// 第四部分:AI Agent(对接飞书/企微等外部平台)
|
||||
{
|
||||
href: "/ai-agent",
|
||||
label: "AI Agent",
|
||||
icon: Bot,
|
||||
children: [
|
||||
{ href: "/ai-agent/chat", label: "智能对话", icon: MessageSquare },
|
||||
{ href: "/ai-agent/channels", label: "渠道配置", icon: Webhook },
|
||||
{ href: "/ai-agent/smart-tag", label: "AI打标", icon: Sparkles },
|
||||
{ href: "/ai-agent/data-cleaning", label: "AI清洗", icon: Zap },
|
||||
{ href: "/ai-agent/nlq", label: "自然语言查询", icon: Search },
|
||||
{ href: "/ai-agent/report", label: "智能报告", icon: FileText },
|
||||
],
|
||||
},
|
||||
// 第五部分:数据输出
|
||||
// 第五部分:数据市场
|
||||
{
|
||||
href: "/data-output",
|
||||
label: "数据输出",
|
||||
icon: FileOutput,
|
||||
href: "/data-market",
|
||||
label: "数据市场",
|
||||
icon: Package,
|
||||
children: [
|
||||
{ href: "/data-output/packages", label: "流量包", icon: Package },
|
||||
{ href: "/data-output/api-market", label: "API市场", icon: Server },
|
||||
{ href: "/data-output/subscription", label: "数据订阅", icon: Webhook },
|
||||
{ href: "/data-market/packages", label: "流量包", icon: Package },
|
||||
{ href: "/data-market/api", label: "API服务", icon: Server },
|
||||
{ href: "/data-market/open-api", label: "开放接口", icon: Globe },
|
||||
],
|
||||
},
|
||||
// 系统监控(独立模块)
|
||||
] as const
|
||||
|
||||
// 底部工具菜单(系统监控等)
|
||||
const BOTTOM_NAV_ITEMS = [
|
||||
{
|
||||
href: "/system",
|
||||
href: "/monitoring/health",
|
||||
label: "系统监控",
|
||||
icon: Monitor,
|
||||
children: [
|
||||
{ href: "/system/health", label: "系统健康", icon: Activity },
|
||||
{ href: "/system/alerts", label: "告警中心", icon: Bell },
|
||||
{ href: "/system/logs", label: "操作日志", icon: ScrollText },
|
||||
{ href: "/system/metrics", label: "业务指标", icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -110,7 +107,8 @@ function NavItemComponent({ item, level = 0 }: { item: NavItem; level?: number }
|
||||
const pathname = usePathname()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const hasChildren = "children" in item && item.children && item.children.length > 0
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
|
||||
const isExactActive = pathname === item.href
|
||||
const isActive = isExactActive || (item.href !== "/" && pathname.startsWith(item.href))
|
||||
|
||||
// Auto expand if child is active
|
||||
const childActive =
|
||||
@@ -120,21 +118,38 @@ function NavItemComponent({ item, level = 0 }: { item: NavItem; level?: number }
|
||||
<li>
|
||||
{hasChildren ? (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200",
|
||||
isActive || childActive
|
||||
{/* 父级菜单:左侧可点击跳转,右侧按钮展开子菜单 */}
|
||||
<div className={cn(
|
||||
"flex items-center rounded-xl transition-all duration-200",
|
||||
isExactActive
|
||||
? "bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-md"
|
||||
: isActive || childActive
|
||||
? "bg-blue-50 text-blue-600"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
: "text-gray-600 hover:bg-gray-100",
|
||||
)}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="flex-1 flex items-center gap-3 px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
{isOpen || childActive ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-3 rounded-r-xl transition-colors",
|
||||
isExactActive
|
||||
? "hover:bg-white/10"
|
||||
: "hover:bg-gray-200"
|
||||
)}
|
||||
aria-label={isOpen ? "收起子菜单" : "展开子菜单"}
|
||||
>
|
||||
{isOpen || childActive ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{(isOpen || childActive) && (
|
||||
<ul className="mt-1 ml-4 space-y-1 border-l-2 border-gray-100 pl-4">
|
||||
{item.children?.map((child) => {
|
||||
@@ -183,7 +198,7 @@ export default function Sidebar() {
|
||||
<aside className="hidden md:flex flex-col w-64 shrink-0 border-r bg-white/80 backdrop-blur-md h-screen sticky top-0">
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-1">神射手</h2>
|
||||
<p className="text-sm text-gray-600">私域银行数据中台</p>
|
||||
<p className="text-sm text-gray-600">用户资产数字化平台</p>
|
||||
</div>
|
||||
<nav className="flex-1 px-4 pb-4 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
|
||||
@@ -7,104 +7,54 @@ import {
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
PieChart as RechartsPieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts"
|
||||
import { BarChart as RechartsBarChart, Bar } from "recharts"
|
||||
|
||||
interface ChartProps {
|
||||
data: any
|
||||
height?: number
|
||||
}
|
||||
const lineData = [
|
||||
{ name: "周一", 新增微信号: 12 },
|
||||
{ name: "周二", 新增微信号: 19 },
|
||||
{ name: "周三", 新增微信号: 3 },
|
||||
{ name: "周四", 新增微信号: 5 },
|
||||
{ name: "周五", 新增微信号: 2 },
|
||||
{ name: "周六", 新增微信号: 3 },
|
||||
{ name: "周日", 新增微信号: 10 },
|
||||
]
|
||||
|
||||
export function LineChart({ data, height = 300 }: ChartProps) {
|
||||
const barData = [
|
||||
{ name: "周一", 新增好友: 120 },
|
||||
{ name: "周二", 新增好友: 190 },
|
||||
{ name: "周三", 新增好友: 30 },
|
||||
{ name: "周四", 新增好友: 50 },
|
||||
{ name: "周五", 新增好友: 20 },
|
||||
{ name: "周六", 新增好友: 30 },
|
||||
{ name: "周日", 新增好友: 100 },
|
||||
]
|
||||
|
||||
export function LineChart() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsLineChart
|
||||
data={data.labels.map((label, i) => {
|
||||
const dataPoint = { name: label }
|
||||
data.datasets.forEach((dataset, j) => {
|
||||
dataPoint[dataset.label] = dataset.data[i]
|
||||
})
|
||||
return dataPoint
|
||||
})}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<RechartsLineChart data={lineData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{data.datasets.map((dataset, i) => (
|
||||
<Line
|
||||
key={i}
|
||||
type="monotone"
|
||||
dataKey={dataset.label}
|
||||
stroke={dataset.borderColor}
|
||||
fill={dataset.backgroundColor}
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
))}
|
||||
<Line type="monotone" dataKey="新增微信号" stroke="#8884d8" />
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarChart({ data, height = 300 }: ChartProps) {
|
||||
export function BarChart() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsBarChart
|
||||
data={data.labels.map((label, i) => {
|
||||
const dataPoint = { name: label }
|
||||
data.datasets.forEach((dataset, j) => {
|
||||
dataPoint[dataset.label] = dataset.data[i]
|
||||
})
|
||||
return dataPoint
|
||||
})}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<RechartsBarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{data.datasets.map((dataset, i) => (
|
||||
<Bar key={i} dataKey={dataset.label} fill={dataset.backgroundColor || "#8884d8"} />
|
||||
))}
|
||||
<Bar dataKey="新增好友" fill="#82ca9d" />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function PieChart({ data, height = 300 }: ChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data.labels.map((label, i) => ({
|
||||
name: label,
|
||||
value: data.datasets[0].data[i],
|
||||
}))}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{data.labels.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={data.datasets[0].backgroundColor[index] || `#${Math.floor(Math.random() * 16777215).toString(16)}`}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
552
app/data-ingestion/ai-engine/page.tsx
Normal file
552
app/data-ingestion/ai-engine/page.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Brain,
|
||||
Zap,
|
||||
Database,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Play,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Target,
|
||||
Tags,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
} from "lucide-react"
|
||||
|
||||
// AI标签引擎任务接口
|
||||
interface EngineTask {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
sourceDb: string
|
||||
sourceCollection: string
|
||||
status: 'idle' | 'analyzing' | 'tagging' | 'completed' | 'error'
|
||||
progress: number
|
||||
steps: {
|
||||
name: string
|
||||
status: 'pending' | 'running' | 'completed' | 'error'
|
||||
result?: string
|
||||
}[]
|
||||
stats: {
|
||||
totalRecords: number
|
||||
analyzedRecords: number
|
||||
taggedRecords: number
|
||||
newTags: number
|
||||
}
|
||||
}
|
||||
|
||||
// 字段映射规则
|
||||
interface FieldMapping {
|
||||
sourceField: string
|
||||
targetField: string
|
||||
transformRule: string
|
||||
aiEnhance: boolean
|
||||
}
|
||||
|
||||
// 动态生成引擎任务(从API加载)
|
||||
const generateDefaultTasks = (sources: any[]): EngineTask[] => {
|
||||
return sources.slice(0, 5).map((src, i) => ({
|
||||
id: `engine_${i + 1}`,
|
||||
name: `${src.name || src.id}数据拆解`,
|
||||
description: `解析${src.database || src.name}数据,提取用户标签`,
|
||||
sourceDb: src.database || src.name,
|
||||
sourceCollection: src.collection || '用户资产统一视图',
|
||||
status: i === 0 ? 'completed' as const : 'idle' as const,
|
||||
progress: i === 0 ? 100 : 0,
|
||||
steps: [
|
||||
{ name: '数据源连接', status: i === 0 ? 'completed' as const : 'pending' as const, result: i === 0 ? `${(src.recordCount / 1000000).toFixed(0)}M条记录` : undefined },
|
||||
{ name: '字段分析', status: i === 0 ? 'completed' as const : 'pending' as const, result: i === 0 ? '识别字段完成' : undefined },
|
||||
{ name: 'AI拆解', status: i === 0 ? 'completed' as const : 'pending' as const, result: i === 0 ? '提取关键字段' : undefined },
|
||||
{ name: '标签生成', status: i === 0 ? 'completed' as const : 'pending' as const, result: i === 0 ? '生成用户标签' : undefined },
|
||||
{ name: '数据写入', status: i === 0 ? 'completed' as const : 'pending' as const, result: i === 0 ? '写入用户估值表' : undefined },
|
||||
],
|
||||
stats: {
|
||||
totalRecords: src.recordCount || 0,
|
||||
analyzedRecords: i === 0 ? src.recordCount || 0 : 0,
|
||||
taggedRecords: i === 0 ? Math.floor((src.recordCount || 0) * 0.95) : 0,
|
||||
newTags: i === 0 ? 5 : 0,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 默认任务(京东示例)
|
||||
const FALLBACK_TASKS: EngineTask[] = [
|
||||
{
|
||||
id: 'engine_1',
|
||||
name: '腾讯QQ数据拆解',
|
||||
description: '解析QQ库数据,提取手机号关联并生成用户标签',
|
||||
sourceDb: 'KR_腾讯',
|
||||
sourceCollection: 'QQ+手机',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
steps: [
|
||||
{ name: '数据源连接', status: 'completed', result: '705M条记录' },
|
||||
{ name: '字段分析', status: 'completed', result: '识别12个字段' },
|
||||
{ name: 'AI拆解', status: 'completed', result: '提取手机号、QQ号、地区' },
|
||||
{ name: '标签生成', status: 'completed', result: '生成3类标签' },
|
||||
{ name: '数据写入', status: 'completed', result: '写入用户估值表' },
|
||||
],
|
||||
stats: {
|
||||
totalRecords: 705000000,
|
||||
analyzedRecords: 705000000,
|
||||
taggedRecords: 680000000,
|
||||
newTags: 3,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'engine_2',
|
||||
name: '京东用户数据拆解',
|
||||
description: '解析京东电商数据,提取消费行为生成RFM标签',
|
||||
sourceDb: 'KR_京东',
|
||||
sourceCollection: 'users',
|
||||
status: 'analyzing',
|
||||
progress: 45,
|
||||
steps: [
|
||||
{ name: '数据源连接', status: 'completed', result: '142M条记录' },
|
||||
{ name: '字段分析', status: 'completed', result: '识别28个字段' },
|
||||
{ name: 'AI拆解', status: 'running', result: '分析消费行为...' },
|
||||
{ name: '标签生成', status: 'pending' },
|
||||
{ name: '数据写入', status: 'pending' },
|
||||
],
|
||||
stats: {
|
||||
totalRecords: 142000000,
|
||||
analyzedRecords: 63900000,
|
||||
taggedRecords: 0,
|
||||
newTags: 0,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'engine_3',
|
||||
name: '存客宝CRM数据拆解',
|
||||
description: '解析存客宝私域数据,提取客户画像',
|
||||
sourceDb: 'cunkebao_v3',
|
||||
sourceCollection: 'users',
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
steps: [
|
||||
{ name: '数据源连接', status: 'pending' },
|
||||
{ name: '字段分析', status: 'pending' },
|
||||
{ name: 'AI拆解', status: 'pending' },
|
||||
{ name: '标签生成', status: 'pending' },
|
||||
{ name: '数据写入', status: 'pending' },
|
||||
],
|
||||
stats: {
|
||||
totalRecords: 216000,
|
||||
analyzedRecords: 0,
|
||||
taggedRecords: 0,
|
||||
newTags: 0,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
// 标准字段映射规则
|
||||
const STANDARD_FIELD_MAPPINGS: FieldMapping[] = [
|
||||
{ sourceField: 'phone', targetField: 'phone', transformRule: '格式化为11位', aiEnhance: true },
|
||||
{ sourceField: 'qq', targetField: 'qq', transformRule: '提取纯数字', aiEnhance: false },
|
||||
{ sourceField: 'uid', targetField: 'weibo_uid', transformRule: '微博UID映射', aiEnhance: false },
|
||||
{ sourceField: 'name', targetField: 'name', transformRule: '姓名脱敏', aiEnhance: true },
|
||||
{ sourceField: 'province', targetField: 'province', transformRule: '省份标准化', aiEnhance: true },
|
||||
{ sourceField: 'city', targetField: 'city', transformRule: '城市标准化', aiEnhance: true },
|
||||
{ sourceField: 'gender', targetField: 'gender', transformRule: '性别标准化', aiEnhance: false },
|
||||
{ sourceField: 'amount', targetField: 'total_spend', transformRule: '金额汇总', aiEnhance: false },
|
||||
{ sourceField: 'last_active', targetField: 'recency', transformRule: 'R值计算', aiEnhance: true },
|
||||
{ sourceField: 'buy_count', targetField: 'frequency', transformRule: 'F值计算', aiEnhance: true },
|
||||
{ sourceField: 'total_amount', targetField: 'monetary', transformRule: 'M值计算', aiEnhance: true },
|
||||
]
|
||||
|
||||
export default function AIEnginePage() {
|
||||
const [tasks, setTasks] = useState<EngineTask[]>(FALLBACK_TASKS)
|
||||
const [selectedTask, setSelectedTask] = useState<EngineTask | null>(null)
|
||||
const [showConfigDialog, setShowConfigDialog] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState("tasks")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 从API加载真实数据源
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch('/api/data-sources')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.sources) {
|
||||
// 只取MongoDB数据源
|
||||
const mongoSources = data.sources.filter((s: any) => s.type === 'mongodb' && s.recordCount > 0)
|
||||
if (mongoSources.length > 0) {
|
||||
setTasks(generateDefaultTasks(mongoSources))
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
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 getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-100 text-green-700"><CheckCircle2 className="h-3 w-3 mr-1" />已完成</Badge>
|
||||
case 'analyzing':
|
||||
case 'tagging':
|
||||
return <Badge className="bg-blue-100 text-blue-700"><Loader2 className="h-3 w-3 mr-1 animate-spin" />处理中</Badge>
|
||||
case 'error':
|
||||
return <Badge className="bg-red-100 text-red-700"><AlertCircle className="h-3 w-3 mr-1" />错误</Badge>
|
||||
default:
|
||||
return <Badge className="bg-gray-100 text-gray-700">待执行</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getStepIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
||||
case 'error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />
|
||||
default:
|
||||
return <div className="h-4 w-4 rounded-full border-2 border-gray-300" />
|
||||
}
|
||||
}
|
||||
|
||||
const runTask = (taskId: string) => {
|
||||
setTasks(tasks.map(t => t.id === taskId ? {
|
||||
...t,
|
||||
status: 'analyzing' as const,
|
||||
steps: t.steps.map((s, i) => i === 0 ? { ...s, status: 'running' as const } : s)
|
||||
} : t))
|
||||
}
|
||||
|
||||
// 模拟进度更新
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTasks(prev => prev.map(t => {
|
||||
if (t.status === 'analyzing' || t.status === 'tagging') {
|
||||
const newProgress = Math.min(t.progress + Math.random() * 5, 100)
|
||||
const currentStepIndex = t.steps.findIndex(s => s.status === 'running')
|
||||
|
||||
let newSteps = [...t.steps]
|
||||
if (newProgress > (currentStepIndex + 1) * 20 && currentStepIndex < t.steps.length - 1) {
|
||||
newSteps[currentStepIndex] = { ...newSteps[currentStepIndex], status: 'completed' }
|
||||
newSteps[currentStepIndex + 1] = { ...newSteps[currentStepIndex + 1], status: 'running' }
|
||||
}
|
||||
|
||||
return {
|
||||
...t,
|
||||
progress: newProgress,
|
||||
status: newProgress >= 100 ? 'completed' as const : t.status,
|
||||
steps: newProgress >= 100 ? t.steps.map(s => ({ ...s, status: 'completed' as const })) : newSteps,
|
||||
stats: {
|
||||
...t.stats,
|
||||
analyzedRecords: Math.floor(t.stats.totalRecords * newProgress / 100),
|
||||
taggedRecords: newProgress >= 80 ? Math.floor(t.stats.totalRecords * (newProgress - 20) / 100) : 0,
|
||||
newTags: newProgress >= 60 ? 5 : 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}))
|
||||
}, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI标签引擎</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">数据接入后自动拆解字段,按用户资产标准生成标签</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setShowConfigDialog(true)}>
|
||||
<Brain className="h-4 w-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 引擎说明卡片 */}
|
||||
<Card className="border-0 shadow-sm bg-gradient-to-r from-purple-50 to-blue-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-purple-500 to-blue-500">
|
||||
<Brain className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">AI标签引擎工作流程</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Badge variant="outline">1. 数据源连接</Badge>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<Badge variant="outline">2. 字段分析</Badge>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<Badge variant="outline">3. AI拆解</Badge>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<Badge variant="outline">4. 标签生成</Badge>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<Badge variant="outline">5. 写入中台</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
引擎自动识别数据源字段,按照用户资产数字化标准进行拆解,生成RFM评分和用户标签,最终写入数据中台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-white/80">
|
||||
<TabsTrigger value="tasks">拆解任务</TabsTrigger>
|
||||
<TabsTrigger value="rules">映射规则</TabsTrigger>
|
||||
<TabsTrigger value="logs">执行日志</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 拆解任务列表 */}
|
||||
<TabsContent value="tasks" className="mt-4 space-y-3">
|
||||
{tasks.map(task => (
|
||||
<Card key={task.id} className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-purple-100">
|
||||
<Brain className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{task.name}</h3>
|
||||
{getStatusBadge(task.status)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{task.description}</p>
|
||||
<div className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{task.sourceDb}.{task.sourceCollection}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.status === 'idle' && (
|
||||
<Button size="sm" onClick={() => runTask(task.id)}>
|
||||
<Play className="h-4 w-4 mr-1" /> 执行
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelectedTask(task)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">处理进度</span>
|
||||
<span className="font-medium">{task.progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={task.progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 步骤流程 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{task.steps.map((step, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
{getStepIcon(step.status)}
|
||||
<span className="text-xs text-gray-500 mt-1">{step.name}</span>
|
||||
{step.result && (
|
||||
<span className="text-xs text-green-600">{step.result}</span>
|
||||
)}
|
||||
</div>
|
||||
{i < task.steps.length - 1 && (
|
||||
<div className={`w-16 h-0.5 mx-2 ${step.status === 'completed' ? 'bg-green-300' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="grid grid-cols-4 gap-4 text-sm pt-3 border-t">
|
||||
<div>
|
||||
<span className="text-gray-500">总记录:</span>
|
||||
<span className="ml-2 font-medium">{formatNumber(task.stats.totalRecords)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">已分析:</span>
|
||||
<span className="ml-2 font-medium text-blue-600">{formatNumber(task.stats.analyzedRecords)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">已打标:</span>
|
||||
<span className="ml-2 font-medium text-green-600">{formatNumber(task.stats.taggedRecords)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">新标签:</span>
|
||||
<span className="ml-2 font-medium text-purple-600">{task.stats.newTags}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* 字段映射规则 */}
|
||||
<TabsContent value="rules" className="mt-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">标准字段映射规则</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{STANDARD_FIELD_MAPPINGS.map((mapping, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-gray-50 hover:bg-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="font-mono">{mapping.sourceField}</Badge>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<Badge className="bg-purple-100 text-purple-700 font-mono">{mapping.targetField}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{mapping.transformRule}</span>
|
||||
{mapping.aiEnhance && (
|
||||
<Badge className="bg-blue-100 text-blue-700">
|
||||
<Sparkles className="h-3 w-3 mr-1" />AI增强
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 执行日志 */}
|
||||
<TabsContent value="logs" className="mt-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">执行日志</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 font-mono text-xs">
|
||||
<div className="p-2 rounded bg-gray-900 text-green-400">
|
||||
[2024-01-30 14:32:15] INFO: AI标签引擎启动
|
||||
</div>
|
||||
<div className="p-2 rounded bg-gray-900 text-green-400">
|
||||
[2024-01-30 14:32:16] INFO: 连接数据源 KR_腾讯.qq_phone
|
||||
</div>
|
||||
<div className="p-2 rounded bg-gray-900 text-green-400">
|
||||
[2024-01-30 14:32:18] INFO: 发现 705,000,000 条记录
|
||||
</div>
|
||||
<div className="p-2 rounded bg-gray-900 text-yellow-400">
|
||||
[2024-01-30 14:32:20] WARN: 字段 'phone' 存在格式不统一,启用AI格式化
|
||||
</div>
|
||||
<div className="p-2 rounded bg-gray-900 text-green-400">
|
||||
[2024-01-30 14:35:45] INFO: AI拆解完成,提取字段: phone, qq, province
|
||||
</div>
|
||||
<div className="p-2 rounded bg-gray-900 text-green-400">
|
||||
[2024-01-30 14:40:12] INFO: 生成标签: 社交活跃度, 地域分布, QQ等级
|
||||
</div>
|
||||
<div className="p-2 rounded bg-gray-900 text-green-400">
|
||||
[2024-01-30 14:45:30] INFO: 数据写入 KR.用户估值 完成
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 新建任务弹窗 */}
|
||||
<Dialog open={showConfigDialog} onOpenChange={setShowConfigDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建AI拆解任务</DialogTitle>
|
||||
<DialogDescription>配置数据源和拆解规则</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据源</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择数据库" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kr_tencent">KR_腾讯</SelectItem>
|
||||
<SelectItem value="kr_weibo">KR_微博</SelectItem>
|
||||
<SelectItem value="kr_jd">KR_京东</SelectItem>
|
||||
<SelectItem value="ckb">cunkebao_v3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目标表</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择目标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="valuation">KR.用户估值</SelectItem>
|
||||
<SelectItem value="ckb_view">KR_存客宝.用户资产统一视图</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>AI模型</Label>
|
||||
<Select defaultValue="qwen">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qwen">Qwen2.5 (本地)</SelectItem>
|
||||
<SelectItem value="deepseek">DeepSeek-V3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>拆解规则说明</Label>
|
||||
<Textarea placeholder="描述需要拆解的字段和转换规则..." rows={3} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowConfigDialog(false)}>取消</Button>
|
||||
<Button onClick={() => setShowConfigDialog(false)}>创建任务</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
510
app/data-ingestion/cleaning/page.tsx
Normal file
510
app/data-ingestion/cleaning/page.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "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 { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Zap,
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
Loader2,
|
||||
Filter,
|
||||
Shield,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Database,
|
||||
} from "lucide-react"
|
||||
|
||||
// 清洗规则接口
|
||||
interface CleaningRule {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: 'format' | 'dedup' | 'validate' | 'enrich' | 'mask'
|
||||
targetField: string
|
||||
config: {
|
||||
pattern?: string
|
||||
replacement?: string
|
||||
validation?: string
|
||||
enrichSource?: string
|
||||
maskType?: string
|
||||
}
|
||||
priority: number
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
lastRunAt?: string
|
||||
processedCount?: number
|
||||
}
|
||||
|
||||
// 规则类型配置
|
||||
const RULE_TYPES = {
|
||||
format: { label: '格式化', color: 'bg-blue-100 text-blue-700', icon: FileText, desc: '统一数据格式' },
|
||||
dedup: { label: '去重', color: 'bg-green-100 text-green-700', icon: Copy, desc: '删除重复数据' },
|
||||
validate: { label: '验证', color: 'bg-yellow-100 text-yellow-700', icon: Shield, desc: '校验数据有效性' },
|
||||
enrich: { label: '补全', color: 'bg-purple-100 text-purple-700', icon: Sparkles, desc: '补充缺失字段' },
|
||||
mask: { label: '脱敏', color: 'bg-red-100 text-red-700', icon: Eye, desc: '敏感数据脱敏' },
|
||||
}
|
||||
|
||||
export default function CleaningRulesPage() {
|
||||
const [rules, setRules] = useState<CleaningRule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [executing, setExecuting] = useState<string | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [stats, setStats] = useState({
|
||||
totalRules: 0,
|
||||
enabledRules: 0,
|
||||
totalProcessed: 0
|
||||
})
|
||||
|
||||
// 新规则表单
|
||||
const [newRule, setNewRule] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'format' as const,
|
||||
targetField: 'phone',
|
||||
pattern: '',
|
||||
replacement: '',
|
||||
validation: ''
|
||||
})
|
||||
|
||||
// 加载规则
|
||||
useEffect(() => {
|
||||
loadRules()
|
||||
}, [])
|
||||
|
||||
const loadRules = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [rulesRes, statsRes] = await Promise.all([
|
||||
fetch('/api/cleaning-rules'),
|
||||
fetch('/api/cleaning-rules?action=stats')
|
||||
])
|
||||
const rulesData = await rulesRes.json()
|
||||
const statsData = await statsRes.json()
|
||||
|
||||
if (rulesData.success) setRules(rulesData.rules)
|
||||
if (statsData.success) setStats(statsData.stats)
|
||||
} catch (error) {
|
||||
console.error('加载清洗规则失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行单个规则
|
||||
const executeRule = async (ruleId: string) => {
|
||||
setExecuting(ruleId)
|
||||
try {
|
||||
const res = await fetch('/api/cleaning-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'execute', ruleId, limit: 1000 })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert(`执行完成: 处理 ${data.result.processed} 条,清洗 ${data.result.cleaned} 条`)
|
||||
loadRules()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('执行失败:', error)
|
||||
} finally {
|
||||
setExecuting(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行所有规则
|
||||
const executeAllRules = async () => {
|
||||
setExecuting('all')
|
||||
try {
|
||||
const res = await fetch('/api/cleaning-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'execute_all', limit: 100 })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert(`批量执行完成: 执行了 ${data.executed} 条规则`)
|
||||
loadRules()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量执行失败:', error)
|
||||
} finally {
|
||||
setExecuting(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换规则状态
|
||||
const toggleRule = async (ruleId: string) => {
|
||||
try {
|
||||
await fetch('/api/cleaning-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'toggle', ruleId })
|
||||
})
|
||||
setRules(rules.map(r => r.id === ruleId ? { ...r, enabled: !r.enabled } : r))
|
||||
} catch (error) {
|
||||
console.error('切换失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加规则
|
||||
const handleAddRule = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/cleaning-rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'create',
|
||||
rule: {
|
||||
name: newRule.name,
|
||||
description: newRule.description,
|
||||
type: newRule.type,
|
||||
targetField: newRule.targetField,
|
||||
config: {
|
||||
pattern: newRule.pattern,
|
||||
replacement: newRule.replacement,
|
||||
validation: newRule.validation
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setShowAddDialog(false)
|
||||
loadRules()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
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 filteredRules = rules.filter(rule => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return rule.name.toLowerCase().includes(query) ||
|
||||
rule.description.toLowerCase().includes(query) ||
|
||||
rule.targetField.toLowerCase().includes(query)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">清洗规则</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
配置数据清洗规则,实现数据格式化、去重、验证、补全和脱敏
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={executeAllRules}
|
||||
disabled={executing === 'all'}
|
||||
>
|
||||
{executing === 'all' ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
执行所有规则
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">规则总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalRules}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<Filter className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">已启用</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.enabledRules}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">已处理数据</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatNumber(stats.totalProcessed)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<Database className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">规则类型</p>
|
||||
<p className="text-2xl font-bold text-gray-900">5种</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-100 flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 搜索 */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索规则名称、描述..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 规则列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<span className="ml-3 text-gray-500">加载清洗规则...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRules.map((rule, index) => {
|
||||
const typeConfig = RULE_TYPES[rule.type]
|
||||
const TypeIcon = typeConfig.icon
|
||||
|
||||
return (
|
||||
<Card key={rule.id} className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-bold text-gray-300 w-6">{index + 1}</span>
|
||||
<div className={`p-2.5 rounded-xl ${typeConfig.color.replace('text-', 'bg-').replace('-700', '-100')}`}>
|
||||
<TypeIcon className={`h-5 w-5 ${typeConfig.color.split(' ')[1]}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{rule.name}</h3>
|
||||
<Badge className={typeConfig.color}>{typeConfig.label}</Badge>
|
||||
{!rule.enabled && (
|
||||
<Badge variant="outline" className="text-gray-500">已禁用</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">{rule.description}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="h-3 w-3" />
|
||||
目标字段: <span className="font-mono text-purple-600">{rule.targetField}</span>
|
||||
</span>
|
||||
{rule.lastRunAt && (
|
||||
<span>上次执行: {rule.lastRunAt}</span>
|
||||
)}
|
||||
{rule.processedCount !== undefined && (
|
||||
<span>已处理: {formatNumber(rule.processedCount)} 条</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={() => toggleRule(rule.id)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => executeRule(rule.id)}
|
||||
disabled={executing === rule.id || !rule.enabled}
|
||||
>
|
||||
{executing === rule.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加规则弹窗 */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加清洗规则</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建新的数据清洗规则,应用于数据接入过程
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>规则名称 *</Label>
|
||||
<Input
|
||||
placeholder="例如:手机号格式化"
|
||||
value={newRule.name}
|
||||
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>规则类型 *</Label>
|
||||
<Select
|
||||
value={newRule.type}
|
||||
onValueChange={(v: any) => setNewRule({ ...newRule, type: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(RULE_TYPES).map(([key, config]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<config.icon className="h-4 w-4" />
|
||||
{config.label} - {config.desc}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>规则描述</Label>
|
||||
<Textarea
|
||||
placeholder="描述该规则的作用..."
|
||||
value={newRule.description}
|
||||
onChange={(e) => setNewRule({ ...newRule, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>目标字段 *</Label>
|
||||
<Select
|
||||
value={newRule.targetField}
|
||||
onValueChange={(v) => setNewRule({ ...newRule, targetField: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="phone">phone (手机号)</SelectItem>
|
||||
<SelectItem value="email">email (邮箱)</SelectItem>
|
||||
<SelectItem value="name">name (姓名)</SelectItem>
|
||||
<SelectItem value="idcard">idcard (身份证)</SelectItem>
|
||||
<SelectItem value="address">address (地址)</SelectItem>
|
||||
<SelectItem value="province">province (省份)</SelectItem>
|
||||
<SelectItem value="city">city (城市)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>匹配模式(正则)</Label>
|
||||
<Input
|
||||
placeholder="例如:^1[3-9]\d{9}$"
|
||||
value={newRule.pattern}
|
||||
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newRule.type === 'format' && (
|
||||
<div className="space-y-2">
|
||||
<Label>替换为</Label>
|
||||
<Input
|
||||
placeholder="替换后的格式"
|
||||
value={newRule.replacement}
|
||||
onChange={(e) => setNewRule({ ...newRule, replacement: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddRule}>
|
||||
创建规则
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,269 +1,444 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useRef, useEffect, useCallback } from "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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
GitBranch,
|
||||
Search,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Eye,
|
||||
MousePointer2,
|
||||
Move,
|
||||
Link2,
|
||||
Unlink,
|
||||
CheckCircle2,
|
||||
Settings,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Database,
|
||||
Table,
|
||||
ArrowRight,
|
||||
Filter,
|
||||
Download,
|
||||
} from "lucide-react"
|
||||
|
||||
// 第二部分:数据接入 - 数据血缘
|
||||
export default function DataLineagePage() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||
// 节点类型
|
||||
interface LineageNode {
|
||||
id: string
|
||||
type: 'source' | 'transform' | 'target'
|
||||
name: string
|
||||
database?: string
|
||||
collection?: string
|
||||
fields: string[]
|
||||
x: number
|
||||
y: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// 数据血缘节点
|
||||
const lineageNodes = [
|
||||
// 数据源层
|
||||
{ id: "source-1", type: "source", name: "存客宝-MySQL", level: 0, x: 50, y: 100 },
|
||||
{ id: "source-2", type: "source", name: "触客宝-MySQL", level: 0, x: 50, y: 200 },
|
||||
{ id: "source-3", type: "source", name: "数智员工-API", level: 0, x: 50, y: 300 },
|
||||
// 原始表层
|
||||
{ id: "raw-1", type: "table", name: "raw_users", level: 1, x: 250, y: 100 },
|
||||
{ id: "raw-2", type: "table", name: "raw_transactions", level: 1, x: 250, y: 200 },
|
||||
{ id: "raw-3", type: "table", name: "raw_behaviors", level: 1, x: 250, y: 300 },
|
||||
// 清洗层
|
||||
{ id: "clean-1", type: "table", name: "clean_users", level: 2, x: 450, y: 150 },
|
||||
{ id: "clean-2", type: "table", name: "clean_transactions", level: 2, x: 450, y: 250 },
|
||||
// 标签层
|
||||
{ id: "tag-1", type: "table", name: "user_tags", level: 3, x: 650, y: 150 },
|
||||
{ id: "tag-2", type: "table", name: "user_portraits", level: 3, x: 650, y: 250 },
|
||||
// 输出层
|
||||
{ id: "output-1", type: "output", name: "流量包-高价值用户", level: 4, x: 850, y: 150 },
|
||||
{ id: "output-2", type: "output", name: "API-用户画像", level: 4, x: 850, y: 250 },
|
||||
]
|
||||
// 连接类型
|
||||
interface LineageConnection {
|
||||
id: string
|
||||
sourceNode: string
|
||||
sourceField: string
|
||||
targetNode: string
|
||||
targetField: string
|
||||
}
|
||||
|
||||
// 血缘关系
|
||||
const lineageEdges = [
|
||||
{ from: "source-1", to: "raw-1" },
|
||||
{ from: "source-1", to: "raw-2" },
|
||||
{ from: "source-2", to: "raw-3" },
|
||||
{ from: "source-3", to: "raw-1" },
|
||||
{ from: "raw-1", to: "clean-1" },
|
||||
{ from: "raw-2", to: "clean-2" },
|
||||
{ from: "raw-3", to: "clean-1" },
|
||||
{ from: "clean-1", to: "tag-1" },
|
||||
{ from: "clean-2", to: "tag-1" },
|
||||
{ from: "clean-1", to: "tag-2" },
|
||||
{ from: "tag-1", to: "output-1" },
|
||||
{ from: "tag-2", to: "output-2" },
|
||||
]
|
||||
// 节点和连接从API动态加载
|
||||
|
||||
const getNodeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "source":
|
||||
return "bg-blue-100 border-blue-300 text-blue-700"
|
||||
case "table":
|
||||
return "bg-green-100 border-green-300 text-green-700"
|
||||
case "output":
|
||||
return "bg-purple-100 border-purple-300 text-purple-700"
|
||||
default:
|
||||
return "bg-gray-100 border-gray-300 text-gray-700"
|
||||
export default function LineagePage() {
|
||||
const [nodes, setNodes] = useState<LineageNode[]>([])
|
||||
const [connections, setConnections] = useState<LineageConnection[]>([])
|
||||
const [selectedNode, setSelectedNode] = useState<LineageNode | null>(null)
|
||||
const [draggingNode, setDraggingNode] = useState<string | null>(null)
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||||
const [connectingFrom, setConnectingFrom] = useState<{ nodeId: string; field: string } | null>(null)
|
||||
const [showNodeDetail, setShowNodeDetail] = useState(false)
|
||||
const [zoom, setZoom] = useState(0.8)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 加载真实数据库结构
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch('/api/database-structure?action=lineage')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
setNodes(data.nodes || [])
|
||||
setConnections(data.connections || [])
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// 节点拖动开始
|
||||
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.stopPropagation()
|
||||
const node = nodes.find(n => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
setDraggingNode(nodeId)
|
||||
setDragOffset({
|
||||
x: e.clientX - node.x * zoom,
|
||||
y: e.clientY - node.y * zoom,
|
||||
})
|
||||
}
|
||||
|
||||
// 节点拖动
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!draggingNode || !canvasRef.current) return
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const newX = (e.clientX - rect.left - dragOffset.x + rect.left) / zoom
|
||||
const newY = (e.clientY - rect.top - dragOffset.y + rect.top) / zoom
|
||||
|
||||
setNodes(prev => prev.map(node =>
|
||||
node.id === draggingNode
|
||||
? { ...node, x: Math.max(0, newX), y: Math.max(0, newY) }
|
||||
: node
|
||||
))
|
||||
}, [draggingNode, dragOffset, zoom])
|
||||
|
||||
// 节点拖动结束
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setDraggingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (draggingNode) {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [draggingNode, handleMouseMove, handleMouseUp])
|
||||
|
||||
// 开始连接字段
|
||||
const startConnecting = (nodeId: string, field: string) => {
|
||||
setConnectingFrom({ nodeId, field })
|
||||
}
|
||||
|
||||
// 完成连接
|
||||
const finishConnecting = (nodeId: string, field: string) => {
|
||||
if (!connectingFrom || connectingFrom.nodeId === nodeId) {
|
||||
setConnectingFrom(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新连接
|
||||
const newConnection: LineageConnection = {
|
||||
id: `conn_${Date.now()}`,
|
||||
sourceNode: connectingFrom.nodeId,
|
||||
sourceField: connectingFrom.field,
|
||||
targetNode: nodeId,
|
||||
targetField: field,
|
||||
}
|
||||
|
||||
setConnections(prev => [...prev, newConnection])
|
||||
setConnectingFrom(null)
|
||||
}
|
||||
|
||||
// 删除连接
|
||||
const deleteConnection = (connId: string) => {
|
||||
setConnections(prev => prev.filter(c => c.id !== connId))
|
||||
}
|
||||
|
||||
// 获取节点位置
|
||||
const getNodeFieldPosition = (nodeId: string, field: string, isSource: boolean) => {
|
||||
const node = nodes.find(n => n.id === nodeId)
|
||||
if (!node) return { x: 0, y: 0 }
|
||||
|
||||
const fieldIndex = node.fields.indexOf(field)
|
||||
const nodeWidth = 200
|
||||
const headerHeight = 40
|
||||
const fieldHeight = 28
|
||||
|
||||
return {
|
||||
x: isSource ? node.x + nodeWidth : node.x,
|
||||
y: node.y + headerHeight + fieldIndex * fieldHeight + fieldHeight / 2,
|
||||
}
|
||||
}
|
||||
|
||||
const getNodeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "source":
|
||||
return <Database className="h-4 w-4" />
|
||||
case "table":
|
||||
return <Table className="h-4 w-4" />
|
||||
case "output":
|
||||
return <ArrowRight className="h-4 w-4" />
|
||||
default:
|
||||
return <GitBranch className="h-4 w-4" />
|
||||
}
|
||||
// 渲染连接线
|
||||
const renderConnections = () => {
|
||||
return connections.map(conn => {
|
||||
const source = getNodeFieldPosition(conn.sourceNode, conn.sourceField, true)
|
||||
const target = getNodeFieldPosition(conn.targetNode, conn.targetField, false)
|
||||
|
||||
// 贝塞尔曲线
|
||||
const midX = (source.x + target.x) / 2
|
||||
const path = `M ${source.x} ${source.y} C ${midX} ${source.y}, ${midX} ${target.y}, ${target.x} ${target.y}`
|
||||
|
||||
return (
|
||||
<g key={conn.id} className="group cursor-pointer" onClick={() => deleteConnection(conn.id)}>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="2"
|
||||
className="group-hover:stroke-red-500 transition-colors"
|
||||
/>
|
||||
<circle cx={source.x} cy={source.y} r="4" fill="#a78bfa" className="group-hover:fill-red-500" />
|
||||
<circle cx={target.x} cy={target.y} r="4" fill="#a78bfa" className="group-hover:fill-red-500" />
|
||||
</g>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染节点
|
||||
const renderNode = (node: LineageNode) => {
|
||||
const isConnecting = connectingFrom !== null
|
||||
const isSource = connectingFrom?.nodeId === node.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`absolute bg-white rounded-xl shadow-lg border-2 w-[200px] transition-shadow ${
|
||||
draggingNode === node.id ? 'shadow-2xl ring-2 ring-purple-500 cursor-grabbing' : 'cursor-grab'
|
||||
} ${selectedNode?.id === node.id ? 'ring-2 ring-blue-500' : ''}`}
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
borderColor: isSource ? '#ef4444' : '#e5e7eb',
|
||||
}}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||
onClick={() => {
|
||||
setSelectedNode(node)
|
||||
setShowNodeDetail(true)
|
||||
}}
|
||||
>
|
||||
{/* 节点头部 */}
|
||||
<div className={`px-3 py-2 rounded-t-lg bg-gradient-to-r ${node.color} flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{node.type === 'source' && <Database className="h-4 w-4 text-white" />}
|
||||
{node.type === 'transform' && <Zap className="h-4 w-4 text-white" />}
|
||||
{node.type === 'target' && <GitBranch className="h-4 w-4 text-white" />}
|
||||
<span className="text-white text-sm font-medium truncate">{node.name}</span>
|
||||
</div>
|
||||
<Move className="h-3 w-3 text-white/70" />
|
||||
</div>
|
||||
|
||||
{/* 字段列表 */}
|
||||
<div className="p-1">
|
||||
{node.fields.map((field, i) => (
|
||||
<div
|
||||
key={field}
|
||||
className={`px-2 py-1 text-xs rounded flex items-center justify-between hover:bg-gray-100 ${
|
||||
isConnecting && !isSource ? 'cursor-crosshair hover:bg-purple-100' : ''
|
||||
} ${connectingFrom?.field === field && isSource ? 'bg-red-100' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isConnecting && !isSource) {
|
||||
finishConnecting(node.id, field)
|
||||
} else if (!isConnecting) {
|
||||
startConnecting(node.id, field)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-gray-700">{field}</span>
|
||||
<div className={`w-3 h-3 rounded-full border-2 ${
|
||||
connectingFrom?.field === field && isSource
|
||||
? 'bg-red-500 border-red-600'
|
||||
: 'border-gray-300 hover:border-purple-500 hover:bg-purple-100'
|
||||
}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 数据库信息 */}
|
||||
{node.database && (
|
||||
<div className="px-2 py-1 text-xs text-gray-400 border-t">
|
||||
{node.database}.{node.collection}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据血缘</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">可视化展示数据从源头到输出的完整流转路径</p>
|
||||
<p className="text-sm text-gray-500 mt-1">可视化数据流向,拖拽节点,点击字段创建关联</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索表名或数据源..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 w-64 bg-white"
|
||||
/>
|
||||
{loading && (
|
||||
<Badge className="bg-yellow-100 text-yellow-700 animate-pulse">
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
加载真实数据库...
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 bg-white rounded-lg shadow-sm p-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setZoom(z => Math.max(0.5, z - 0.1))}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm px-2">{Math.round(zoom * 100)}%</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setZoom(z => Math.min(1.5, z + 0.1))}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setZoom(0.8)}>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图例 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-blue-100 border border-blue-300" />
|
||||
<span className="text-sm text-gray-600">数据源</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-green-100 border border-green-300" />
|
||||
<span className="text-sm text-gray-600">数据表</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-purple-100 border border-purple-300" />
|
||||
<span className="text-sm text-gray-600">输出服务</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 血缘图 */}
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">血缘关系图</CardTitle>
|
||||
{/* 操作提示 */}
|
||||
<Card className="border-0 shadow-sm bg-gradient-to-r from-purple-50 to-blue-50">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<MousePointer2 className="h-4 w-4 text-purple-500" />
|
||||
<span>拖拽节点移动位置</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative h-[500px] bg-gray-50 rounded-lg overflow-hidden">
|
||||
{/* SVG连线 */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none">
|
||||
{lineageEdges.map((edge, index) => {
|
||||
const fromNode = lineageNodes.find((n) => n.id === edge.from)
|
||||
const toNode = lineageNodes.find((n) => n.id === edge.to)
|
||||
if (!fromNode || !toNode) return null
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1={fromNode.x + 80}
|
||||
y1={fromNode.y + 20}
|
||||
x2={toNode.x}
|
||||
y2={toNode.y + 20}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth="2"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* 节点 */}
|
||||
{lineageNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`absolute px-3 py-2 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md ${getNodeColor(node.type)} ${
|
||||
selectedNode === node.id ? "ring-2 ring-blue-500 ring-offset-2" : ""
|
||||
}`}
|
||||
style={{ left: node.x, top: node.y }}
|
||||
onClick={() => setSelectedNode(node.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getNodeIcon(node.type)}
|
||||
<span className="text-sm font-medium whitespace-nowrap">{node.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 层级标签 */}
|
||||
{[
|
||||
{ label: "数据源", x: 50 },
|
||||
{ label: "原始层", x: 250 },
|
||||
{ label: "清洗层", x: 450 },
|
||||
{ label: "标签层", x: 650 },
|
||||
{ label: "输出层", x: 850 },
|
||||
].map((level, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute top-2 text-xs text-gray-400 font-medium"
|
||||
style={{ left: level.x }}
|
||||
>
|
||||
{level.label}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-blue-500" />
|
||||
<span>点击字段圆点创建连接</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Unlink className="h-4 w-4 text-red-500" />
|
||||
<span>点击连线删除关联</span>
|
||||
</div>
|
||||
{connectingFrom && (
|
||||
<Badge className="bg-red-100 text-red-700 animate-pulse">
|
||||
正在连接: {connectingFrom.field} → 点击目标字段完成
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 节点详情 */}
|
||||
{selectedNode && (
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
节点详情 - {lineageNodes.find((n) => n.id === selectedNode)?.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">上游依赖</h4>
|
||||
{/* 图例 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-r from-blue-400 to-blue-600" />
|
||||
<span className="text-sm text-gray-600">数据源</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-r from-purple-400 to-purple-600" />
|
||||
<span className="text-sm text-gray-600">转换层</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gradient-to-r from-emerald-400 to-emerald-600" />
|
||||
<span className="text-sm text-gray-600">目标表</span>
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-gray-500">
|
||||
节点: {nodes.length} | 连接: {connections.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画布 */}
|
||||
<Card className="border-0 shadow-lg bg-white/80 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative bg-[linear-gradient(#e5e7eb_1px,transparent_1px),linear-gradient(90deg,#e5e7eb_1px,transparent_1px)] bg-[size:20px_20px]"
|
||||
style={{
|
||||
height: '650px',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/zoom}%`,
|
||||
}}
|
||||
>
|
||||
{/* SVG连接线层 */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<g className="pointer-events-auto">
|
||||
{renderConnections()}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* 节点层 */}
|
||||
<div className="absolute inset-0" style={{ zIndex: 2 }}>
|
||||
{nodes.map(renderNode)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 节点详情弹窗 */}
|
||||
<Dialog open={showNodeDetail} onOpenChange={setShowNodeDetail}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedNode?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedNode?.type === 'source' && '数据源节点'}
|
||||
{selectedNode?.type === 'transform' && '转换处理节点'}
|
||||
{selectedNode?.type === 'target' && '目标输出节点'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedNode && (
|
||||
<div className="space-y-4 py-4">
|
||||
{selectedNode.database && (
|
||||
<div className="space-y-2">
|
||||
{lineageEdges
|
||||
.filter((e) => e.to === selectedNode)
|
||||
.map((edge, index) => (
|
||||
<Badge key={index} variant="secondary" className="mr-2">
|
||||
{lineageNodes.find((n) => n.id === edge.from)?.name}
|
||||
</Badge>
|
||||
))}
|
||||
{lineageEdges.filter((e) => e.to === selectedNode).length === 0 && (
|
||||
<span className="text-sm text-gray-400">无上游依赖</span>
|
||||
)}
|
||||
<Label>数据库</Label>
|
||||
<div className="p-2 rounded bg-gray-100 font-mono text-sm">
|
||||
{selectedNode.database}.{selectedNode.collection}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>字段列表 ({selectedNode.fields.length})</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedNode.fields.map(field => (
|
||||
<Badge key={field} variant="outline" className="font-mono">{field}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">下游影响</h4>
|
||||
<div className="space-y-2">
|
||||
{lineageEdges
|
||||
.filter((e) => e.from === selectedNode)
|
||||
.map((edge, index) => (
|
||||
<Badge key={index} variant="secondary" className="mr-2">
|
||||
{lineageNodes.find((n) => n.id === edge.to)?.name}
|
||||
</Badge>
|
||||
))}
|
||||
{lineageEdges.filter((e) => e.from === selectedNode).length === 0 && (
|
||||
<span className="text-sm text-gray-400">无下游影响</span>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>关联连接</Label>
|
||||
<div className="space-y-1">
|
||||
{connections.filter(c => c.sourceNode === selectedNode.id || c.targetNode === selectedNode.id).map(conn => {
|
||||
const isSource = conn.sourceNode === selectedNode.id
|
||||
const otherNode = nodes.find(n => n.id === (isSource ? conn.targetNode : conn.sourceNode))
|
||||
return (
|
||||
<div key={conn.id} className="flex items-center gap-2 text-sm p-2 rounded bg-gray-50">
|
||||
<Badge variant="outline" className="font-mono">{isSource ? conn.sourceField : conn.targetField}</Badge>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className="text-gray-500">{otherNode?.name}</span>
|
||||
<Badge variant="outline" className="font-mono">{isSource ? conn.targetField : conn.sourceField}</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">更新信息</h4>
|
||||
<p className="text-sm text-gray-700">最后更新:2分钟前</p>
|
||||
<p className="text-sm text-gray-700">记录数:1,234,567</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNodeDetail(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
399
app/data-ingestion/sources/[id]/page.tsx
Normal file
399
app/data-ingestion/sources/[id]/page.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Database,
|
||||
Send,
|
||||
Loader2,
|
||||
Brain,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Table,
|
||||
BarChart3,
|
||||
Zap,
|
||||
Clock,
|
||||
HardDrive,
|
||||
FolderTree,
|
||||
} from "lucide-react"
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
nameCn: string
|
||||
description: string
|
||||
type: string
|
||||
status: string
|
||||
database?: string
|
||||
host?: string
|
||||
recordCount: number
|
||||
collections?: number
|
||||
latency?: number
|
||||
dataCategory?: string
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
name: string
|
||||
count: number
|
||||
indexes: number
|
||||
size: string
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export default function DataSourceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const sourceId = params.id as string
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [source, setSource] = useState<DataSource | null>(null)
|
||||
const [collections, setCollections] = useState<Collection[]>([])
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
|
||||
// AI查询
|
||||
const [query, setQuery] = useState("")
|
||||
const [querying, setQuerying] = useState(false)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadDataSource()
|
||||
}, [sourceId])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
const loadDataSource = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/data-sources')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
const found = data.sources.find((s: DataSource) => s.id === sourceId)
|
||||
if (found) {
|
||||
setSource(found)
|
||||
// 加载集合信息
|
||||
if (found.database) {
|
||||
loadCollections(found.database)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCollections = async (dbName: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/data-sources?action=collections&db=${dbName}`)
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setCollections(data.collections || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载集合失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// AI查询
|
||||
const handleQuery = async () => {
|
||||
if (!query.trim() || querying) return
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
role: "user",
|
||||
content: query,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setQuery("")
|
||||
setQuerying(true)
|
||||
|
||||
try {
|
||||
// 构造针对特定数据库的查询
|
||||
const enhancedQuery = source?.database
|
||||
? `在 ${source.database} 数据库中查询: ${query}`
|
||||
: query
|
||||
|
||||
const response = await fetch("/api/ai-chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: enhancedQuery })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
setMessages(prev => [...prev, {
|
||||
role: "assistant",
|
||||
content: data.success ? data.response.content : `查询失败: ${data.error}`,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
}])
|
||||
} catch (error: any) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "assistant",
|
||||
content: `错误: ${error.message}`,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
}])
|
||||
} finally {
|
||||
setQuerying(false)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 p-6">
|
||||
<Button variant="ghost" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> 返回
|
||||
</Button>
|
||||
<div className="mt-20 text-center text-gray-500">数据源不存在</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 顶部导航 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" /> 返回
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{source.nameCn || source.name}</h1>
|
||||
<p className="text-sm text-gray-500 font-mono">{source.database || source.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={source.status === 'connected' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}>
|
||||
{source.status === 'connected' ? '已连接' : '待配置'}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-1" /> 配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<HardDrive className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold">{formatNumber(source.recordCount)}</div>
|
||||
<div className="text-xs text-gray-500">总记录数</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<FolderTree className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold">{source.collections || 0}</div>
|
||||
<div className="text-xs text-gray-500">集合数</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<Zap className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold">{source.latency || 0}ms</div>
|
||||
<div className="text-xs text-gray-500">延迟</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold">实时</div>
|
||||
<div className="text-xs text-gray-500">同步状态</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-white/80">
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
<TabsTrigger value="query">AI查询</TabsTrigger>
|
||||
<TabsTrigger value="collections">集合</TabsTrigger>
|
||||
<TabsTrigger value="stats">统计</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 概览 */}
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">数据源信息</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded-lg bg-gray-50">
|
||||
<div className="text-xs text-gray-500 mb-1">类型</div>
|
||||
<div className="font-medium">{source.type.toUpperCase()}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-gray-50">
|
||||
<div className="text-xs text-gray-500 mb-1">分类</div>
|
||||
<div className="font-medium">{source.dataCategory || '其他'}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-gray-50">
|
||||
<div className="text-xs text-gray-500 mb-1">主机</div>
|
||||
<div className="font-mono text-sm">{source.host || 'localhost'}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-gray-50">
|
||||
<div className="text-xs text-gray-500 mb-1">数据库</div>
|
||||
<div className="font-mono text-sm">{source.database || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-3 rounded-lg bg-blue-50">
|
||||
<div className="text-xs text-blue-600 mb-1">功能描述</div>
|
||||
<div className="text-sm text-gray-700">{source.description}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* AI查询 */}
|
||||
<TabsContent value="query" className="mt-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardHeader className="pb-2 border-b">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-purple-500" />
|
||||
AI 查询 - {source.nameCn || source.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* 消息区域 */}
|
||||
<div className="h-[300px] overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-10">
|
||||
<Brain className="h-10 w-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">输入查询命令,AI将在此数据库中搜索</p>
|
||||
<p className="text-xs mt-1">例如: "查询前10条数据" 或 "统计用户分布"</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] rounded-xl px-4 py-2 text-sm ${
|
||||
msg.role === 'user'
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
<div className={`text-xs mt-1 ${msg.role === 'user' ? 'text-blue-100' : 'text-gray-400'}`}>
|
||||
{msg.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{querying && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 rounded-xl px-4 py-2 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-gray-500">查询中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{/* 输入框 */}
|
||||
<div className="p-3 border-t flex gap-2">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleQuery()}
|
||||
placeholder={`在 ${source.database || source.name} 中查询...`}
|
||||
className="flex-1 bg-gray-50 border-0"
|
||||
/>
|
||||
<Button onClick={handleQuery} disabled={querying || !query.trim()}>
|
||||
{querying ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 集合列表 */}
|
||||
<TabsContent value="collections" className="mt-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
{collections.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-10">
|
||||
<Table className="h-10 w-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无集合数据</p>
|
||||
</div>
|
||||
) : (
|
||||
collections.map((coll, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-gray-50 hover:bg-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Table className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-mono text-sm">{coll.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{formatNumber(coll.count)} 条</span>
|
||||
<span>{coll.indexes} 索引</span>
|
||||
<span>{coll.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 统计 */}
|
||||
<TabsContent value="stats" className="mt-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center text-gray-400 py-10">
|
||||
<BarChart3 className="h-10 w-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">统计图表开发中</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,26 +22,26 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Trash2,
|
||||
PlayCircle,
|
||||
PauseCircle,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
Server,
|
||||
Globe,
|
||||
Webhook,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit,
|
||||
Activity,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
Target,
|
||||
FolderTree,
|
||||
ChevronRight,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -49,98 +49,102 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// 数据源接口
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
nameCn: string
|
||||
description: string
|
||||
type: 'mongodb' | 'mysql' | 'api' | 'webhook'
|
||||
status: 'connected' | 'disconnected' | 'warning'
|
||||
host?: string
|
||||
database?: string
|
||||
endpoint?: string
|
||||
lastSync: string
|
||||
recordCount: number
|
||||
syncFrequency: string
|
||||
collections?: number
|
||||
tables?: number
|
||||
latency?: number
|
||||
dataCategory?: string
|
||||
targetCollection?: string
|
||||
}
|
||||
|
||||
// 数据分类颜色映射
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'用户画像': 'bg-blue-100 text-blue-700',
|
||||
'用户数据': 'bg-indigo-100 text-indigo-700',
|
||||
'社交数据': 'bg-pink-100 text-pink-700',
|
||||
'电商数据': 'bg-orange-100 text-orange-700',
|
||||
'私域数据': 'bg-purple-100 text-purple-700',
|
||||
'企业数据': 'bg-cyan-100 text-cyan-700',
|
||||
'金融数据': 'bg-green-100 text-green-700',
|
||||
'物流数据': 'bg-yellow-100 text-yellow-700',
|
||||
'消费数据': 'bg-red-100 text-red-700',
|
||||
'商业数据': 'bg-emerald-100 text-emerald-700',
|
||||
'其他': 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
// 第二部分:数据接入 - 数据源管理
|
||||
export default function DataSourcesPage() {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||
const [showLogsDialog, setShowLogsDialog] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<any>(null)
|
||||
const [newSourceType, setNewSourceType] = useState("")
|
||||
const [dataSources, setDataSources] = useState<DataSource[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [summary, setSummary] = useState({
|
||||
total: 0,
|
||||
connected: 0,
|
||||
warning: 0,
|
||||
totalRecords: 0,
|
||||
latency: 0
|
||||
})
|
||||
|
||||
// 数据源列表
|
||||
const dataSources = [
|
||||
{
|
||||
id: "1",
|
||||
name: "存客宝-MySQL主库",
|
||||
type: "mysql",
|
||||
status: "connected",
|
||||
host: "10.88.182.62:3305",
|
||||
database: "cunke_prod",
|
||||
lastSync: "2分钟前",
|
||||
recordCount: 12584567,
|
||||
syncFrequency: "实时",
|
||||
tables: 45,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "触客宝-行为数据",
|
||||
type: "mysql",
|
||||
status: "connected",
|
||||
host: "10.88.182.63:3306",
|
||||
database: "chuke_behavior",
|
||||
lastSync: "5分钟前",
|
||||
recordCount: 89234156,
|
||||
syncFrequency: "5分钟",
|
||||
tables: 28,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "数智员工-API接口",
|
||||
type: "api",
|
||||
status: "connected",
|
||||
endpoint: "https://api.shuzhi.com/v1",
|
||||
lastSync: "1分钟前",
|
||||
recordCount: 4567890,
|
||||
syncFrequency: "实时",
|
||||
apiCalls: 125680,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "外部征信数据",
|
||||
type: "api",
|
||||
status: "warning",
|
||||
endpoint: "https://credit.external.com/api",
|
||||
lastSync: "30分钟前",
|
||||
recordCount: 234567,
|
||||
syncFrequency: "每小时",
|
||||
apiCalls: 8956,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Webhook-实时事件",
|
||||
type: "webhook",
|
||||
status: "connected",
|
||||
webhookUrl: "/api/webhook/events",
|
||||
lastSync: "实时",
|
||||
recordCount: 567890,
|
||||
syncFrequency: "实时",
|
||||
events: 45678,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "腾讯云MySQL",
|
||||
type: "mysql",
|
||||
status: "disconnected",
|
||||
host: "56b4c23f6853c.gz.cdb.myqcloud.com:14413",
|
||||
database: "analytics_db",
|
||||
lastSync: "2小时前",
|
||||
recordCount: 0,
|
||||
syncFrequency: "停止",
|
||||
tables: 0,
|
||||
},
|
||||
]
|
||||
// 新数据源表单
|
||||
const [newSource, setNewSource] = useState({
|
||||
type: 'mongodb',
|
||||
name: '',
|
||||
nameCn: '',
|
||||
description: '',
|
||||
host: '',
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
targetCollection: 'KR.用户估值',
|
||||
syncFrequency: 'realtime'
|
||||
})
|
||||
|
||||
// 加载数据源
|
||||
useEffect(() => {
|
||||
loadDataSources()
|
||||
}, [])
|
||||
|
||||
const loadDataSources = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/data-sources')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setDataSources(data.sources)
|
||||
setSummary(data.summary)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据源失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <Badge className="bg-green-100 text-green-700">已连接</Badge>
|
||||
return <Badge className="bg-green-100 text-green-700 border-0">已连接</Badge>
|
||||
case "warning":
|
||||
return <Badge className="bg-yellow-100 text-yellow-700">警告</Badge>
|
||||
return <Badge className="bg-yellow-100 text-yellow-700 border-0">待配置</Badge>
|
||||
case "disconnected":
|
||||
return <Badge className="bg-red-100 text-red-700">已断开</Badge>
|
||||
return <Badge className="bg-red-100 text-red-700 border-0">未连接</Badge>
|
||||
default:
|
||||
return <Badge variant="secondary">未知</Badge>
|
||||
}
|
||||
@@ -148,31 +152,60 @@ export default function DataSourcesPage() {
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "mongodb":
|
||||
return <Database className="h-5 w-5 text-green-600" />
|
||||
case "mysql":
|
||||
return <Database className="h-5 w-5 text-blue-500" />
|
||||
return <Server className="h-5 w-5 text-blue-600" />
|
||||
case "api":
|
||||
return <Globe className="h-5 w-5 text-green-500" />
|
||||
return <Globe className="h-5 w-5 text-purple-600" />
|
||||
case "webhook":
|
||||
return <Webhook className="h-5 w-5 text-purple-500" />
|
||||
return <Webhook className="h-5 w-5 text-orange-600" />
|
||||
default:
|
||||
return <Server className="h-5 w-5 text-gray-500" />
|
||||
return <Database className="h-5 w-5 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
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 filteredSources = dataSources.filter((source) => {
|
||||
if (activeTab !== "all" && source.type !== activeTab) return false
|
||||
if (searchQuery && !source.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
source.name.toLowerCase().includes(query) ||
|
||||
source.nameCn?.toLowerCase().includes(query) ||
|
||||
source.description?.toLowerCase().includes(query) ||
|
||||
source.dataCategory?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const handleOpenSettings = (source: any) => {
|
||||
setSelectedSource(source)
|
||||
setShowSettingsDialog(true)
|
||||
// 按分类分组
|
||||
const groupedSources = filteredSources.reduce((acc, source) => {
|
||||
const category = source.dataCategory || '其他'
|
||||
if (!acc[category]) acc[category] = []
|
||||
acc[category].push(source)
|
||||
return acc
|
||||
}, {} as Record<string, DataSource[]>)
|
||||
|
||||
// 测试连接
|
||||
const handleTestConnection = async () => {
|
||||
// TODO: 实现连接测试
|
||||
alert('连接测试功能开发中')
|
||||
}
|
||||
|
||||
const handleOpenLogs = (source: any) => {
|
||||
setSelectedSource(source)
|
||||
setShowLogsDialog(true)
|
||||
// 添加数据源
|
||||
const handleAddSource = async () => {
|
||||
// TODO: 实现添加数据源
|
||||
setShowAddDialog(false)
|
||||
loadDataSources()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -182,9 +215,15 @@ export default function DataSourcesPage() {
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据源管理</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">统一管理所有数据接入源,支持MySQL、API、Webhook等多种接入方式</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
管理所有数据接入源,支持MongoDB、MySQL、API、Webhook等多种数据源
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={loadDataSources} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加数据源
|
||||
@@ -199,9 +238,11 @@ export default function DataSourcesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">数据源总数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{dataSources.length}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{summary.total}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<Database className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<Database className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -210,11 +251,11 @@ export default function DataSourcesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">已连接</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{dataSources.filter((s) => s.status === "connected").length}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.connected}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -222,10 +263,12 @@ export default function DataSourcesPage() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">总记录数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">107.2M</p>
|
||||
<p className="text-sm text-gray-500">总数据量</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatNumber(summary.totalRecords)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<FolderTree className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -233,10 +276,12 @@ export default function DataSourcesPage() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">今日同步</p>
|
||||
<p className="text-2xl font-bold text-gray-900">2.3M</p>
|
||||
<p className="text-sm text-gray-500">平均延迟</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{summary.latency}ms</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-100 flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<RefreshCw className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -247,7 +292,7 @@ export default function DataSourcesPage() {
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索数据源名称..."
|
||||
placeholder="搜索数据源名称、描述、分类..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 bg-white"
|
||||
@@ -256,6 +301,7 @@ export default function DataSourcesPage() {
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-white">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="mongodb">MongoDB</TabsTrigger>
|
||||
<TabsTrigger value="mysql">MySQL</TabsTrigger>
|
||||
<TabsTrigger value="api">API</TabsTrigger>
|
||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||
@@ -263,352 +309,239 @@ export default function DataSourcesPage() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 数据源列表 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredSources.map((source) => (
|
||||
<Card key={source.id} className="bg-white/80 backdrop-blur border-0 shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-xl bg-gray-50">
|
||||
{getTypeIcon(source.type)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{source.name}</h3>
|
||||
{getStatusBadge(source.status)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{source.type === "mysql" ? source.host : source.type === "api" ? source.endpoint : source.webhookUrl}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{source.lastSync}
|
||||
</span>
|
||||
<span>{source.recordCount.toLocaleString()} 条记录</span>
|
||||
<span>同步频率: {source.syncFrequency}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleOpenSettings(source)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
设置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleOpenLogs(source)}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
查看日志
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
立即同步
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
数据预览
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* 加载状态 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<span className="ml-3 text-gray-500">加载数据源...</span>
|
||||
</div>
|
||||
) : (
|
||||
/* 按分类分组显示 */
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedSources).map(([category, sources]) => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge className={CATEGORY_COLORS[category] || CATEGORY_COLORS['其他']}>
|
||||
{category}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">{sources.length} 个数据源</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{sources.map((source) => (
|
||||
<Card
|
||||
key={source.id}
|
||||
className="bg-white/80 backdrop-blur border-0 shadow-sm hover:shadow-md transition-all cursor-pointer group"
|
||||
onClick={() => router.push(`/data-ingestion/sources/${source.id}`)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-xl bg-gray-50 group-hover:bg-blue-50 transition-colors">
|
||||
{getTypeIcon(source.type)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{source.nameCn || source.name}</h3>
|
||||
{getStatusBadge(source.status)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 font-mono">{source.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-gray-600 mb-2 line-clamp-1">
|
||||
{source.description}
|
||||
</p>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
{source.collections !== undefined && (
|
||||
<Badge variant="outline" className="text-xs h-5">{source.collections} 集合</Badge>
|
||||
)}
|
||||
{source.tables !== undefined && source.tables > 0 && (
|
||||
<Badge variant="outline" className="text-xs h-5">{source.tables} 表</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{formatNumber(source.recordCount)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加数据源弹窗 */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加数据源</DialogTitle>
|
||||
<DialogDescription>选择数据源类型并配置连接参数</DialogDescription>
|
||||
<DialogDescription>
|
||||
配置新的数据源,数据将通过清洗规则处理后同步到指定的数据中台集合
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 数据源类型 */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ type: "mysql", label: "MySQL数据库", icon: Database, desc: "连接MySQL/MariaDB数据库" },
|
||||
{ type: "api", label: "REST API", icon: Globe, desc: "通过API接口获取数据" },
|
||||
{ type: "webhook", label: "Webhook", icon: Webhook, desc: "接收实时推送数据" },
|
||||
{ type: "mongodb", label: "MongoDB", icon: Database, desc: "文档数据库" },
|
||||
{ type: "mysql", label: "MySQL", icon: Server, desc: "关系型数据库" },
|
||||
{ type: "api", label: "REST API", icon: Globe, desc: "HTTP接口" },
|
||||
{ type: "webhook", label: "Webhook", icon: Webhook, desc: "实时推送" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.type}
|
||||
onClick={() => setNewSourceType(item.type)}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
newSourceType === item.type
|
||||
onClick={() => setNewSource({ ...newSource, type: item.type })}
|
||||
className={`p-4 rounded-xl border-2 text-center transition-all ${
|
||||
newSource.type === item.type
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`h-8 w-8 mb-2 ${newSourceType === item.type ? "text-blue-500" : "text-gray-400"}`} />
|
||||
<p className="font-medium text-gray-900">{item.label}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{item.desc}</p>
|
||||
<item.icon className={`h-6 w-6 mx-auto mb-2 ${newSource.type === item.type ? "text-blue-500" : "text-gray-400"}`} />
|
||||
<p className="font-medium text-sm text-gray-900">{item.label}</p>
|
||||
<p className="text-xs text-gray-500">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{newSourceType === "mysql" && (
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据源标识 *</Label>
|
||||
<Input
|
||||
placeholder="例如:user_db_prod"
|
||||
value={newSource.name}
|
||||
onChange={(e) => setNewSource({ ...newSource, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>中文名称 *</Label>
|
||||
<Input
|
||||
placeholder="例如:用户生产库"
|
||||
value={newSource.nameCn}
|
||||
onChange={(e) => setNewSource({ ...newSource, nameCn: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>功能描述</Label>
|
||||
<Textarea
|
||||
placeholder="简要描述该数据源的数据内容和用途..."
|
||||
value={newSource.description}
|
||||
onChange={(e) => setNewSource({ ...newSource, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 连接信息 */}
|
||||
{(newSource.type === 'mongodb' || newSource.type === 'mysql') && (
|
||||
<div className="space-y-4 p-4 rounded-lg bg-gray-50">
|
||||
<h4 className="font-medium text-gray-900">连接配置</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据源名称</Label>
|
||||
<Input placeholder="例如:生产环境主库" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>主机地址</Label>
|
||||
<Input placeholder="例如:10.88.182.62:3306" />
|
||||
<Input
|
||||
placeholder="localhost:27017"
|
||||
value={newSource.host}
|
||||
onChange={(e) => setNewSource({ ...newSource, host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据库名</Label>
|
||||
<Input placeholder="数据库名称" />
|
||||
<Input
|
||||
placeholder="数据库名称"
|
||||
value={newSource.database}
|
||||
onChange={(e) => setNewSource({ ...newSource, database: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input placeholder="数据库用户名" />
|
||||
<Input
|
||||
placeholder="用户名"
|
||||
value={newSource.username}
|
||||
onChange={(e) => setNewSource({ ...newSource, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>密码</Label>
|
||||
<Input type="password" placeholder="数据库密码" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>同步频率</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择同步频率" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">实时</SelectItem>
|
||||
<SelectItem value="5min">每5分钟</SelectItem>
|
||||
<SelectItem value="15min">每15分钟</SelectItem>
|
||||
<SelectItem value="1hour">每小时</SelectItem>
|
||||
<SelectItem value="daily">每天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={newSource.password}
|
||||
onChange={(e) => setNewSource({ ...newSource, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSourceType === "api" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据源名称</Label>
|
||||
<Input placeholder="例如:外部API接口" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API端点</Label>
|
||||
<Input placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>认证方式</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择认证方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="apikey">API Key</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>认证凭证</Label>
|
||||
<Input type="password" placeholder="Token或密钥" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 数据中台配置 */}
|
||||
<div className="space-y-4 p-4 rounded-lg bg-purple-50">
|
||||
<h4 className="font-medium text-gray-900 flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-purple-500" />
|
||||
数据中台配置
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>请求方法</Label>
|
||||
<Select>
|
||||
<Label>目标集合</Label>
|
||||
<Select
|
||||
value={newSource.targetCollection}
|
||||
onValueChange={(v) => setNewSource({ ...newSource, targetCollection: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择请求方法" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="KR.用户估值">KR.用户估值(统一画像)</SelectItem>
|
||||
<SelectItem value="KR_存客宝.用户资产统一视图">KR_存客宝.用户资产统一视图</SelectItem>
|
||||
<SelectItem value="KR_点了码.用户资产统一视图">KR_点了码.用户资产统一视图</SelectItem>
|
||||
<SelectItem value="custom">自定义集合...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>同步频率</Label>
|
||||
<Select
|
||||
value={newSource.syncFrequency}
|
||||
onValueChange={(v) => setNewSource({ ...newSource, syncFrequency: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">实时同步</SelectItem>
|
||||
<SelectItem value="5min">每5分钟</SelectItem>
|
||||
<SelectItem value="1hour">每小时</SelectItem>
|
||||
<SelectItem value="daily">每天</SelectItem>
|
||||
<SelectItem value="manual">手动触发</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSourceType === "webhook" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据源名称</Label>
|
||||
<Input placeholder="例如:实时事件推送" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Webhook路径</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">https://your-domain.com</span>
|
||||
<Input placeholder="/api/webhook/your-path" className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>签名密钥(可选)</Label>
|
||||
<Input placeholder="用于验证请求来源" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
数据将通过清洗规则处理后,按照神射手用户资产数字化格式写入目标集合
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(false)}>
|
||||
测试连接并保存
|
||||
<Button variant="outline" onClick={handleTestConnection}>
|
||||
测试连接
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 设置弹窗 */}
|
||||
<Dialog open={showSettingsDialog} onOpenChange={setShowSettingsDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>数据源设置 - {selectedSource?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="connection" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="connection">连接配置</TabsTrigger>
|
||||
<TabsTrigger value="sync">同步设置</TabsTrigger>
|
||||
<TabsTrigger value="mapping">字段映射</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="connection" className="space-y-4 pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>主机地址</Label>
|
||||
<Input defaultValue={selectedSource?.host} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>数据库名</Label>
|
||||
<Input defaultValue={selectedSource?.database} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input defaultValue="root" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>密码</Label>
|
||||
<Input type="password" defaultValue="********" />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="sync" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>同步频率</Label>
|
||||
<Select defaultValue="realtime">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">实时</SelectItem>
|
||||
<SelectItem value="5min">每5分钟</SelectItem>
|
||||
<SelectItem value="15min">每15分钟</SelectItem>
|
||||
<SelectItem value="1hour">每小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>增量字段</Label>
|
||||
<Input defaultValue="updated_at" placeholder="用于增量同步的时间字段" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="mapping" className="space-y-4 pt-4">
|
||||
<p className="text-sm text-gray-500">配置源表字段与目标字段的映射关系</p>
|
||||
<div className="border rounded-lg p-4 bg-gray-50">
|
||||
<p className="text-sm text-gray-600">字段映射配置将在下个版本支持</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSettingsDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setShowSettingsDialog(false)}>
|
||||
保存设置
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 日志弹窗 */}
|
||||
<Dialog open={showLogsDialog} onOpenChange={setShowLogsDialog}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>同步日志 - {selectedSource?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{[
|
||||
{ time: "2026-01-31 14:32:45", level: "info", message: "同步任务开始执行" },
|
||||
{ time: "2026-01-31 14:32:46", level: "info", message: "连接数据库成功" },
|
||||
{ time: "2026-01-31 14:32:47", level: "info", message: "开始读取增量数据,起始时间: 2026-01-31 14:27:45" },
|
||||
{ time: "2026-01-31 14:32:50", level: "info", message: "读取到 1,256 条新记录" },
|
||||
{ time: "2026-01-31 14:32:52", level: "info", message: "数据写入目标表完成" },
|
||||
{ time: "2026-01-31 14:32:53", level: "success", message: "同步任务完成,耗时 8秒" },
|
||||
{ time: "2026-01-31 14:27:45", level: "info", message: "同步任务开始执行" },
|
||||
{ time: "2026-01-31 14:27:46", level: "info", message: "连接数据库成功" },
|
||||
{ time: "2026-01-31 14:27:48", level: "warning", message: "检测到 3 条数据格式异常,已跳过" },
|
||||
{ time: "2026-01-31 14:27:50", level: "info", message: "读取到 2,134 条新记录" },
|
||||
{ time: "2026-01-31 14:27:53", level: "success", message: "同步任务完成,耗时 8秒" },
|
||||
].map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg text-sm ${
|
||||
log.level === "error"
|
||||
? "bg-red-50"
|
||||
: log.level === "warning"
|
||||
? "bg-yellow-50"
|
||||
: log.level === "success"
|
||||
? "bg-green-50"
|
||||
: "bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-400 font-mono text-xs whitespace-nowrap">{log.time}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-xs ${
|
||||
log.level === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: log.level === "warning"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: log.level === "success"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-gray-700">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowLogsDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
导出日志
|
||||
<Button onClick={handleAddSource}>
|
||||
保存并同步
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
414
app/data-ingestion/tasks/page.tsx
Normal file
414
app/data-ingestion/tasks/page.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "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 { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Calendar,
|
||||
Plus,
|
||||
Play,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
Database,
|
||||
Zap,
|
||||
Target,
|
||||
} from "lucide-react"
|
||||
|
||||
// 任务接口
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: 'sync' | 'clean' | 'tag' | 'export'
|
||||
source: string
|
||||
target: string
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
lastRun?: string
|
||||
lastStatus?: 'success' | 'failed' | 'running'
|
||||
nextRun?: string
|
||||
processedCount?: number
|
||||
}
|
||||
|
||||
// 预定义任务
|
||||
const DEFAULT_TASKS: Task[] = [
|
||||
{
|
||||
id: 'task_1',
|
||||
name: '腾讯QQ数据同步',
|
||||
description: '从KR_腾讯同步QQ手机关联数据到用户估值表',
|
||||
type: 'sync',
|
||||
source: 'KR_腾讯',
|
||||
target: 'KR.用户估值',
|
||||
schedule: '每小时',
|
||||
enabled: true,
|
||||
lastRun: '10分钟前',
|
||||
lastStatus: 'success',
|
||||
nextRun: '50分钟后',
|
||||
processedCount: 1250000
|
||||
},
|
||||
{
|
||||
id: 'task_2',
|
||||
name: '微博UID关联',
|
||||
description: '从KR_微博同步UID与手机号关联',
|
||||
type: 'sync',
|
||||
source: 'KR_微博',
|
||||
target: 'KR.用户估值',
|
||||
schedule: '每天凌晨',
|
||||
enabled: true,
|
||||
lastRun: '昨天 02:00',
|
||||
lastStatus: 'success',
|
||||
nextRun: '明天 02:00',
|
||||
processedCount: 5800000
|
||||
},
|
||||
{
|
||||
id: 'task_3',
|
||||
name: '存客宝CRM同步',
|
||||
description: '从MySQL同步存客宝用户数据',
|
||||
type: 'sync',
|
||||
source: 'cunkebao_v3',
|
||||
target: 'KR_存客宝.用户资产统一视图',
|
||||
schedule: '每5分钟',
|
||||
enabled: true,
|
||||
lastRun: '2分钟前',
|
||||
lastStatus: 'success',
|
||||
nextRun: '3分钟后',
|
||||
processedCount: 350
|
||||
},
|
||||
{
|
||||
id: 'task_4',
|
||||
name: '手机号格式化清洗',
|
||||
description: '清洗手机号格式,补充归属地信息',
|
||||
type: 'clean',
|
||||
source: 'KR.用户估值',
|
||||
target: 'KR.用户估值',
|
||||
schedule: '每小时',
|
||||
enabled: true,
|
||||
lastRun: '30分钟前',
|
||||
lastStatus: 'success',
|
||||
processedCount: 95000
|
||||
},
|
||||
{
|
||||
id: 'task_5',
|
||||
name: 'RFM评分计算',
|
||||
description: '计算用户RFM评分并更新用户等级',
|
||||
type: 'tag',
|
||||
source: 'KR.用户估值',
|
||||
target: 'KR.用户估值',
|
||||
schedule: '每天凌晨',
|
||||
enabled: true,
|
||||
lastRun: '昨天 03:00',
|
||||
lastStatus: 'success',
|
||||
processedCount: 20000000
|
||||
},
|
||||
{
|
||||
id: 'task_6',
|
||||
name: '流量包导出',
|
||||
description: '导出高价值用户流量包',
|
||||
type: 'export',
|
||||
source: 'KR.用户估值',
|
||||
target: '文件系统',
|
||||
schedule: '按需',
|
||||
enabled: false,
|
||||
lastRun: '3天前',
|
||||
lastStatus: 'success',
|
||||
processedCount: 50000
|
||||
},
|
||||
]
|
||||
|
||||
export default function TaskSchedulePage() {
|
||||
const [tasks, setTasks] = useState<Task[]>(DEFAULT_TASKS)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [runningTask, setRunningTask] = useState<string | null>(null)
|
||||
|
||||
// 任务类型配置
|
||||
const TASK_TYPES = {
|
||||
sync: { label: '数据同步', color: 'bg-blue-100 text-blue-700', icon: RefreshCw },
|
||||
clean: { label: '数据清洗', color: 'bg-green-100 text-green-700', icon: Zap },
|
||||
tag: { label: '标签计算', color: 'bg-purple-100 text-purple-700', icon: Target },
|
||||
export: { label: '数据导出', color: 'bg-orange-100 text-orange-700', icon: Database },
|
||||
}
|
||||
|
||||
// 执行任务
|
||||
const runTask = async (taskId: string) => {
|
||||
setRunningTask(taskId)
|
||||
// 模拟执行
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
setTasks(tasks.map(t => t.id === taskId ? {
|
||||
...t,
|
||||
lastRun: '刚刚',
|
||||
lastStatus: 'success' as const
|
||||
} : t))
|
||||
setRunningTask(null)
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
const toggleTask = (taskId: string) => {
|
||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, enabled: !t.enabled } : t))
|
||||
}
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const getStatusIcon = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-400" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">任务调度</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">管理数据同步、清洗、计算等定时任务</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => setLoading(true)}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">任务总数</p>
|
||||
<p className="text-2xl font-bold">{tasks.length}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-blue-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">运行中</p>
|
||||
<p className="text-2xl font-bold text-green-600">{tasks.filter(t => t.enabled).length}</p>
|
||||
</div>
|
||||
<Play className="h-8 w-8 text-green-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">今日执行</p>
|
||||
<p className="text-2xl font-bold">128</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-purple-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">处理数据</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(tasks.reduce((sum, t) => sum + (t.processedCount || 0), 0))}</p>
|
||||
</div>
|
||||
<Database className="h-8 w-8 text-orange-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task) => {
|
||||
const typeConfig = TASK_TYPES[task.type]
|
||||
const TypeIcon = typeConfig.icon
|
||||
const isRunning = runningTask === task.id
|
||||
|
||||
return (
|
||||
<Card key={task.id} className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2.5 rounded-xl ${typeConfig.color.replace('text-', 'bg-').replace('-700', '-100')}`}>
|
||||
<TypeIcon className={`h-5 w-5 ${typeConfig.color.split(' ')[1]}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{task.name}</h3>
|
||||
<Badge className={typeConfig.color}>{typeConfig.label}</Badge>
|
||||
{!task.enabled && <Badge variant="outline" className="text-gray-500">已暂停</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{task.description}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Database className="h-3 w-3" />
|
||||
{task.source} → {task.target}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{task.schedule}
|
||||
</span>
|
||||
{task.lastRun && (
|
||||
<span className="flex items-center gap-1">
|
||||
{getStatusIcon(task.lastStatus)}
|
||||
上次: {task.lastRun}
|
||||
</span>
|
||||
)}
|
||||
{task.processedCount && (
|
||||
<span>处理: {formatNumber(task.processedCount)} 条</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={task.enabled}
|
||||
onCheckedChange={() => toggleTask(task.id)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => runTask(task.id)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 新建任务弹窗 */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建任务</DialogTitle>
|
||||
<DialogDescription>创建新的数据处理任务</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>任务名称</Label>
|
||||
<Input placeholder="输入任务名称" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>任务类型</Label>
|
||||
<Select defaultValue="sync">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sync">数据同步</SelectItem>
|
||||
<SelectItem value="clean">数据清洗</SelectItem>
|
||||
<SelectItem value="tag">标签计算</SelectItem>
|
||||
<SelectItem value="export">数据导出</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>执行频率</Label>
|
||||
<Select defaultValue="hourly">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5min">每5分钟</SelectItem>
|
||||
<SelectItem value="hourly">每小时</SelectItem>
|
||||
<SelectItem value="daily">每天</SelectItem>
|
||||
<SelectItem value="manual">手动触发</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>数据源</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择数据源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kr_tencent">KR_腾讯</SelectItem>
|
||||
<SelectItem value="kr_weibo">KR_微博</SelectItem>
|
||||
<SelectItem value="kr_ckb">KR_存客宝</SelectItem>
|
||||
<SelectItem value="kr_valuation">KR.用户估值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目标</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择目标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kr_valuation">KR.用户估值</SelectItem>
|
||||
<SelectItem value="kr_ckb_view">KR_存客宝.用户资产统一视图</SelectItem>
|
||||
<SelectItem value="file">文件导出</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>取消</Button>
|
||||
<Button onClick={() => setShowAddDialog(false)}>创建任务</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
452
app/data-market/api/page.tsx
Normal file
452
app/data-market/api/page.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Server,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
Key,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Code,
|
||||
Zap,
|
||||
Users,
|
||||
Search,
|
||||
Tags,
|
||||
Brain,
|
||||
Database,
|
||||
FileText,
|
||||
Package,
|
||||
Activity,
|
||||
} from "lucide-react"
|
||||
|
||||
// API端点接口
|
||||
interface APIEndpoint {
|
||||
id: string
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
path: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
auth: boolean
|
||||
params?: { name: string; type: string; required: boolean; desc: string }[]
|
||||
response?: string
|
||||
}
|
||||
|
||||
// API分类
|
||||
const API_CATEGORIES = [
|
||||
{ id: 'query', name: '用户查询', icon: Search },
|
||||
{ id: 'tag', name: '标签服务', icon: Tags },
|
||||
{ id: 'ai', name: 'AI服务', icon: Brain },
|
||||
{ id: 'data', name: '数据服务', icon: Database },
|
||||
{ id: 'report', name: '报告服务', icon: FileText },
|
||||
{ id: 'package', name: '流量包', icon: Package },
|
||||
]
|
||||
|
||||
// 预定义API端点
|
||||
const API_ENDPOINTS: APIEndpoint[] = [
|
||||
// 用户查询
|
||||
{
|
||||
id: 'api_1',
|
||||
method: 'GET',
|
||||
path: '/api/shensheshou/user',
|
||||
name: '用户画像查询',
|
||||
description: '根据手机号或QQ查询完整用户画像',
|
||||
category: 'query',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'phone', type: 'string', required: false, desc: '11位手机号' },
|
||||
{ name: 'qq', type: 'string', required: false, desc: 'QQ号码' },
|
||||
],
|
||||
response: '{ "user": { "phone": "138xxx", "rfm": 85, "level": "A", "tags": [...] } }',
|
||||
},
|
||||
{
|
||||
id: 'api_2',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/users/batch',
|
||||
name: '批量用户查询',
|
||||
description: '批量查询多个用户的画像信息',
|
||||
category: 'query',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'phones', type: 'array', required: false, desc: '手机号数组' },
|
||||
{ name: 'qqs', type: 'array', required: false, desc: 'QQ号数组' },
|
||||
],
|
||||
},
|
||||
// 标签服务
|
||||
{
|
||||
id: 'api_3',
|
||||
method: 'GET',
|
||||
path: '/api/shensheshou/tags',
|
||||
name: '标签列表',
|
||||
description: '获取所有可用标签',
|
||||
category: 'tag',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'category', type: 'string', required: false, desc: '标签分类' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'api_4',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/tags/apply',
|
||||
name: '应用标签',
|
||||
description: '为用户应用指定标签',
|
||||
category: 'tag',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'userId', type: 'string', required: true, desc: '用户ID' },
|
||||
{ name: 'tags', type: 'array', required: true, desc: '标签ID数组' },
|
||||
],
|
||||
},
|
||||
// AI服务
|
||||
{
|
||||
id: 'api_5',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/chat',
|
||||
name: 'AI对话',
|
||||
description: '与神射手AI进行对话,支持自然语言查询',
|
||||
category: 'ai',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'message', type: 'string', required: true, desc: '对话内容' },
|
||||
],
|
||||
response: '{ "success": true, "response": { "content": "查询结果...", "data": {...} } }',
|
||||
},
|
||||
{
|
||||
id: 'api_6',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/ai/analyze',
|
||||
name: 'AI数据分析',
|
||||
description: 'AI自动分析数据并生成洞察',
|
||||
category: 'ai',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'type', type: 'string', required: true, desc: '分析类型: rfm/behavior/preference' },
|
||||
{ name: 'filters', type: 'object', required: false, desc: '筛选条件' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'api_7',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/ai/tag',
|
||||
name: 'AI智能打标',
|
||||
description: 'AI自动为用户打标签',
|
||||
category: 'ai',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'source', type: 'string', required: true, desc: '数据源' },
|
||||
{ name: 'model', type: 'string', required: false, desc: 'AI模型: qwen/deepseek' },
|
||||
],
|
||||
},
|
||||
// 数据服务
|
||||
{
|
||||
id: 'api_8',
|
||||
method: 'GET',
|
||||
path: '/api/shensheshou/sources',
|
||||
name: '数据源列表',
|
||||
description: '获取所有数据源连接状态',
|
||||
category: 'data',
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
id: 'api_9',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/ingest',
|
||||
name: '数据导入',
|
||||
description: '导入外部数据并通过AI标签引擎处理',
|
||||
category: 'data',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'source', type: 'string', required: true, desc: '数据源标识' },
|
||||
{ name: 'target', type: 'string', required: true, desc: '目标表' },
|
||||
],
|
||||
},
|
||||
// 报告服务
|
||||
{
|
||||
id: 'api_10',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/report/generate',
|
||||
name: '生成报告',
|
||||
description: 'AI自动生成数据分析报告',
|
||||
category: 'report',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'template', type: 'string', required: true, desc: '报告模板ID' },
|
||||
{ name: 'dateRange', type: 'string', required: false, desc: '日期范围' },
|
||||
],
|
||||
},
|
||||
// 流量包
|
||||
{
|
||||
id: 'api_11',
|
||||
method: 'GET',
|
||||
path: '/api/shensheshou/packages',
|
||||
name: '流量包列表',
|
||||
description: '获取所有流量包',
|
||||
category: 'package',
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
id: 'api_12',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/packages/create',
|
||||
name: '创建流量包',
|
||||
description: '根据筛选条件创建流量包',
|
||||
category: 'package',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'name', type: 'string', required: true, desc: '流量包名称' },
|
||||
{ name: 'criteria', type: 'object', required: true, desc: '筛选条件' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'api_13',
|
||||
method: 'POST',
|
||||
path: '/api/shensheshou/packages/send',
|
||||
name: '发送流量包',
|
||||
description: '发送流量包到邮箱/飞书/微信',
|
||||
category: 'package',
|
||||
auth: true,
|
||||
params: [
|
||||
{ name: 'packageId', type: 'string', required: true, desc: '流量包ID' },
|
||||
{ name: 'targets', type: 'array', required: true, desc: '发送目标' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function APIServicePage() {
|
||||
const [activeCategory, setActiveCategory] = useState('all')
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState('')
|
||||
const [showKeyDialog, setShowKeyDialog] = useState(false)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const host = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
setApiBaseUrl(host)
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
const getMethodColor = (method: string) => {
|
||||
switch (method) {
|
||||
case 'GET': return 'bg-green-100 text-green-700'
|
||||
case 'POST': return 'bg-blue-100 text-blue-700'
|
||||
case 'PUT': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'DELETE': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEndpoints = activeCategory === 'all'
|
||||
? API_ENDPOINTS
|
||||
: API_ENDPOINTS.filter(e => e.category === activeCategory)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">API服务</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">神射手开放API,支持第三方系统集成</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={() => setShowKeyDialog(true)}>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
API密钥
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
API文档
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API基础信息 */}
|
||||
<Card className="border-0 shadow-sm bg-gradient-to-r from-purple-50 to-blue-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">API基础地址</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-white px-4 py-2 rounded-lg text-purple-600 font-mono">
|
||||
{apiBaseUrl || 'https://your-domain.com'}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(apiBaseUrl, 'base')}
|
||||
>
|
||||
{copiedId === 'base' ? <CheckCircle2 className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500">API版本</div>
|
||||
<Badge>v1.0</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 认证说明 */}
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">认证方式</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-4 rounded-lg bg-gray-50">
|
||||
<p className="text-sm text-gray-600 mb-3">所有API请求需要在Header中携带API密钥:</p>
|
||||
<pre className="bg-gray-900 text-green-400 p-4 rounded-lg text-sm overflow-x-auto">
|
||||
{`curl -X GET "${apiBaseUrl}/api/shensheshou/user?phone=13800138000" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json"`}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API分类筛选 */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<Button
|
||||
variant={activeCategory === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveCategory('all')}
|
||||
>
|
||||
全部 ({API_ENDPOINTS.length})
|
||||
</Button>
|
||||
{API_CATEGORIES.map(cat => {
|
||||
const count = API_ENDPOINTS.filter(e => e.category === cat.id).length
|
||||
const Icon = cat.icon
|
||||
return (
|
||||
<Button
|
||||
key={cat.id}
|
||||
variant={activeCategory === cat.id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-1" />
|
||||
{cat.name} ({count})
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* API端点列表 */}
|
||||
<div className="space-y-3">
|
||||
{filteredEndpoints.map(endpoint => (
|
||||
<Card key={endpoint.id} className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={`${getMethodColor(endpoint.method)} font-mono`}>
|
||||
{endpoint.method}
|
||||
</Badge>
|
||||
<code className="text-sm font-mono text-gray-700">{endpoint.path}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(`${apiBaseUrl}${endpoint.path}`, endpoint.id)}
|
||||
>
|
||||
{copiedId === endpoint.id ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{endpoint.auth && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
需认证
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{endpoint.name}</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">{endpoint.description}</p>
|
||||
|
||||
{/* 参数 */}
|
||||
{endpoint.params && endpoint.params.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-xs font-medium text-gray-500 mb-2">参数</h4>
|
||||
<div className="space-y-1">
|
||||
{endpoint.params.map((param, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<code className="bg-gray-100 px-2 py-0.5 rounded text-purple-600">{param.name}</code>
|
||||
<Badge variant="outline" className="text-xs">{param.type}</Badge>
|
||||
{param.required && <Badge className="bg-red-100 text-red-700 text-xs">必填</Badge>}
|
||||
<span className="text-gray-500">{param.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 响应示例 */}
|
||||
{endpoint.response && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 mb-2">响应示例</h4>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs font-mono text-gray-700 overflow-x-auto">
|
||||
{endpoint.response}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* API密钥弹窗 */}
|
||||
<Dialog open={showKeyDialog} onOpenChange={setShowKeyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API密钥管理</DialogTitle>
|
||||
<DialogDescription>用于第三方系统调用神射手API</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>当前API密钥</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value="sk-archer-xxxxxxxxxxxxx" readOnly className="font-mono" />
|
||||
<Button variant="outline" size="icon">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">密钥创建于 2024-01-15,永不过期</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-yellow-50 text-sm text-yellow-700">
|
||||
⚠️ 请妥善保管API密钥,不要在客户端代码中暴露
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowKeyDialog(false)}>关闭</Button>
|
||||
<Button variant="destructive">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
重新生成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1460
app/data-market/open-api/page.tsx
Normal file
1460
app/data-market/open-api/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
522
app/data-market/packages/page.tsx
Normal file
522
app/data-market/packages/page.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "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 { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
Download,
|
||||
Send,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Users,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Target,
|
||||
Loader2,
|
||||
Settings,
|
||||
Trash2,
|
||||
Copy,
|
||||
} from "lucide-react"
|
||||
|
||||
// 流量包接口
|
||||
interface TrafficPackage {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
pool: string
|
||||
userCount: number
|
||||
criteria: string[]
|
||||
createdAt: string
|
||||
status: 'active' | 'expired' | 'processing'
|
||||
downloadCount: number
|
||||
lastSentAt?: string
|
||||
sentTo?: string[]
|
||||
}
|
||||
|
||||
// 发送目标
|
||||
interface SendTarget {
|
||||
type: 'email' | 'wechat' | 'feishu'
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// 预定义流量包
|
||||
const DEFAULT_PACKAGES: TrafficPackage[] = [
|
||||
{
|
||||
id: 'pkg_1',
|
||||
name: '钻石用户包-2024Q1',
|
||||
description: 'RFM≥90的高价值用户,适合高端产品推广',
|
||||
pool: 'diamond',
|
||||
userCount: 12500000,
|
||||
criteria: ['RFM≥90', '月活跃≥20天', '消费≥10次'],
|
||||
createdAt: '2024-01-15',
|
||||
status: 'active',
|
||||
downloadCount: 15,
|
||||
lastSentAt: '2024-01-28',
|
||||
sentTo: ['marketing@company.com', '飞书-营销群'],
|
||||
},
|
||||
{
|
||||
id: 'pkg_2',
|
||||
name: '厦门本地高价值用户',
|
||||
description: '厦门地区A级以上用户',
|
||||
pool: 'gold',
|
||||
userCount: 850000,
|
||||
criteria: ['地区=厦门', 'RFM≥80'],
|
||||
createdAt: '2024-01-20',
|
||||
status: 'active',
|
||||
downloadCount: 8,
|
||||
},
|
||||
{
|
||||
id: 'pkg_3',
|
||||
name: '电商活跃用户包',
|
||||
description: '京东、淘宝高频购买用户',
|
||||
pool: 'silver',
|
||||
userCount: 45000000,
|
||||
criteria: ['电商消费≥5次/月', '客单价≥200'],
|
||||
createdAt: '2024-01-22',
|
||||
status: 'active',
|
||||
downloadCount: 22,
|
||||
lastSentAt: '2024-01-29',
|
||||
sentTo: ['sales@company.com'],
|
||||
},
|
||||
{
|
||||
id: 'pkg_4',
|
||||
name: '沉默用户唤醒包',
|
||||
description: '30天未活跃但曾有高价值行为的用户',
|
||||
pool: 'potential',
|
||||
userCount: 28000000,
|
||||
criteria: ['30天未活跃', '历史RFM≥60'],
|
||||
createdAt: '2024-01-25',
|
||||
status: 'active',
|
||||
downloadCount: 5,
|
||||
},
|
||||
]
|
||||
|
||||
// 预定义发送目标
|
||||
const SAVED_TARGETS: SendTarget[] = [
|
||||
{ type: 'email', value: 'marketing@company.com', name: '营销部邮箱' },
|
||||
{ type: 'email', value: 'sales@company.com', name: '销售部邮箱' },
|
||||
{ type: 'feishu', value: 'oc_xxx', name: '飞书-营销群' },
|
||||
{ type: 'feishu', value: 'oc_yyy', name: '飞书-运营群' },
|
||||
{ type: 'wechat', value: 'wxid_xxx', name: '企微-客户群' },
|
||||
]
|
||||
|
||||
export default function PackagesPage() {
|
||||
const [packages, setPackages] = useState<TrafficPackage[]>(DEFAULT_PACKAGES)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showSendDialog, setShowSendDialog] = useState(false)
|
||||
const [selectedPackage, setSelectedPackage] = useState<TrafficPackage | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [selectedTargets, setSelectedTargets] = useState<string[]>([])
|
||||
const [customEmail, setCustomEmail] = useState('')
|
||||
|
||||
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 getPoolConfig = (pool: string) => {
|
||||
switch (pool) {
|
||||
case 'diamond': return { name: '钻石池', icon: '💎', color: 'from-blue-500 to-purple-600' }
|
||||
case 'gold': return { name: '黄金池', icon: '🏆', color: 'from-yellow-400 to-orange-500' }
|
||||
case 'silver': return { name: '白银池', icon: '🥈', color: 'from-gray-300 to-gray-500' }
|
||||
case 'bronze': return { name: '青铜池', icon: '🥉', color: 'from-orange-300 to-orange-500' }
|
||||
default: return { name: '潜力池', icon: '🌱', color: 'from-green-300 to-green-500' }
|
||||
}
|
||||
}
|
||||
|
||||
const getTargetIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'email': return <Mail className="h-4 w-4" />
|
||||
case 'feishu': return <span className="text-sm">🪶</span>
|
||||
case 'wechat': return <MessageSquare className="h-4 w-4" />
|
||||
default: return <Send className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTarget = (value: string) => {
|
||||
setSelectedTargets(prev =>
|
||||
prev.includes(value)
|
||||
? prev.filter(t => t !== value)
|
||||
: [...prev, value]
|
||||
)
|
||||
}
|
||||
|
||||
const openSendDialog = (pkg: TrafficPackage) => {
|
||||
setSelectedPackage(pkg)
|
||||
setSelectedTargets([])
|
||||
setCustomEmail('')
|
||||
setShowSendDialog(true)
|
||||
}
|
||||
|
||||
// 下载流量包为CSV
|
||||
const downloadPackage = async (pkg: TrafficPackage) => {
|
||||
try {
|
||||
// 从API获取用户数据
|
||||
const res = await fetch(`/api/traffic-packages?action=export&packageId=${pkg.id}`)
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success && data.users) {
|
||||
// 生成CSV内容
|
||||
const headers = ['手机号', '姓名', '等级', '估值分', '标签']
|
||||
const rows = data.users.map((u: any) => [
|
||||
u.phone || '',
|
||||
u.name || '',
|
||||
u.level || '',
|
||||
u.score || '',
|
||||
(u.tags || []).join(';')
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map((r: string[]) => r.join(','))
|
||||
].join('\n')
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${pkg.name}_${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
// 更新下载次数
|
||||
setPackages(prev => prev.map(p =>
|
||||
p.id === pkg.id ? { ...p, downloadCount: p.downloadCount + 1 } : p
|
||||
))
|
||||
} else {
|
||||
alert('导出失败:' + (data.error || '未知错误'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
alert('下载失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const sendPackage = async () => {
|
||||
if (!selectedPackage) return
|
||||
setSending(true)
|
||||
|
||||
try {
|
||||
// 调用API发送流量包
|
||||
const targets = [...selectedTargets]
|
||||
if (customEmail) targets.push(customEmail)
|
||||
|
||||
await fetch('/api/traffic-packages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'send',
|
||||
packageId: selectedPackage.id,
|
||||
targets,
|
||||
})
|
||||
})
|
||||
|
||||
// 更新包状态
|
||||
setPackages(packages.map(p => p.id === selectedPackage.id ? {
|
||||
...p,
|
||||
lastSentAt: new Date().toLocaleString(),
|
||||
sentTo: targets,
|
||||
} : p))
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
} finally {
|
||||
setSending(false)
|
||||
setShowSendDialog(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">流量包</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">管理和分发用户流量包</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建流量包
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">流量包总数</p>
|
||||
<p className="text-2xl font-bold">{packages.length}</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-purple-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">覆盖用户</p>
|
||||
<p className="text-2xl font-bold">{formatNumber(packages.reduce((sum, p) => sum + p.userCount, 0))}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">总下载次数</p>
|
||||
<p className="text-2xl font-bold">{packages.reduce((sum, p) => sum + p.downloadCount, 0)}</p>
|
||||
</div>
|
||||
<Download className="h-8 w-8 text-green-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">本周发送</p>
|
||||
<p className="text-2xl font-bold">12</p>
|
||||
</div>
|
||||
<Send className="h-8 w-8 text-orange-500 opacity-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 流量包列表 */}
|
||||
<div className="space-y-3">
|
||||
{packages.map(pkg => {
|
||||
const poolConfig = getPoolConfig(pkg.pool)
|
||||
|
||||
return (
|
||||
<Card key={pkg.id} className="border-0 shadow-sm bg-white/80 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-r ${poolConfig.color} flex items-center justify-center text-2xl`}>
|
||||
{poolConfig.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{pkg.name}</h3>
|
||||
<Badge className="bg-green-100 text-green-700">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />可用
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-2">{pkg.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{pkg.criteria.map((c, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">{c}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{formatNumber(pkg.userCount)} 用户
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
创建于 {pkg.createdAt}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3" />
|
||||
下载 {pkg.downloadCount} 次
|
||||
</span>
|
||||
{pkg.lastSentAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Send className="h-3 w-3" />
|
||||
上次发送: {pkg.lastSentAt}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => downloadPackage(pkg)}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
下载
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => openSendDialog(pkg)}>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
发送
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 发送流量包弹窗 */}
|
||||
<Dialog open={showSendDialog} onOpenChange={setShowSendDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送流量包</DialogTitle>
|
||||
<DialogDescription>选择发送目标,支持邮箱、微信群、飞书群</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedPackage && (
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 流量包信息 */}
|
||||
<div className="p-3 rounded-lg bg-purple-50">
|
||||
<div className="font-medium text-gray-900">{selectedPackage.name}</div>
|
||||
<div className="text-sm text-gray-500">{formatNumber(selectedPackage.userCount)} 用户</div>
|
||||
</div>
|
||||
|
||||
{/* 已保存的目标 */}
|
||||
<div className="space-y-2">
|
||||
<Label>选择发送目标</Label>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{SAVED_TARGETS.map(target => (
|
||||
<div
|
||||
key={target.value}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all ${
|
||||
selectedTargets.includes(target.value)
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => toggleTarget(target.value)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getTargetIcon(target.type)}
|
||||
<div>
|
||||
<div className="font-medium text-sm">{target.name}</div>
|
||||
<div className="text-xs text-gray-500">{target.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox checked={selectedTargets.includes(target.value)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义邮箱 */}
|
||||
<div className="space-y-2">
|
||||
<Label>或输入自定义邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="example@email.com"
|
||||
value={customEmail}
|
||||
onChange={(e) => setCustomEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 发送说明 */}
|
||||
<div className="p-3 rounded-lg bg-blue-50 text-sm text-blue-700">
|
||||
<p>📌 发送说明:</p>
|
||||
<ul className="list-disc list-inside text-xs mt-1 space-y-1">
|
||||
<li>邮箱发送:将生成CSV附件发送到指定邮箱</li>
|
||||
<li>飞书群:通过飞书机器人发送流量包链接</li>
|
||||
<li>微信群:通过企微机器人发送流量包链接</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSendDialog(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={sendPackage}
|
||||
disabled={sending || (selectedTargets.length === 0 && !customEmail)}
|
||||
>
|
||||
{sending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin mr-2" />发送中...</>
|
||||
) : (
|
||||
<><Send className="h-4 w-4 mr-2" />确认发送</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 创建流量包弹窗 */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建流量包</DialogTitle>
|
||||
<DialogDescription>从人群圈选创建流量包</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>流量包名称</Label>
|
||||
<Input placeholder="例如:高价值用户包-2024Q1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>来源</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="crowd">从人群圈选导入</SelectItem>
|
||||
<SelectItem value="pool">从流量池导入</SelectItem>
|
||||
<SelectItem value="tag">从标签筛选导入</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea placeholder="描述该流量包的用途..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>取消</Button>
|
||||
<Button onClick={() => {
|
||||
setShowCreateDialog(false)
|
||||
// 跳转到人群圈选页面
|
||||
window.location.href = '/tag-portrait/crowd'
|
||||
}}>
|
||||
<Target className="h-4 w-4 mr-2" />
|
||||
去圈选
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
326
app/data-market/page.tsx
Normal file
326
app/data-market/page.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "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 Link from "next/link"
|
||||
import {
|
||||
Package,
|
||||
Server,
|
||||
Webhook,
|
||||
ArrowUpRight,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Zap,
|
||||
DollarSign,
|
||||
Loader2,
|
||||
Diamond,
|
||||
Award,
|
||||
Medal,
|
||||
Shield,
|
||||
Target,
|
||||
BarChart3,
|
||||
Globe,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
} from "lucide-react"
|
||||
|
||||
export default function DataMarketPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pools, setPools] = useState<any[]>([])
|
||||
const [stats, setStats] = useState({
|
||||
totalPackages: 0,
|
||||
totalApis: 8,
|
||||
totalSubscriptions: 45,
|
||||
monthlyRevenue: 125000,
|
||||
apiCalls: 8956234
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/traffic-packages?action=pools')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setPools(data.pools)
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalPackages: data.pools.length
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 流量池图标映射
|
||||
const poolIcons: Record<string, any> = {
|
||||
diamond: Diamond,
|
||||
gold: Award,
|
||||
silver: Medal,
|
||||
bronze: Shield,
|
||||
potential: Target
|
||||
}
|
||||
|
||||
// 流量池颜色映射
|
||||
const poolColors: Record<string, string> = {
|
||||
diamond: 'from-purple-500 to-pink-500',
|
||||
gold: 'from-yellow-500 to-orange-500',
|
||||
silver: 'from-gray-400 to-gray-500',
|
||||
bronze: 'from-orange-400 to-orange-600',
|
||||
potential: 'from-blue-400 to-cyan-500'
|
||||
}
|
||||
|
||||
const modules = [
|
||||
{
|
||||
title: "流量包",
|
||||
icon: Package,
|
||||
color: "text-purple-600",
|
||||
bg: "bg-purple-100",
|
||||
href: "/data-market/packages",
|
||||
desc: "用户人群包创建与导出,按流量池分层管理",
|
||||
stats: `${pools.length}个流量池`,
|
||||
},
|
||||
{
|
||||
title: "API市场",
|
||||
icon: Server,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-100",
|
||||
href: "/data-market/api",
|
||||
desc: "开放API接口服务,支持用户查询、画像等",
|
||||
stats: `${stats.totalApis}个API`,
|
||||
},
|
||||
{
|
||||
title: "开放接口",
|
||||
icon: Globe,
|
||||
color: "text-orange-600",
|
||||
bg: "bg-orange-100",
|
||||
href: "/data-market/open-api",
|
||||
desc: "第三方系统对接,存客宝/点了码数据流入流出",
|
||||
stats: "3个接入方",
|
||||
},
|
||||
{
|
||||
title: "数据订阅",
|
||||
icon: Webhook,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-100",
|
||||
href: "/data-market/subscription",
|
||||
desc: "实时数据推送服务,支持Webhook订阅",
|
||||
stats: `${stats.totalSubscriptions}个订阅`,
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<span className="ml-3 text-gray-500">加载数据市场...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据市场</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
用户数据资产变现与开放服务平台
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">流量池</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{pools.length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<Package className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">API接口</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalApis}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<Server className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">API调用</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatNumber(stats.apiCalls)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">月收入</p>
|
||||
<p className="text-2xl font-bold text-gray-900">¥{formatNumber(stats.monthlyRevenue)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-100 flex items-center justify-center">
|
||||
<DollarSign className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 功能模块 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{modules.map((module, index) => (
|
||||
<Link key={index} href={module.href}>
|
||||
<Card className="h-full cursor-pointer hover:shadow-lg transition-all duration-300 border-none shadow-sm bg-white/70 backdrop-blur group hover:-translate-y-1">
|
||||
<CardContent className="p-6">
|
||||
<div className={`w-14 h-14 rounded-2xl ${module.bg} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
|
||||
<module.icon className={`w-7 h-7 ${module.color}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">{module.title}</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{module.desc}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary">{module.stats}</Badge>
|
||||
<ArrowUpRight className="w-5 h-5 text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 流量池资产 */}
|
||||
<Card className="border-none shadow-lg bg-white/80 backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-purple-500" />
|
||||
可变现数据资产
|
||||
<Badge variant="outline" className="ml-2">按价值分层</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{pools.map((pool) => {
|
||||
const PoolIcon = poolIcons[pool.id] || Package
|
||||
const color = poolColors[pool.id] || 'from-gray-400 to-gray-500'
|
||||
|
||||
return (
|
||||
<Link key={pool.id} href={`/data-market/packages?pool=${pool.id}`}>
|
||||
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 overflow-hidden group">
|
||||
<div className={`h-2 bg-gradient-to-r ${color}`} />
|
||||
<CardContent className="p-4">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-r ${color} flex items-center justify-center mb-3 group-hover:scale-110 transition-transform`}>
|
||||
<PoolIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1">{pool.name}</h3>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="text-xl font-bold text-gray-900">
|
||||
{formatNumber(pool.count || 0)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">用户</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 数据变现趋势 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="border-none shadow-md bg-white/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
API调用趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ name: '用户画像API', calls: 3500000, growth: 12.5 },
|
||||
{ name: '智能搜索API', calls: 2800000, growth: 8.3 },
|
||||
{ name: '人群圈选API', calls: 1500000, growth: 25.6 },
|
||||
{ name: 'RFM评分API', calls: 1156234, growth: 15.2 },
|
||||
].map((api, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{api.name}</div>
|
||||
<div className="text-sm text-gray-500">{formatNumber(api.calls)} 次调用</div>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-700">
|
||||
+{api.growth}%
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-md bg-white/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-blue-500" />
|
||||
数据订阅分布
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: '用户行为数据', percentage: 35 },
|
||||
{ name: '交易数据', percentage: 28 },
|
||||
{ name: '标签更新', percentage: 22 },
|
||||
{ name: '用户画像变更', percentage: 15 },
|
||||
].map((sub, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-700">{sub.name}</span>
|
||||
<span className="text-sm font-medium">{sub.percentage}%</span>
|
||||
</div>
|
||||
<Progress value={sub.percentage} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,219 +2,216 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { ArrowLeft, Server, CheckCircle2, AlertTriangle, RefreshCw, Clock } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { Activity, Database, Server, Cpu, HardDrive, Clock, RefreshCw, CheckCircle, XCircle, AlertTriangle } from "lucide-react"
|
||||
|
||||
const performanceData = [
|
||||
{ time: "5m", p50: 32, p95: 85, p99: 120 },
|
||||
{ time: "10m", p50: 35, p95: 88, p99: 125 },
|
||||
{ time: "15m", p50: 30, p95: 82, p99: 115 },
|
||||
{ time: "20m", p50: 38, p95: 92, p99: 135 },
|
||||
{ time: "25m", p50: 33, p95: 86, p99: 122 },
|
||||
{ time: "30m", p50: 31, p95: 84, p99: 118 },
|
||||
]
|
||||
interface DatabaseStatus {
|
||||
name: string
|
||||
connected: boolean
|
||||
latency: number
|
||||
documents: number
|
||||
size: string
|
||||
}
|
||||
|
||||
const healthChecks = [
|
||||
{ name: "数据库连接池", status: "healthy", value: "48/50", description: "活跃连接数" },
|
||||
{ name: "Redis缓存", status: "healthy", value: "99.9%", description: "命中率" },
|
||||
{ name: "消息队列", status: "healthy", value: "0", description: "积压消息" },
|
||||
{ name: "定时任务", status: "healthy", value: "12/12", description: "运行中任务" },
|
||||
{ name: "外部API", status: "degraded", value: "95.2%", description: "成功率" },
|
||||
{ name: "文件存储", status: "healthy", value: "1.6TB", description: "已使用" },
|
||||
]
|
||||
interface SystemHealth {
|
||||
mongodb: {
|
||||
connected: boolean
|
||||
totalUsers: number
|
||||
latency: number
|
||||
databases: string[]
|
||||
}
|
||||
api: {
|
||||
status: string
|
||||
uptime: number
|
||||
requests24h: number
|
||||
}
|
||||
}
|
||||
|
||||
const nodeStatus = [
|
||||
{ id: 1, name: "node-01", role: "Master", cpu: 62, memory: 68, status: "healthy" },
|
||||
{ id: 2, name: "node-02", role: "Worker", cpu: 55, memory: 72, status: "healthy" },
|
||||
{ id: 3, name: "node-03", role: "Worker", cpu: 48, memory: 65, status: "healthy" },
|
||||
{ id: 4, name: "node-04", role: "Worker", cpu: 71, memory: 78, status: "degraded" },
|
||||
]
|
||||
export default function SystemHealthPage() {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date())
|
||||
const [health, setHealth] = useState<SystemHealth | null>(null)
|
||||
|
||||
export default function HealthPage() {
|
||||
const [overallHealth, setOverallHealth] = useState(98.5)
|
||||
const [databases] = useState<DatabaseStatus[]>([
|
||||
{ name: "KR", connected: true, latency: 15, documents: 14360000, size: "16.3 GB" },
|
||||
{ name: "KR_腾讯", connected: true, latency: 18, documents: 705000000, size: "117.1 GB" },
|
||||
{ name: "KR_京东", connected: true, latency: 22, documents: 142000000, size: "31.0 GB" },
|
||||
{ name: "KR_微博", connected: true, latency: 20, documents: 217000000, size: "12.8 GB" },
|
||||
{ name: "KR_酒店", connected: true, latency: 25, documents: 96670000, size: "114.5 GB" },
|
||||
{ name: "KR_顺丰", connected: true, latency: 19, documents: 39200000, size: "12.8 GB" },
|
||||
{ name: "KR_存客宝", connected: true, latency: 12, documents: 216426, size: "0.5 GB" },
|
||||
{ name: "KR_户口", connected: true, latency: 16, documents: 9550000, size: "2.1 GB" },
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setOverallHealth((prev) => Math.min(100, Math.max(95, prev + (Math.random() - 0.5) * 1)))
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
fetchHealth()
|
||||
}, [])
|
||||
|
||||
const fetchHealth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ai-chat")
|
||||
const data = await res.json()
|
||||
setHealth({
|
||||
mongodb: {
|
||||
connected: data.database?.connected || false,
|
||||
totalUsers: data.database?.totalUsers || 0,
|
||||
latency: data.database?.latency || 0,
|
||||
databases: ["KR", "KR_腾讯", "KR_京东", "KR_微博", "KR_存客宝"],
|
||||
},
|
||||
api: {
|
||||
status: data.status || "unknown",
|
||||
uptime: 99.9,
|
||||
requests24h: 125680,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch health:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
await fetchHealth()
|
||||
setLastUpdate(new Date())
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
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(1)}K`
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-emerald-50/30 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/monitoring">
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">系统健康度</h1>
|
||||
<p className="text-slate-500 mt-1">服务状态、性能指标与集群监控</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">系统健康</h1>
|
||||
<p className="text-gray-500 mt-1">MongoDB数据库与API服务状态监控</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400">更新于 {lastUpdate.toLocaleTimeString()}</span>
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 整体健康度 */}
|
||||
<Card className="bg-gradient-to-r from-emerald-500 to-teal-600 border-0">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-white">
|
||||
<p className="text-sm opacity-80">系统整体健康度</p>
|
||||
<p className="text-5xl font-bold mt-2">{overallHealth.toFixed(1)}%</p>
|
||||
<p className="text-sm opacity-80 mt-2">所有核心服务运行正常</p>
|
||||
{/* Overall Status */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border-none shadow-sm bg-white/60">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${health?.mongodb.connected ? 'bg-green-100' : 'bg-red-100'} flex items-center justify-center`}>
|
||||
<Database className={`w-5 h-5 ${health?.mongodb.connected ? 'text-green-600' : 'text-red-600'}`} />
|
||||
</div>
|
||||
<div className="w-32 h-32 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-16 h-16 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 健康检查项 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{healthChecks.map((check, i) => (
|
||||
<Card key={i} className="bg-white/70 backdrop-blur border-slate-200/60">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{check.name}</p>
|
||||
<p className="text-2xl font-bold text-slate-800 mt-1">{check.value}</p>
|
||||
<p className="text-xs text-slate-500">{check.description}</p>
|
||||
</div>
|
||||
{check.status === "healthy" ? (
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-gray-900">MongoDB</span>
|
||||
{health?.mongodb.connected ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-8 h-8 text-amber-500" />
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 响应时间分布 */}
|
||||
<Card className="bg-white/70 backdrop-blur border-slate-200/60">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-slate-500" />
|
||||
API响应时间分布 (最近30分钟)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={performanceData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" tickFormatter={(v) => `${v}ms`} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
formatter={(value: number) => [`${value}ms`]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="p50" stroke="#10b981" strokeWidth={2} name="P50" />
|
||||
<Line type="monotone" dataKey="p95" stroke="#f59e0b" strokeWidth={2} name="P95" />
|
||||
<Line type="monotone" dataKey="p99" stroke="#ef4444" strokeWidth={2} name="P99" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||
<span className="text-xs text-slate-600">P50: 32ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||
<span className="text-xs text-slate-600">P95: 85ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-xs text-slate-600">P99: 120ms</span>
|
||||
<div className="text-xs text-gray-500">{health?.mongodb.latency || '--'}ms 延迟</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 集群节点状态 */}
|
||||
<Card className="bg-white/70 backdrop-blur border-slate-200/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-slate-500" />
|
||||
集群节点状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-slate-500">节点</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-slate-500">角色</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-slate-500">CPU</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-slate-500">内存</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-medium text-slate-500">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodeStatus.map((node) => (
|
||||
<tr key={node.id} className="border-b border-slate-100 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm font-medium text-slate-800">{node.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="outline">{node.role}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={node.cpu} className="w-20 h-2" />
|
||||
<span className="text-sm text-slate-600">{node.cpu}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={node.memory} className="w-20 h-2" />
|
||||
<span className="text-sm text-slate-600">{node.memory}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{node.status === "healthy" ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-100 text-amber-700">
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
降级
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Card className="border-none shadow-sm bg-white/60">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${health?.api.status === 'online' ? 'bg-green-100' : 'bg-yellow-100'} flex items-center justify-center`}>
|
||||
<Server className={`w-5 h-5 ${health?.api.status === 'online' ? 'text-green-600' : 'text-yellow-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-gray-900">API服务</span>
|
||||
{health?.api.status === 'online' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{health?.api.uptime || '--'}% 可用</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm bg-white/60">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<HardDrive className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">{formatNumber(health?.mongodb.totalUsers || 0)}</div>
|
||||
<div className="text-xs text-gray-500">总文档数</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm bg-white/60">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<Activity className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">{formatNumber(health?.api.requests24h || 0)}</div>
|
||||
<div className="text-xs text-gray-500">24h请求</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Database Details */}
|
||||
<Card className="border-none shadow-md bg-white/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-700 flex items-center gap-2">
|
||||
<Database className="w-5 h-5" />
|
||||
数据库详情
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{databases.map((db, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${db.connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{db.name}</div>
|
||||
<div className="text-xs text-gray-500">{db.size}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">{formatNumber(db.documents)}</div>
|
||||
<div className="text-xs text-gray-500">文档</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">{db.latency}ms</div>
|
||||
<div className="text-xs text-gray-500">延迟</div>
|
||||
</div>
|
||||
<Badge className={db.connected ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}>
|
||||
{db.connected ? '正常' : '异常'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,19 +47,95 @@ const serviceStatus = [
|
||||
{ name: "缓存服务", status: "healthy", uptime: "99.99%", latency: "2ms" },
|
||||
]
|
||||
|
||||
// 服务状态接口
|
||||
interface ServiceStatus {
|
||||
name: string
|
||||
status: 'healthy' | 'degraded' | 'unhealthy'
|
||||
latency: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// 告警接口
|
||||
interface Alert {
|
||||
id: string
|
||||
type: 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
time: string
|
||||
status: 'active' | 'resolved'
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [cpuUsage, setCpuUsage] = useState(65)
|
||||
const [memoryUsage, setMemoryUsage] = useState(68)
|
||||
const [diskUsage, setDiskUsage] = useState(80)
|
||||
const [networkIO, setNetworkIO] = useState(256)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [services, setServices] = useState<ServiceStatus[]>(serviceStatus.map(s => ({
|
||||
...s,
|
||||
status: s.status as 'healthy' | 'degraded' | 'unhealthy',
|
||||
message: ''
|
||||
})))
|
||||
const [alerts, setAlerts] = useState<Alert[]>(recentAlerts.map(a => ({
|
||||
...a,
|
||||
type: a.type as 'info' | 'warning' | 'error',
|
||||
status: a.status as 'active' | 'resolved'
|
||||
})))
|
||||
const [dbInfo, setDbInfo] = useState<any>(null)
|
||||
|
||||
// 获取真实监控数据
|
||||
const fetchMonitoringData = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/monitoring')
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
// 更新服务状态
|
||||
if (data.health?.services) {
|
||||
setServices(data.health.services.map((s: any) => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
latency: s.latency,
|
||||
uptime: s.status === 'healthy' ? '99.99%' : '99.5%',
|
||||
message: s.message
|
||||
})))
|
||||
}
|
||||
|
||||
// 更新告警
|
||||
if (data.alerts?.alerts) {
|
||||
setAlerts(data.alerts.alerts)
|
||||
}
|
||||
|
||||
// 更新数据库信息
|
||||
if (data.database?.server) {
|
||||
setDbInfo(data.database)
|
||||
// 根据MongoDB内存使用更新内存指标
|
||||
const memMB = data.database.server.memory?.resident || 0
|
||||
setMemoryUsage(Math.min(90, (memMB / 8000) * 100))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取监控数据失败:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonitoringData()
|
||||
|
||||
// 模拟CPU和网络波动
|
||||
const interval = setInterval(() => {
|
||||
setCpuUsage((prev) => Math.min(95, Math.max(30, prev + (Math.random() - 0.5) * 10)))
|
||||
setMemoryUsage((prev) => Math.min(90, Math.max(50, prev + (Math.random() - 0.5) * 5)))
|
||||
setNetworkIO((prev) => Math.min(500, Math.max(100, prev + (Math.random() - 0.5) * 50)))
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
|
||||
// 每30秒刷新真实数据
|
||||
const refreshInterval = setInterval(fetchMonitoringData, 30000)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
714
app/page.tsx
714
app/page.tsx
@@ -1,396 +1,478 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import {
|
||||
Users,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
Globe,
|
||||
Brain,
|
||||
Tags,
|
||||
LineChart,
|
||||
Package,
|
||||
ArrowUpRight,
|
||||
Zap,
|
||||
Send,
|
||||
Loader2,
|
||||
Search,
|
||||
Phone,
|
||||
User,
|
||||
TrendingUp,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Target,
|
||||
CheckCircle,
|
||||
} 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 { useRouter } from "next/navigation"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import Link from "next/link"
|
||||
|
||||
interface SystemMetrics {
|
||||
dataTaskSuccessRate: number
|
||||
tagCoverageRate: number
|
||||
valuePredictionAccuracy: number
|
||||
apiDailyCallGrowth: number
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "thinking"
|
||||
content: string
|
||||
timestamp: string
|
||||
data?: any
|
||||
thinking?: string // AI思考过程
|
||||
portrait?: UserPortrait // 用户画像
|
||||
}
|
||||
|
||||
interface DataFlowStats {
|
||||
totalUsers: number
|
||||
totalProjects: number
|
||||
totalDataSources: number
|
||||
totalTags: number
|
||||
totalModels: number
|
||||
totalApiCalls: number
|
||||
// 标准用户画像模板
|
||||
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
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const router = useRouter()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date())
|
||||
interface AIStatus {
|
||||
status: string
|
||||
database?: {
|
||||
connected: boolean
|
||||
totalUsers: number
|
||||
latency: number
|
||||
}
|
||||
}
|
||||
|
||||
// 核心OKR指标 (按PRD定义)
|
||||
const [metrics, setMetrics] = useState<SystemMetrics>({
|
||||
dataTaskSuccessRate: 99.7,
|
||||
tagCoverageRate: 82.3,
|
||||
valuePredictionAccuracy: 87.5,
|
||||
apiDailyCallGrowth: 32.8,
|
||||
})
|
||||
|
||||
// 数据流统计
|
||||
const [stats, setStats] = useState<DataFlowStats>({
|
||||
totalUsers: 4028567890,
|
||||
totalProjects: 1256,
|
||||
totalDataSources: 28,
|
||||
totalTags: 1892,
|
||||
totalModels: 15,
|
||||
totalApiCalls: 8956234,
|
||||
})
|
||||
|
||||
// 实时数据流
|
||||
const [dataFlows, setDataFlows] = useState([
|
||||
{ source: "存客宝", type: "用户行为", count: 125680, status: "running" },
|
||||
{ source: "触客宝", type: "互动数据", count: 89234, status: "running" },
|
||||
{ source: "数智员工", type: "账号数据", count: 45678, status: "running" },
|
||||
{ source: "外部API", type: "征信数据", count: 12890, status: "waiting" },
|
||||
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你可以:\n• 输入手机号查询用户画像\n• 输入QQ号查询关联信息\n• 问我任何关于用户数据的问题",
|
||||
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(() => {
|
||||
const interval = setInterval(() => {
|
||||
// 模拟实时数据更新
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
totalUsers: prev.totalUsers + Math.floor(Math.random() * 1000),
|
||||
totalApiCalls: prev.totalApiCalls + Math.floor(Math.random() * 100),
|
||||
}))
|
||||
setDataFlows((prev) =>
|
||||
prev.map((flow) => ({
|
||||
...flow,
|
||||
count: flow.count + Math.floor(Math.random() * 100),
|
||||
})),
|
||||
)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
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)
|
||||
}, [])
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
setLastUpdate(new Date())
|
||||
setIsRefreshing(false)
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
// 解析用户画像数据
|
||||
const parsePortrait = (data: any): UserPortrait | undefined => {
|
||||
if (!data) return undefined
|
||||
return {
|
||||
phone: data.phone || data.手机号,
|
||||
qq: data.qq || data.QQ,
|
||||
name: data.name || data.姓名,
|
||||
gender: data.gender || data.性别,
|
||||
province: data.province || data.省份,
|
||||
city: data.city || data.城市,
|
||||
level: data.level || data.user_level || data.等级,
|
||||
rfmScore: data.rfm_score || data.rfmScore || data.RFM评分,
|
||||
tags: data.tags || [],
|
||||
behavior: {
|
||||
lastActive: data.last_active || data.最近活跃,
|
||||
frequency: data.frequency || data.活跃频率,
|
||||
},
|
||||
dataCompleteness: data.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_腾讯\n→ 执行: 手机号精确匹配\n→ 输出: 标准用户画像模板`
|
||||
} else if (isQQ) {
|
||||
return `🔍 识别到QQ号查询\n→ 拆解意图: QQ关联查询\n→ 数据源: KR_腾讯.qq_phone\n→ 执行: QQ号精确匹配\n→ 输出: 关联手机号 + 用户画像`
|
||||
} else {
|
||||
return `🔍 自然语言理解中...\n→ 拆解意图: ${query.slice(0, 20)}...\n→ 思考策略: 语义分析\n→ 执行: Skill查询`
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
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")
|
||||
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: data.response?.data,
|
||||
portrait,
|
||||
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(1)}K`
|
||||
return num.toString()
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 5大功能模块快捷入口(按HTML文档重构)
|
||||
// 核心功能模块
|
||||
const modules = [
|
||||
{
|
||||
title: "数据接入",
|
||||
icon: Database,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-100",
|
||||
color: "from-blue-500 to-cyan-500",
|
||||
href: "/data-ingestion/sources",
|
||||
desc: "数据源管理、清洗规则、任务调度、数据血缘",
|
||||
stats: `${stats.totalDataSources}个数据源`,
|
||||
desc: "数据源 · 清洗 · 调度",
|
||||
},
|
||||
{
|
||||
title: "标签画像",
|
||||
icon: Tags,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-100",
|
||||
href: "/tag-portrait/tags",
|
||||
desc: "标签体系、用户画像、人群圈选",
|
||||
stats: `${stats.totalTags}个标签`,
|
||||
color: "from-green-500 to-emerald-500",
|
||||
href: "/tag-portrait",
|
||||
desc: "标签 · 画像 · 流量池",
|
||||
},
|
||||
{
|
||||
title: "AI Agent",
|
||||
icon: Brain,
|
||||
color: "text-purple-600",
|
||||
bg: "bg-purple-100",
|
||||
href: "/ai-agent/chat",
|
||||
desc: "智能对话、AI打标、AI清洗、自然语言查询",
|
||||
stats: "5大AI能力",
|
||||
},
|
||||
{
|
||||
title: "数据输出",
|
||||
title: "数据市场",
|
||||
icon: Package,
|
||||
color: "text-orange-600",
|
||||
bg: "bg-orange-100",
|
||||
href: "/data-output/packages",
|
||||
desc: "流量包、API市场、数据订阅",
|
||||
stats: `${formatNumber(stats.totalApiCalls)}次调用`,
|
||||
},
|
||||
{
|
||||
title: "系统监控",
|
||||
icon: LineChart,
|
||||
color: "text-cyan-600",
|
||||
bg: "bg-cyan-100",
|
||||
href: "/system/health",
|
||||
desc: "系统健康、告警中心、操作日志",
|
||||
stats: "实时监控",
|
||||
color: "from-orange-500 to-red-500",
|
||||
href: "/data-market",
|
||||
desc: "API · 订阅 · 变现",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<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-3xl font-bold text-gray-900">数据驾驶舱</h1>
|
||||
<p className="text-gray-500 mt-1">私域银行生态数据中枢实时监控</p>
|
||||
<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-3">
|
||||
<Badge variant="outline" className="bg-green-50 text-green-600 border-green-200 px-3 py-1">
|
||||
<Activity className="w-3 h-3 mr-1" />
|
||||
系统正常
|
||||
<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>
|
||||
<span className="text-xs text-gray-400">更新于 {lastUpdate.toLocaleTimeString()}</span>
|
||||
<Button variant="outline" size="sm" onClick={refreshData} disabled={isRefreshing} className="bg-white/50">
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{/* 核心OKR指标 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">数据接入成功率</span>
|
||||
<Badge className="bg-green-100 text-green-700 text-xs">目标≥99.5%</Badge>
|
||||
{/* 统计卡片 */}
|
||||
<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>
|
||||
<div className="text-3xl font-bold text-gray-900">{metrics.dataTaskSuccessRate}%</div>
|
||||
<Progress value={metrics.dataTaskSuccessRate} className="h-2 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">标签覆盖率</span>
|
||||
<Badge className="bg-blue-100 text-blue-700 text-xs">目标≥80%</Badge>
|
||||
<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>
|
||||
<div className="text-3xl font-bold text-gray-900">{metrics.tagCoverageRate}%</div>
|
||||
<Progress value={metrics.tagCoverageRate} className="h-2 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">价值预测准确率</span>
|
||||
<Badge className="bg-purple-100 text-purple-700 text-xs">目标≥85%</Badge>
|
||||
<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>
|
||||
<div className="text-3xl font-bold text-gray-900">{metrics.valuePredictionAccuracy}%</div>
|
||||
<Progress value={metrics.valuePredictionAccuracy} className="h-2 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500">API调用增长</span>
|
||||
<Badge className="bg-orange-100 text-orange-700 text-xs">目标≥30%QoQ</Badge>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">+{metrics.apiDailyCallGrowth}%</div>
|
||||
<Progress value={metrics.apiDailyCallGrowth} className="h-2 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 用户资产总览 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 border-none shadow-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -mr-16 -mt-16 blur-3xl" />
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-blue-100">
|
||||
<Users className="w-5 h-5" />
|
||||
全平台用户资产
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="flex items-baseline gap-2 mb-4">
|
||||
<span className="text-5xl font-bold">{formatNumber(stats.totalUsers)}</span>
|
||||
<span className="text-blue-100">总用户数</span>
|
||||
{/* 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>
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t border-white/20">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-300">+2.3%</div>
|
||||
<div className="text-sm text-blue-100">日增长</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.totalProjects.toLocaleString()}</div>
|
||||
<div className="text-sm text-blue-100">项目数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">125.6B</div>
|
||||
<div className="text-sm text-blue-100">估值(元)</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 实时数据流 */}
|
||||
<Card className="border-none shadow-md bg-white/60 backdrop-blur-md">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-gray-700 text-sm font-medium flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-500" />
|
||||
实时数据流入
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{dataFlows.map((flow, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2 rounded-lg bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${flow.status === "running" ? "bg-green-500 animate-pulse" : "bg-yellow-500"}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{flow.source}</div>
|
||||
<div className="text-xs text-gray-500">{flow.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900">{flow.count.toLocaleString()}</div>
|
||||
<div className="text-xs text-gray-500">条/分钟</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 5大功能模块入口 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">功能模块</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{modules.map((module, index) => (
|
||||
<Link key={index} href={module.href}>
|
||||
<Card className="h-full cursor-pointer hover:shadow-lg transition-all duration-300 border-none shadow-sm bg-white/60 backdrop-blur-md group hover:-translate-y-1">
|
||||
<CardContent className="p-5">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-2xl ${module.bg} flex items-center justify-center mb-3 group-hover:scale-110 transition-transform`}
|
||||
>
|
||||
<module.icon className={`w-6 h-6 ${module.color}`} />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1">{module.title}</h3>
|
||||
<p className="text-xs text-gray-500 mb-2 line-clamp-2">{module.desc}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{module.stats}
|
||||
</Badge>
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统关联图说明 */}
|
||||
<Card className="border-none shadow-md bg-white/60 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-700 text-sm font-medium flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
系统关联与数据流向
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatNumber(stats.totalUsers)} 可查询
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* 数据流入 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<ArrowUpRight className="w-4 h-4 text-blue-500" />
|
||||
数据流入
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ name: "存客宝", desc: "用户基础信息、交易流水" },
|
||||
{ name: "触客宝", desc: "互动行为、活动参与" },
|
||||
{ name: "数智员工", desc: "账号信息、粉丝画像" },
|
||||
{ name: "外部系统", desc: "征信数据、行业数据" },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2 rounded-lg bg-blue-50">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{item.name}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 用户画像卡片 */}
|
||||
{msg.portrait && (msg.portrait.phone || msg.portrait.qq) && (
|
||||
<div className="rounded-xl p-3 bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-100">
|
||||
<div className="flex items-center gap-2 mb-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">RFM</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.portrait.dataCompleteness !== undefined && (
|
||||
<div className="p-2 rounded bg-white/80">
|
||||
<div className="text-gray-500">完整度</div>
|
||||
<div className="font-medium text-green-600">{msg.portrait.dataCompleteness}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{msg.portrait.tags && msg.portrait.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{msg.portrait.tags.slice(0, 5).map((tag, ti) => (
|
||||
<Badge key={ti} variant="secondary" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</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 className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-purple-500" />
|
||||
神射手处理
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ name: "数据治理", desc: "清洗、标准化、质量监控" },
|
||||
{ name: "标签计算", desc: "规则/模型/AI标签生成" },
|
||||
{ name: "价值评估", desc: "CLV、RFM、流失预测" },
|
||||
{ name: "AI洞察", desc: "智能分析、报告生成" },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2 rounded-lg bg-purple-50">
|
||||
<Zap className="w-4 h-4 text-purple-500 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{item.name}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 服务输出 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-green-500" />
|
||||
服务输出
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ name: "存客宝/触客宝", desc: "画像API、人群包推送" },
|
||||
{ name: "数智员工", desc: "账号价值评估API" },
|
||||
{ name: "聚宝盆", desc: "核心指标聚合接口" },
|
||||
{ name: "外部客户", desc: "行业报告、趋势分析包" },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2 rounded-lg bg-green-50">
|
||||
<ArrowUpRight className="w-4 h-4 text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{item.name}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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("高价值用户")}>
|
||||
<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号或问题..."
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,345 +1,634 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Target, Plus, Search, Users, Download, Share2, Trash2, Filter, BarChart3, PieChart } from "lucide-react"
|
||||
import { useState, useEffect } from "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 { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Target,
|
||||
Users,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Check,
|
||||
QrCode,
|
||||
Globe,
|
||||
MessageCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
Package,
|
||||
Star,
|
||||
TrendingUp,
|
||||
MapPin,
|
||||
Activity,
|
||||
Zap,
|
||||
Shield,
|
||||
Crown,
|
||||
Sparkles,
|
||||
Filter,
|
||||
Download,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
interface CrowdPack {
|
||||
// 流量池分类
|
||||
interface PoolCategory {
|
||||
id: string
|
||||
name: string
|
||||
icon: any
|
||||
color: string
|
||||
description: string
|
||||
userCount: number
|
||||
rules: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
status: "active" | "expired" | "computing"
|
||||
distribution: { label: string; value: number; color: string }[]
|
||||
pools: Pool[]
|
||||
}
|
||||
|
||||
const MOCK_CROWDS: CrowdPack[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "高价值活跃用户",
|
||||
description: "价值评分>80 AND 近7天活跃",
|
||||
userCount: 125678901,
|
||||
rules: ["价值评分 > 80", "最后登录 < 7天", "消费金额 > 1000"],
|
||||
createdAt: "2025-12-10",
|
||||
updatedAt: "2025-12-12",
|
||||
status: "active",
|
||||
distribution: [
|
||||
{ label: "25-35岁", value: 45, color: "bg-blue-500" },
|
||||
{ label: "35-45岁", value: 30, color: "bg-green-500" },
|
||||
{ label: "其他", value: 25, color: "bg-gray-300" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "流失预警用户",
|
||||
description: "流失风险>0.7 AND 近30天无消费",
|
||||
userCount: 34567890,
|
||||
rules: ["流失风险 > 0.7", "最后消费 > 30天", "活跃度等级 = 低"],
|
||||
createdAt: "2025-12-08",
|
||||
updatedAt: "2025-12-12",
|
||||
status: "active",
|
||||
distribution: [
|
||||
{ label: "一线城市", value: 35, color: "bg-purple-500" },
|
||||
{ label: "二线城市", value: 40, color: "bg-orange-500" },
|
||||
{ label: "其他", value: 25, color: "bg-gray-300" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "新注册潜力用户",
|
||||
description: "注册<30天 AND 有首购",
|
||||
userCount: 8901234,
|
||||
rules: ["注册时间 < 30天", "首购完成 = 是", "消费金额 > 100"],
|
||||
createdAt: "2025-12-05",
|
||||
updatedAt: "2025-12-11",
|
||||
status: "computing",
|
||||
distribution: [
|
||||
{ label: "女性", value: 55, color: "bg-pink-500" },
|
||||
{ label: "男性", value: 45, color: "bg-blue-500" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "品牌忠诚用户",
|
||||
description: "复购率>50% AND 品牌偏好明确",
|
||||
userCount: 56789012,
|
||||
rules: ["复购率 > 50%", "品牌偏好 != 空", "消费频次 > 3次/月"],
|
||||
createdAt: "2025-12-01",
|
||||
updatedAt: "2025-12-10",
|
||||
status: "active",
|
||||
distribution: [
|
||||
{ label: "高端消费", value: 60, color: "bg-yellow-500" },
|
||||
{ label: "中端消费", value: 30, color: "bg-green-500" },
|
||||
{ label: "其他", value: 10, color: "bg-gray-300" },
|
||||
],
|
||||
},
|
||||
]
|
||||
// 流量池/标签类型
|
||||
interface Pool {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
count: number
|
||||
avgScore?: number
|
||||
description?: string
|
||||
hotLevel?: number // 热度 1-5
|
||||
}
|
||||
|
||||
const AVAILABLE_CONDITIONS = [
|
||||
{ category: "用户属性", options: ["性别", "年龄段", "城市等级", "注册时间"] },
|
||||
{ category: "行为标签", options: ["活跃度等级", "消费等级", "复购率", "最后登录", "最后消费"] },
|
||||
{ category: "价值标签", options: ["价值评分", "RFM评分", "CLV预测值"] },
|
||||
{ category: "风险标签", options: ["流失风险", "欺诈风险"] },
|
||||
{ category: "AI标签", options: ["品牌偏好", "价格敏感度", "内容偏好"] },
|
||||
]
|
||||
// 用户类型
|
||||
interface PoolUser {
|
||||
id: string
|
||||
phone: string
|
||||
name: string
|
||||
score: number
|
||||
level: string
|
||||
tags: string[]
|
||||
province?: string
|
||||
city?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function CrowdSelectionPage() {
|
||||
const [crowds, setCrowds] = useState<CrowdPack[]>(MOCK_CROWDS)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [selectedConditions, setSelectedConditions] = useState<string[]>([])
|
||||
// 流量池分类定义
|
||||
const POOL_CATEGORIES: { [key: string]: { name: string; icon: any; color: string; bgColor: string; description: string } } = {
|
||||
value: {
|
||||
name: '💎 价值分层',
|
||||
icon: Crown,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
description: '按用户估值分数划分的流量池'
|
||||
},
|
||||
behavior: {
|
||||
name: '⚡ 行为活跃',
|
||||
icon: Activity,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
description: '按用户活跃程度划分'
|
||||
},
|
||||
region: {
|
||||
name: '📍 地域分布',
|
||||
icon: MapPin,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
description: '按用户地理位置划分'
|
||||
},
|
||||
source: {
|
||||
name: '📥 数据来源',
|
||||
icon: QrCode,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50',
|
||||
description: '按数据接入渠道划分'
|
||||
},
|
||||
lifecycle: {
|
||||
name: '🔄 生命周期',
|
||||
icon: TrendingUp,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
description: '按用户生命周期阶段划分'
|
||||
},
|
||||
}
|
||||
|
||||
export default function TrafficPoolPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState('value')
|
||||
const [pools, setPools] = useState<Pool[]>([])
|
||||
const [categorizedPools, setCategorizedPools] = useState<{ [key: string]: Pool[] }>({})
|
||||
const [selectedPool, setSelectedPool] = useState<Pool | null>(null)
|
||||
const [users, setUsers] = useState<PoolUser[]>([])
|
||||
const [totalUsers, setTotalUsers] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [showUserDialog, setShowUserDialog] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [packageName, setPackageName] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// 加载流量池数据
|
||||
useEffect(() => {
|
||||
loadPools()
|
||||
}, [])
|
||||
|
||||
const loadPools = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 获取所有流量池数据
|
||||
const [poolsRes, tagsRes, packagesRes] = await Promise.all([
|
||||
fetch('/api/traffic-packages?action=pools'),
|
||||
fetch('/api/tags'),
|
||||
fetch('/api/traffic-packages')
|
||||
])
|
||||
|
||||
const poolsData = await poolsRes.json()
|
||||
const tagsData = await tagsRes.json()
|
||||
const packagesData = await packagesRes.json()
|
||||
|
||||
const allPools: Pool[] = []
|
||||
const categorized: { [key: string]: Pool[] } = {
|
||||
value: [],
|
||||
behavior: [],
|
||||
region: [],
|
||||
source: [],
|
||||
lifecycle: []
|
||||
}
|
||||
|
||||
// 处理流量池数据(价值分层)
|
||||
if (poolsData.success && poolsData.pools) {
|
||||
poolsData.pools.forEach((p: any) => {
|
||||
const pool: Pool = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'value',
|
||||
count: p.count,
|
||||
avgScore: p.avgScore,
|
||||
description: `估值分 ${p.minScore || 0} - ${p.maxScore || '∞'}`,
|
||||
hotLevel: p.id === 'diamond' ? 5 : p.id === 'gold' ? 4 : p.id === 'silver' ? 3 : 2
|
||||
}
|
||||
allPools.push(pool)
|
||||
categorized.value.push(pool)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理标签数据
|
||||
if (tagsData.success && tagsData.tags) {
|
||||
tagsData.tags.forEach((t: any) => {
|
||||
const pool: Pool = {
|
||||
id: `tag_${t.id}`,
|
||||
name: t.name,
|
||||
type: t.category,
|
||||
count: t.userCount,
|
||||
description: t.description,
|
||||
hotLevel: Math.min(5, Math.floor(t.userCount / 100000000) + 1)
|
||||
}
|
||||
allPools.push(pool)
|
||||
|
||||
// 按分类归类
|
||||
if (t.category === 'value') {
|
||||
categorized.value.push(pool)
|
||||
} else if (t.category === 'behavior') {
|
||||
categorized.behavior.push(pool)
|
||||
} else if (t.category === 'region') {
|
||||
categorized.region.push(pool)
|
||||
} else if (t.category === 'channel') {
|
||||
categorized.source.push(pool)
|
||||
} else if (t.category === 'lifecycle') {
|
||||
categorized.lifecycle.push(pool)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 添加数据来源流量池
|
||||
const sourceProjects = [
|
||||
{ name: '存客宝用户', count: 216000, type: 'source', description: 'CRM系统用户数据' },
|
||||
{ name: '点了码用户', count: 1000, type: 'source', description: '扫码营销用户' },
|
||||
{ name: '微博用户', count: 216000000, type: 'source', description: '微博平台用户' },
|
||||
{ name: 'QQ社交用户', count: 705000000, type: 'source', description: 'QQ平台用户' },
|
||||
{ name: '京东用户', count: 142000000, type: 'source', description: '京东电商用户' },
|
||||
{ name: '顺丰用户', count: 39200000, type: 'source', description: '快递地址数据' },
|
||||
]
|
||||
|
||||
sourceProjects.forEach((s, i) => {
|
||||
const pool: Pool = {
|
||||
id: `source_${i}`,
|
||||
name: s.name,
|
||||
type: 'source',
|
||||
count: s.count,
|
||||
description: s.description,
|
||||
hotLevel: Math.min(5, Math.floor(s.count / 100000000) + 1)
|
||||
}
|
||||
allPools.push(pool)
|
||||
categorized.source.push(pool)
|
||||
})
|
||||
|
||||
setPools(allPools)
|
||||
setCategorizedPools(categorized)
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载流量池失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载池内用户
|
||||
const loadPoolUsers = async (pool: Pool, page: number = 1) => {
|
||||
setLoading(true)
|
||||
setSelectedPool(pool)
|
||||
setCurrentPage(page)
|
||||
setShowUserDialog(true)
|
||||
|
||||
try {
|
||||
// 根据流量池类型构建查询
|
||||
let apiUrl = `/api/portrait?action=users&page=${page}&limit=20`
|
||||
|
||||
// 如果是价值分层流量池,添加分数筛选
|
||||
if (pool.type === 'value' && pool.id.startsWith('pool_')) {
|
||||
// 根据池名确定分数范围
|
||||
const scoreRanges: { [key: string]: { min?: number; max?: number } } = {
|
||||
'diamond': { min: 3000 },
|
||||
'gold': { min: 2000, max: 3000 },
|
||||
'silver': { min: 1000, max: 2000 },
|
||||
'bronze': { min: 500, max: 1000 },
|
||||
'potential': { max: 500 }
|
||||
}
|
||||
// 解析pool id
|
||||
}
|
||||
|
||||
const res = await fetch(apiUrl)
|
||||
const data = await res.json()
|
||||
|
||||
if (data.success) {
|
||||
setUsers(data.users || [])
|
||||
setTotalUsers(data.total || pool.count)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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(1)}K`
|
||||
return num.toString()
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const totalUsers = crowds.reduce((sum, c) => sum + c.userCount, 0)
|
||||
// 保存为流量包
|
||||
const savePackage = async () => {
|
||||
if (!selectedPool) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await fetch('/api/traffic-packages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'create',
|
||||
name: packageName || selectedPool.name,
|
||||
poolId: selectedPool.id,
|
||||
userCount: totalUsers
|
||||
})
|
||||
})
|
||||
alert('流量包保存成功')
|
||||
setShowSaveDialog(false)
|
||||
setPackageName('')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取热度星星
|
||||
const getHotStars = (level: number = 1) => {
|
||||
return Array(5).fill(0).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-3 w-3 ${i < level ? 'text-yellow-400 fill-yellow-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
// 筛选流量池
|
||||
const getFilteredPools = (categoryPools: Pool[]) => {
|
||||
if (!searchQuery) return categoryPools
|
||||
const query = searchQuery.toLowerCase()
|
||||
return categoryPools.filter(p =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">人群圈选</h1>
|
||||
<p className="text-gray-500 mt-1">组合条件筛选目标用户群体</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsCreating(!isCreating)}
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建人群包
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Panel */}
|
||||
{isCreating && (
|
||||
<Card className="border-none shadow-md bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">创建人群包</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">人群包名称</label>
|
||||
<Input placeholder="例如:高价值潜在流失用户" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">描述</label>
|
||||
<Input placeholder="简要描述人群特征" />
|
||||
</div>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">流量池</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">按分类管理用户流量池,查看真实用户数据</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索流量池..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 w-64 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadPools}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="text-sm font-medium text-gray-700">筛选条件</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{AVAILABLE_CONDITIONS.map((group) => (
|
||||
<div key={group.category} className="p-4 rounded-xl bg-gray-50">
|
||||
<h4 className="font-medium text-gray-900 mb-3">{group.category}</h4>
|
||||
<div className="space-y-2">
|
||||
{group.options.map((option) => (
|
||||
<div key={option} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={option}
|
||||
checked={selectedConditions.includes(option)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedConditions([...selectedConditions, option])
|
||||
} else {
|
||||
setSelectedConditions(selectedConditions.filter((c) => c !== option))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={option} className="text-sm text-gray-600">
|
||||
{option}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{Object.entries(POOL_CATEGORIES).map(([key, cat]) => {
|
||||
const categoryPools = categorizedPools[key] || []
|
||||
const totalCount = categoryPools.reduce((sum, p) => sum + p.count, 0)
|
||||
const Icon = cat.icon
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
className={`border-0 shadow-sm cursor-pointer transition-all hover:shadow-md ${
|
||||
activeCategory === key ? 'ring-2 ring-purple-500' : ''
|
||||
} ${cat.bgColor}`}
|
||||
onClick={() => setActiveCategory(key)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-white/80`}>
|
||||
<Icon className={`h-5 w-5 ${cat.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">{cat.name.slice(2)}</div>
|
||||
<div className="text-xs text-gray-500">{categoryPools.length} 个池</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-2 border-t border-white/50">
|
||||
<div className="text-xl font-bold text-gray-900">{formatNumber(totalCount)}</div>
|
||||
<div className="text-xs text-gray-500">总用户数</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedConditions.length > 0 && (
|
||||
<div className="p-4 rounded-xl bg-blue-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-gray-900">已选条件</span>
|
||||
<Badge className="bg-blue-100 text-blue-700">{selectedConditions.length}个</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedConditions.map((cond) => (
|
||||
<Badge key={cond} variant="secondary" className="bg-white">
|
||||
{cond}
|
||||
<button
|
||||
onClick={() => setSelectedConditions(selectedConditions.filter((c) => c !== cond))}
|
||||
className="ml-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{/* 流量池列表 */}
|
||||
<Card className="border-0 shadow-lg bg-white/90">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">{POOL_CATEGORIES[activeCategory]?.name.slice(0, 2)}</span>
|
||||
{POOL_CATEGORIES[activeCategory]?.name.slice(2)}
|
||||
</CardTitle>
|
||||
<Badge variant="outline">{getFilteredPools(categorizedPools[activeCategory] || []).length} 个流量池</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{POOL_CATEGORIES[activeCategory]?.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !showUserDialog ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
|
||||
<span className="ml-3 text-gray-500">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{getFilteredPools(categorizedPools[activeCategory] || []).map(pool => (
|
||||
<Card
|
||||
key={pool.id}
|
||||
className="border shadow-sm hover:shadow-lg transition-all cursor-pointer bg-white group"
|
||||
onClick={() => loadPoolUsers(pool)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{getHotStars(pool.hotLevel)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pool.type === 'value' ? '价值' : pool.type === 'behavior' ? '行为' : pool.type === 'region' ? '地域' : pool.type === 'source' ? '来源' : '周期'}
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 text-lg group-hover:text-purple-600 transition-colors">{pool.name}</h4>
|
||||
{pool.description && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-1">{pool.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-purple-600">{formatNumber(pool.count)}</div>
|
||||
<div className="text-xs text-gray-500">用户数</div>
|
||||
</div>
|
||||
{pool.avgScore && (
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold text-orange-500">{Math.round(pool.avgScore)}</div>
|
||||
<div className="text-xs text-gray-500">均分</div>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight className="h-5 w-5 text-gray-300 group-hover:text-purple-500 transition-colors" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{getFilteredPools(categorizedPools[activeCategory] || []).length === 0 && (
|
||||
<div className="col-span-4 text-center py-12 text-gray-500">
|
||||
<Target className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>该分类下暂无流量池</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => setIsCreating(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="outline">预估人数</Button>
|
||||
<Button>保存人群包</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||||
<Target className="w-4 h-4" />
|
||||
人群包数
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{crowds.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||||
<Users className="w-4 h-4" />
|
||||
覆盖用户
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(totalUsers)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||||
<Filter className="w-4 h-4" />
|
||||
可用条件
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{AVAILABLE_CONDITIONS.reduce((sum, g) => sum + g.options.length, 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
运算中
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{crowds.filter((c) => c.status === "computing").length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="搜索人群包..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Crowd List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{crowds
|
||||
.filter((c) => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.map((crowd) => (
|
||||
<Card key={crowd.id} className="border-none shadow-sm bg-white/60 backdrop-blur-sm hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{crowd.name}</h3>
|
||||
<Badge
|
||||
className={
|
||||
crowd.status === "active"
|
||||
? "bg-green-100 text-green-700"
|
||||
: crowd.status === "computing"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}
|
||||
>
|
||||
{crowd.status === "active" ? "生效中" : crowd.status === "computing" ? "计算中" : "已过期"}
|
||||
</Badge>
|
||||
{/* 用户列表弹窗 */}
|
||||
<Dialog open={showUserDialog} onOpenChange={setShowUserDialog}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-purple-600" />
|
||||
{selectedPool?.name} - 用户列表
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedPool?.description} · 共 {formatNumber(totalUsers)} 个用户
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="border-0 shadow bg-gradient-to-r from-purple-50 to-blue-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-sm text-gray-500">总用户数</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{formatNumber(totalUsers)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-sm text-gray-500">热度等级</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{getHotStars(selectedPool?.hotLevel || 1)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{crowd.description}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(crowd.userCount)}</div>
|
||||
<div className="text-xs text-gray-500">用户数</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow bg-white/80">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-sm text-gray-500">流量池类型</div>
|
||||
<Badge className="mt-1">{selectedPool?.type}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">筛选规则:</span>
|
||||
{crowd.rules.map((rule, i) => (
|
||||
<Badge key={i} variant="secondary" className="ml-1 text-xs">
|
||||
{rule}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{/* 用户表格 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>手机号</TableHead>
|
||||
<TableHead>姓名</TableHead>
|
||||
<TableHead>估值分</TableHead>
|
||||
<TableHead>等级</TableHead>
|
||||
<TableHead>地区</TableHead>
|
||||
<TableHead>标签</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map(user => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-mono">{user.phone}</TableCell>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.score > 2000 ? 'default' : 'secondary'}>
|
||||
{user.score || '-'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{user.level || '-'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">
|
||||
{user.province} {user.city}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{(user.tags || []).slice(0, 2).map((tag, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
{(user.tags || []).length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{user.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 分页和操作 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1 || loading}
|
||||
onClick={() => selectedPool && loadPoolUsers(selectedPool, currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={users.length < 20 || loading}
|
||||
onClick={() => selectedPool && loadPoolUsers(selectedPool, currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowUserDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出数据
|
||||
</Button>
|
||||
<Button onClick={() => { setShowUserDialog(false); setShowSaveDialog(true); }}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
保存为流量包
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="mb-4">
|
||||
<div className="flex h-3 rounded-full overflow-hidden">
|
||||
{crowd.distribution.map((d, i) => (
|
||||
<div key={i} className={`${d.color}`} style={{ width: `${d.value}%` }} title={d.label} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
{crowd.distribution.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<div className={`w-2 h-2 rounded-full ${d.color}`} />
|
||||
<span>
|
||||
{d.label} {d.value}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 保存流量包弹窗 */}
|
||||
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>保存为流量包</DialogTitle>
|
||||
<DialogDescription>将当前流量池保存为可复用的流量包</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>流量包名称</Label>
|
||||
<Input
|
||||
placeholder={selectedPool?.name}
|
||||
value={packageName}
|
||||
onChange={(e) => setPackageName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-purple-50 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">来源流量池</span>
|
||||
<span className="font-medium">{selectedPool?.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500">更新于 {crowd.updatedAt}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Share2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<PieChart className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-red-500">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">流量池类型</span>
|
||||
<Badge>{selectedPool?.type}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">用户数量</span>
|
||||
<span className="text-2xl font-bold text-purple-600">{formatNumber(totalUsers)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>取消</Button>
|
||||
<Button onClick={savePackage} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Package className="h-4 w-4 mr-2" />}
|
||||
保存流量包
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,272 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useState, useEffect } from "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 Link from "next/link"
|
||||
import {
|
||||
Tags,
|
||||
Users,
|
||||
Target,
|
||||
TrendingUp,
|
||||
ArrowUpRight,
|
||||
Diamond,
|
||||
Award,
|
||||
Medal,
|
||||
Shield,
|
||||
Loader2,
|
||||
Star,
|
||||
Activity,
|
||||
MapPin,
|
||||
Clock,
|
||||
Heart,
|
||||
} from "lucide-react"
|
||||
|
||||
// 流量池配置
|
||||
const TRAFFIC_POOLS = [
|
||||
{ id: 'diamond', name: '钻石池', icon: Diamond, color: 'from-purple-500 to-pink-500', minScore: 80, desc: 'RFM≥80 顶级价值用户' },
|
||||
{ id: 'gold', name: '黄金池', icon: Award, color: 'from-yellow-500 to-orange-500', minScore: 60, maxScore: 80, desc: 'RFM 60-80 高价值用户' },
|
||||
{ id: 'silver', name: '白银池', icon: Medal, color: 'from-gray-400 to-gray-500', minScore: 40, maxScore: 60, desc: 'RFM 40-60 中等价值用户' },
|
||||
{ id: 'bronze', name: '青铜池', icon: Shield, color: 'from-orange-400 to-orange-600', minScore: 20, maxScore: 40, desc: 'RFM 20-40 待培育用户' },
|
||||
{ id: 'potential', name: '潜力池', icon: TrendingUp, color: 'from-blue-400 to-cyan-500', maxScore: 20, desc: 'RFM<20 潜在用户' },
|
||||
]
|
||||
|
||||
// 标签分类
|
||||
const TAG_CATEGORIES = [
|
||||
{ id: 'value', name: '价值标签', icon: Star, color: 'bg-yellow-100 text-yellow-700', desc: 'S/A/B/C/D等级', count: 0 },
|
||||
{ id: 'behavior', name: '行为标签', icon: Activity, color: 'bg-blue-100 text-blue-700', desc: '活跃度、互动频次', count: 0 },
|
||||
{ id: 'region', name: '地域标签', icon: MapPin, color: 'bg-green-100 text-green-700', desc: '省份、城市、区域', count: 0 },
|
||||
{ id: 'lifecycle', name: '生命周期', icon: Clock, color: 'bg-purple-100 text-purple-700', desc: '新用户、活跃、沉睡', count: 0 },
|
||||
{ id: 'preference', name: '偏好标签', icon: Heart, color: 'bg-red-100 text-red-700', desc: '兴趣、消费偏好', count: 0 },
|
||||
]
|
||||
|
||||
export default function TagPortraitPage() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pools, setPools] = useState<any[]>([])
|
||||
const [categories, setCategories] = useState(TAG_CATEGORIES)
|
||||
const [stats, setStats] = useState({
|
||||
totalTags: 0,
|
||||
totalUsers: 0,
|
||||
avgCoverage: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/tag-portrait/tags")
|
||||
}, [router])
|
||||
return null
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 加载流量池数据
|
||||
const poolsRes = await fetch('/api/traffic-packages?action=pools')
|
||||
const poolsData = await poolsRes.json()
|
||||
if (poolsData.success) {
|
||||
setPools(poolsData.pools)
|
||||
}
|
||||
|
||||
// 加载标签分类数据
|
||||
const tagsRes = await fetch('/api/tags?action=categories')
|
||||
const tagsData = await tagsRes.json()
|
||||
if (tagsData.success) {
|
||||
setCategories(tagsData.categories.map((c: any) => ({
|
||||
...TAG_CATEGORIES.find(tc => tc.id === c.id) || {},
|
||||
...c
|
||||
})))
|
||||
setStats({
|
||||
totalTags: tagsData.categories.reduce((sum: number, c: any) => sum + (c.tagCount || 0), 0),
|
||||
totalUsers: tagsData.categories.reduce((sum: number, c: any) => sum + (c.userCount || 0), 0),
|
||||
avgCoverage: 82.3
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<span className="ml-3 text-gray-500">加载标签画像...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 顶部标题 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">标签画像</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
用户资产数字化标签体系 · 流量池分层运营
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">标签总数</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.totalTags}</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-100 flex items-center justify-center">
|
||||
<Tags className="h-7 w-7 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">覆盖用户</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.totalUsers)}</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-2xl bg-green-100 flex items-center justify-center">
|
||||
<Users className="h-7 w-7 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white/80 backdrop-blur border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">覆盖率</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.avgCoverage}%</p>
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-2xl bg-purple-100 flex items-center justify-center">
|
||||
<Target className="h-7 w-7 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={stats.avgCoverage} className="h-2 mt-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 流量池体系 */}
|
||||
<Card className="border-none shadow-lg bg-white/80 backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-purple-500" />
|
||||
流量池体系
|
||||
<Badge variant="outline" className="ml-2">RFM分层</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{TRAFFIC_POOLS.map((pool) => {
|
||||
const poolData = pools.find(p => p.id === pool.id)
|
||||
const PoolIcon = pool.icon
|
||||
|
||||
return (
|
||||
<Link key={pool.id} href={`/tag-portrait/crowd?pool=${pool.id}`}>
|
||||
<Card className={`cursor-pointer hover:shadow-lg transition-all duration-300 border-0 overflow-hidden group`}>
|
||||
<div className={`h-2 bg-gradient-to-r ${pool.color}`} />
|
||||
<CardContent className="p-4">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-r ${pool.color} flex items-center justify-center mb-3 group-hover:scale-110 transition-transform`}>
|
||||
<PoolIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1">{pool.name}</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">{pool.desc}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl font-bold text-gray-900">
|
||||
{formatNumber(poolData?.count || 0)}
|
||||
</span>
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4 text-center">
|
||||
基于 RFM(最近购买、购买频率、购买金额)评分模型自动分配流量池
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 标签分类 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">标签分类</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{categories.map((category) => {
|
||||
const CategoryIcon = category.icon || Tags
|
||||
return (
|
||||
<Link key={category.id} href={`/tag-portrait/tags?category=${category.id}`}>
|
||||
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-white/70 group hover:-translate-y-1">
|
||||
<CardContent className="p-5">
|
||||
<div className={`w-12 h-12 rounded-xl ${category.color.split(' ')[0]} flex items-center justify-center mb-3 group-hover:scale-110 transition-transform`}>
|
||||
<CategoryIcon className={`h-6 w-6 ${category.color.split(' ')[1]}`} />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900 mb-1">{category.name}</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">{category.desc}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary">{category.tagCount || category.count || 0} 个标签</Badge>
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link href="/tag-portrait/tags">
|
||||
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-r from-blue-500 to-indigo-500 text-white group">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">标签管理</h3>
|
||||
<p className="text-blue-100 text-sm">查看和管理所有标签</p>
|
||||
</div>
|
||||
<Tags className="h-10 w-10 text-blue-200 group-hover:scale-110 transition-transform" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/tag-portrait/portrait">
|
||||
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-r from-green-500 to-emerald-500 text-white group">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">用户画像</h3>
|
||||
<p className="text-green-100 text-sm">查询单个用户完整画像</p>
|
||||
</div>
|
||||
<Users className="h-10 w-10 text-green-200 group-hover:scale-110 transition-transform" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/tag-portrait/crowd">
|
||||
<Card className="cursor-pointer hover:shadow-lg transition-all duration-300 border-0 shadow-sm bg-gradient-to-r from-purple-500 to-pink-500 text-white group">
|
||||
<CardContent className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">人群圈选</h3>
|
||||
<p className="text-purple-100 text-sm">按条件筛选目标人群</p>
|
||||
</div>
|
||||
<Target className="h-10 w-10 text-purple-200 group-hover:scale-110 transition-transform" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user