Files
users/app/data-ingestion/sources/page.tsx

553 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
import { useState, useEffect } 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, 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 {
Database,
Plus,
Search,
RefreshCw,
Settings,
CheckCircle2,
Clock,
Server,
Globe,
Webhook,
MoreVertical,
Eye,
Loader2,
ArrowRight,
Zap,
Target,
FolderTree,
ChevronRight,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
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 [dataSources, setDataSources] = useState<DataSource[]>([])
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState({
total: 0,
connected: 0,
warning: 0,
totalRecords: 0,
latency: 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 border-0"></Badge>
case "warning":
return <Badge className="bg-yellow-100 text-yellow-700 border-0"></Badge>
case "disconnected":
return <Badge className="bg-red-100 text-red-700 border-0"></Badge>
default:
return <Badge variant="secondary"></Badge>
}
}
const getTypeIcon = (type: string) => {
switch (type) {
case "mongodb":
return <Database className="h-5 w-5 text-green-600" />
case "mysql":
return <Server className="h-5 w-5 text-blue-600" />
case "api":
return <Globe className="h-5 w-5 text-purple-600" />
case "webhook":
return <Webhook className="h-5 w-5 text-orange-600" />
default:
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) {
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 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 handleAddSource = async () => {
// TODO: 实现添加数据源
setShowAddDialog(false)
loadDataSources()
}
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">
MongoDBMySQLAPIWebhook等多种数据源
</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" />
</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">{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>
</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">{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>
</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(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>
</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">{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>
</div>
</CardContent>
</Card>
</div>
{/* 搜索和筛选 */}
<div className="flex flex-col md:flex-row gap-4">
<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="搜索数据源名称、描述、分类..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-white"
/>
</div>
<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>
</TabsList>
</Tabs>
</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-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>
<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>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 数据源类型 */}
<div className="grid grid-cols-4 gap-3">
{[
{ 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={() => 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-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>
{/* 基本信息 */}
<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="localhost:27017"
value={newSource.host}
onChange={(e) => setNewSource({ ...newSource, host: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="数据库名称"
value={newSource.database}
onChange={(e) => setNewSource({ ...newSource, database: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="用户名"
value={newSource.username}
onChange={(e) => setNewSource({ ...newSource, username: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="password"
placeholder="密码"
value={newSource.password}
onChange={(e) => setNewSource({ ...newSource, password: e.target.value })}
/>
</div>
</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
value={newSource.targetCollection}
onValueChange={(v) => setNewSource({ ...newSource, targetCollection: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<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>
<p className="text-xs text-gray-500">
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
</Button>
<Button variant="outline" onClick={handleTestConnection}>
</Button>
<Button onClick={handleAddSource}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
)
}