chore: 以本地为准,上传全部并替换 GitHub
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user