chore: 以本地为准,上传全部并替换 GitHub

This commit is contained in:
卡若
2026-02-03 11:36:53 +08:00
parent 1219166526
commit b404bf546e
131 changed files with 37618 additions and 3930 deletions

View 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>
)
}

View File

@@ -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">MySQLAPIWebhook等多种接入方式</p>
<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" />
@@ -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>