553 lines
22 KiB
TypeScript
553 lines
22 KiB
TypeScript
"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">
|
||
管理所有数据接入源,支持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" />
|
||
添加数据源
|
||
</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>
|
||
)
|
||
}
|