Reorganize navigation and module structure based on new requirements. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
934 lines
36 KiB
TypeScript
934 lines
36 KiB
TypeScript
"use client"
|
||
|
||
import { useState } from "react"
|
||
import {
|
||
Database,
|
||
Plus,
|
||
Search,
|
||
Play,
|
||
Pause,
|
||
RefreshCw,
|
||
Settings,
|
||
Trash2,
|
||
CheckCircle,
|
||
XCircle,
|
||
Clock,
|
||
Server,
|
||
FileText,
|
||
Globe,
|
||
AlertTriangle,
|
||
Link2,
|
||
} from "lucide-react"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
|
||
interface DataSource {
|
||
id: string
|
||
name: string
|
||
type: "mysql" | "postgresql" | "restapi" | "webhook" | "sftp" | "kafka"
|
||
status: "running" | "stopped" | "error" | "syncing"
|
||
syncMode: "full" | "incremental" | "realtime"
|
||
frequency: string
|
||
lastSync: string
|
||
records: number
|
||
errorCount: number
|
||
config: Record<string, string>
|
||
}
|
||
|
||
const MOCK_SOURCES: DataSource[] = [
|
||
{
|
||
id: "1",
|
||
name: "存客宝-用户主库",
|
||
type: "mysql",
|
||
status: "running",
|
||
syncMode: "incremental",
|
||
frequency: "每5分钟",
|
||
lastSync: "2025-12-12 14:30:00",
|
||
records: 128956342,
|
||
errorCount: 0,
|
||
config: { host: "db.cunkebao.com", port: "3306", database: "user_main" },
|
||
},
|
||
{
|
||
id: "2",
|
||
name: "触客宝-行为数据",
|
||
type: "kafka",
|
||
status: "running",
|
||
syncMode: "realtime",
|
||
frequency: "实时",
|
||
lastSync: "2025-12-12 14:35:12",
|
||
records: 89234567,
|
||
errorCount: 2,
|
||
config: { brokers: "kafka.chukebao.com:9092", topic: "user_behavior" },
|
||
},
|
||
{
|
||
id: "3",
|
||
name: "数智员工-账号API",
|
||
type: "restapi",
|
||
status: "running",
|
||
syncMode: "incremental",
|
||
frequency: "每小时",
|
||
lastSync: "2025-12-12 14:00:00",
|
||
records: 45678901,
|
||
errorCount: 0,
|
||
config: { endpoint: "https://api.shuzhi.com/v1/accounts", method: "GET", auth: "Bearer Token" },
|
||
},
|
||
{
|
||
id: "4",
|
||
name: "征信数据-外部接口",
|
||
type: "restapi",
|
||
status: "stopped",
|
||
syncMode: "incremental",
|
||
frequency: "每天",
|
||
lastSync: "2025-12-11 00:00:00",
|
||
records: 12890456,
|
||
errorCount: 0,
|
||
config: { endpoint: "https://credit.external.com/api", method: "POST", auth: "API Key" },
|
||
},
|
||
{
|
||
id: "5",
|
||
name: "交易流水-SFTP",
|
||
type: "sftp",
|
||
status: "running",
|
||
syncMode: "incremental",
|
||
frequency: "每30分钟",
|
||
lastSync: "2025-12-12 14:30:00",
|
||
records: 256789012,
|
||
errorCount: 1,
|
||
config: { host: "sftp.bank.com", path: "/data/transactions" },
|
||
},
|
||
{
|
||
id: "6",
|
||
name: "聚宝盆-指标数据",
|
||
type: "postgresql",
|
||
status: "error",
|
||
syncMode: "full",
|
||
frequency: "每天",
|
||
lastSync: "2025-12-10 00:00:00",
|
||
records: 8901234,
|
||
errorCount: 15,
|
||
config: { host: "pg.jubao.com", port: "5432", database: "metrics" },
|
||
},
|
||
{
|
||
id: "7",
|
||
name: "微信消息-Webhook",
|
||
type: "webhook",
|
||
status: "running",
|
||
syncMode: "realtime",
|
||
frequency: "实时推送",
|
||
lastSync: "2025-12-12 14:36:00",
|
||
records: 34567890,
|
||
errorCount: 0,
|
||
config: { webhookUrl: "/api/webhook/wechat", secret: "***" },
|
||
},
|
||
]
|
||
|
||
const TYPE_ICONS = {
|
||
mysql: Database,
|
||
postgresql: Database,
|
||
restapi: Globe,
|
||
webhook: Link2,
|
||
sftp: FileText,
|
||
kafka: Server,
|
||
}
|
||
|
||
const TYPE_COLORS = {
|
||
mysql: "bg-blue-100 text-blue-700",
|
||
postgresql: "bg-indigo-100 text-indigo-700",
|
||
restapi: "bg-green-100 text-green-700",
|
||
webhook: "bg-teal-100 text-teal-700",
|
||
sftp: "bg-yellow-100 text-yellow-700",
|
||
kafka: "bg-purple-100 text-purple-700",
|
||
}
|
||
|
||
const TYPE_LABELS = {
|
||
mysql: "MySQL",
|
||
postgresql: "PostgreSQL",
|
||
restapi: "REST API",
|
||
webhook: "Webhook",
|
||
sftp: "SFTP",
|
||
kafka: "Kafka",
|
||
}
|
||
|
||
const STATUS_CONFIG = {
|
||
running: { color: "bg-green-100 text-green-700", icon: CheckCircle, label: "运行中" },
|
||
stopped: { color: "bg-gray-100 text-gray-700", icon: Pause, label: "已停止" },
|
||
error: { color: "bg-red-100 text-red-700", icon: XCircle, label: "错误" },
|
||
syncing: { color: "bg-blue-100 text-blue-700", icon: RefreshCw, label: "同步中" },
|
||
}
|
||
|
||
const syncLogs = [
|
||
{ id: 1, time: "2025-12-12 14:30:00", level: "info", message: "开始增量同步...", records: null },
|
||
{ id: 2, time: "2025-12-12 14:30:05", level: "info", message: "获取增量数据 2,350 条", records: 2350 },
|
||
{ id: 3, time: "2025-12-12 14:30:12", level: "success", message: "同步完成,写入 2,350 条", records: 2350 },
|
||
{ id: 4, time: "2025-12-12 14:25:00", level: "info", message: "开始增量同步...", records: null },
|
||
{ id: 5, time: "2025-12-12 14:25:08", level: "success", message: "同步完成,写入 1,890 条", records: 1890 },
|
||
]
|
||
|
||
export default function DataSourcesPage() {
|
||
const [sources, setSources] = useState<DataSource[]>(MOCK_SOURCES)
|
||
const [searchQuery, setSearchQuery] = useState("")
|
||
const [filterType, setFilterType] = useState<string>("all")
|
||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
|
||
const [logsDialogOpen, setLogsDialogOpen] = useState(false)
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||
const [selectedSource, setSelectedSource] = useState<DataSource | null>(null)
|
||
const [newSource, setNewSource] = useState({
|
||
name: "",
|
||
type: "mysql",
|
||
host: "",
|
||
port: "",
|
||
database: "",
|
||
// API配置
|
||
endpoint: "",
|
||
method: "GET",
|
||
authType: "none",
|
||
authToken: "",
|
||
headers: "",
|
||
// Webhook配置
|
||
webhookPath: "",
|
||
webhookSecret: "",
|
||
})
|
||
const [addStep, setAddStep] = useState(1)
|
||
|
||
const filteredSources = sources.filter((source) => {
|
||
const matchesSearch = source.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||
const matchesType = filterType === "all" || source.type === filterType
|
||
return matchesSearch && matchesType
|
||
})
|
||
|
||
const stats = {
|
||
total: sources.length,
|
||
running: sources.filter((s) => s.status === "running").length,
|
||
error: sources.filter((s) => s.status === "error").length,
|
||
totalRecords: sources.reduce((sum, s) => sum + s.records, 0),
|
||
apiCount: sources.filter((s) => s.type === "restapi" || s.type === "webhook").length,
|
||
}
|
||
|
||
const formatNumber = (num: number): string => {
|
||
if (num >= 1000000000) return `${(num / 1000000000).toFixed(2)}B`
|
||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||
return num.toString()
|
||
}
|
||
|
||
const handleToggleStatus = (id: string) => {
|
||
setSources((prev) =>
|
||
prev.map((s) => {
|
||
if (s.id === id) {
|
||
return { ...s, status: s.status === "running" ? "stopped" : "running" }
|
||
}
|
||
return s
|
||
}),
|
||
)
|
||
}
|
||
|
||
const handleAddSource = () => {
|
||
const newId = (sources.length + 1).toString()
|
||
const newSourceData: DataSource = {
|
||
id: newId,
|
||
name: newSource.name,
|
||
type: newSource.type as DataSource["type"],
|
||
status: "stopped",
|
||
syncMode: newSource.type === "webhook" ? "realtime" : "incremental",
|
||
frequency: newSource.type === "webhook" ? "实时推送" : "每小时",
|
||
lastSync: "-",
|
||
records: 0,
|
||
errorCount: 0,
|
||
config:
|
||
newSource.type === "restapi"
|
||
? { endpoint: newSource.endpoint, method: newSource.method, auth: newSource.authType }
|
||
: newSource.type === "webhook"
|
||
? { webhookUrl: newSource.webhookPath, secret: "***" }
|
||
: { host: newSource.host, port: newSource.port, database: newSource.database },
|
||
}
|
||
setSources((prev) => [...prev, newSourceData])
|
||
setIsAddDialogOpen(false)
|
||
setAddStep(1)
|
||
setNewSource({
|
||
name: "",
|
||
type: "mysql",
|
||
host: "",
|
||
port: "",
|
||
database: "",
|
||
endpoint: "",
|
||
method: "GET",
|
||
authType: "none",
|
||
authToken: "",
|
||
headers: "",
|
||
webhookPath: "",
|
||
webhookSecret: "",
|
||
})
|
||
}
|
||
|
||
const handleDeleteSource = () => {
|
||
if (selectedSource) {
|
||
setSources((prev) => prev.filter((s) => s.id !== selectedSource.id))
|
||
setDeleteDialogOpen(false)
|
||
setSelectedSource(null)
|
||
}
|
||
}
|
||
|
||
const renderConfigForm = () => {
|
||
switch (newSource.type) {
|
||
case "restapi":
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>API端点URL</Label>
|
||
<Input
|
||
placeholder="https://api.example.com/v1/users"
|
||
value={newSource.endpoint}
|
||
onChange={(e) => setNewSource({ ...newSource, endpoint: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>请求方法</Label>
|
||
<Select value={newSource.method} onValueChange={(v) => setNewSource({ ...newSource, method: v })}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="GET">GET</SelectItem>
|
||
<SelectItem value="POST">POST</SelectItem>
|
||
<SelectItem value="PUT">PUT</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>认证方式</Label>
|
||
<Select value={newSource.authType} onValueChange={(v) => setNewSource({ ...newSource, authType: v })}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">无认证</SelectItem>
|
||
<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>
|
||
{newSource.authType !== "none" && (
|
||
<div className="space-y-2">
|
||
<Label>
|
||
{newSource.authType === "bearer" ? "Token" : newSource.authType === "apikey" ? "API Key" : "凭证"}
|
||
</Label>
|
||
<Input
|
||
type="password"
|
||
placeholder="输入认证凭证"
|
||
value={newSource.authToken}
|
||
onChange={(e) => setNewSource({ ...newSource, authToken: e.target.value })}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="space-y-2">
|
||
<Label>自定义Headers (JSON格式,可选)</Label>
|
||
<Textarea
|
||
placeholder='{"Content-Type": "application/json"}'
|
||
value={newSource.headers}
|
||
onChange={(e) => setNewSource({ ...newSource, headers: e.target.value })}
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
case "webhook":
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="p-4 bg-blue-50 rounded-lg">
|
||
<p className="text-sm text-blue-700">
|
||
Webhook模式下,外部系统将主动推送数据到您的接收端点。配置完成后,请将生成的Webhook URL提供给数据推送方。
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Webhook路径</Label>
|
||
<div className="flex gap-2">
|
||
<span className="flex items-center px-3 bg-gray-100 rounded-l-md text-sm text-gray-600">
|
||
https://api.yourplatform.com
|
||
</span>
|
||
<Input
|
||
placeholder="/webhook/your-source"
|
||
value={newSource.webhookPath}
|
||
onChange={(e) => setNewSource({ ...newSource, webhookPath: e.target.value })}
|
||
className="rounded-l-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>签名密钥 (用于验证请求来源)</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="password"
|
||
placeholder="自动生成或手动输入"
|
||
value={newSource.webhookSecret}
|
||
onChange={(e) => setNewSource({ ...newSource, webhookSecret: e.target.value })}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setNewSource({ ...newSource, webhookSecret: Math.random().toString(36).slice(2) })}
|
||
>
|
||
生成
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>数据格式</Label>
|
||
<Select defaultValue="json">
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="json">JSON</SelectItem>
|
||
<SelectItem value="xml">XML</SelectItem>
|
||
<SelectItem value="form">Form Data</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
)
|
||
default:
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>主机地址</Label>
|
||
<Input
|
||
placeholder="localhost"
|
||
value={newSource.host}
|
||
onChange={(e) => setNewSource({ ...newSource, host: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>端口</Label>
|
||
<Input
|
||
placeholder="3306"
|
||
value={newSource.port}
|
||
onChange={(e) => setNewSource({ ...newSource, port: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>{newSource.type === "kafka" ? "Topic" : "数据库名称"}</Label>
|
||
<Input
|
||
placeholder={newSource.type === "kafka" ? "topic_name" : "database_name"}
|
||
value={newSource.database}
|
||
onChange={(e) => setNewSource({ ...newSource, database: e.target.value })}
|
||
/>
|
||
</div>
|
||
{newSource.type !== "kafka" && (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>用户名</Label>
|
||
<Input placeholder="root" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>密码</Label>
|
||
<Input type="password" placeholder="••••••••" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 p-6">
|
||
{/* Header */}
|
||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">数据源管理</h1>
|
||
<p className="text-gray-500 mt-1">配置与管理多源数据接入,支持数据库、API、消息队列等</p>
|
||
</div>
|
||
<Dialog
|
||
open={isAddDialogOpen}
|
||
onOpenChange={(open) => {
|
||
setIsAddDialogOpen(open)
|
||
if (!open) setAddStep(1)
|
||
}}
|
||
>
|
||
<DialogTrigger asChild>
|
||
<Button className="bg-gradient-to-r from-blue-500 to-purple-500 text-white">
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
新增数据源
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>新增数据源</DialogTitle>
|
||
<DialogDescription>{addStep === 1 ? "选择数据源类型并填写基本信息" : "配置连接参数"}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{addStep === 1 ? (
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label>数据源名称</Label>
|
||
<Input
|
||
placeholder="例如:用户主库"
|
||
value={newSource.name}
|
||
onChange={(e) => setNewSource({ ...newSource, name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>数据源类型</Label>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{[
|
||
{ type: "mysql", label: "MySQL", icon: Database, desc: "关系型数据库" },
|
||
{ type: "postgresql", label: "PostgreSQL", icon: Database, desc: "关系型数据库" },
|
||
{ type: "restapi", label: "REST API", icon: Globe, desc: "HTTP接口拉取" },
|
||
{ type: "webhook", label: "Webhook", icon: Link2, desc: "数据推送接收" },
|
||
{ type: "kafka", label: "Kafka", icon: Server, desc: "消息队列" },
|
||
{ type: "sftp", label: "SFTP", icon: FileText, desc: "文件传输" },
|
||
].map((item) => (
|
||
<button
|
||
key={item.type}
|
||
onClick={() => setNewSource({ ...newSource, type: item.type })}
|
||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||
newSource.type === item.type
|
||
? "border-blue-500 bg-blue-50"
|
||
: "border-gray-200 hover:border-gray-300"
|
||
}`}
|
||
>
|
||
<item.icon
|
||
className={`w-5 h-5 mb-1 ${newSource.type === item.type ? "text-blue-500" : "text-gray-400"}`}
|
||
/>
|
||
<div className="font-medium text-sm">{item.label}</div>
|
||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="py-4">{renderConfigForm()}</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
{addStep === 2 && (
|
||
<Button variant="outline" onClick={() => setAddStep(1)}>
|
||
上一步
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setIsAddDialogOpen(false)
|
||
setAddStep(1)
|
||
}}
|
||
>
|
||
取消
|
||
</Button>
|
||
{addStep === 1 ? (
|
||
<Button onClick={() => setAddStep(2)} disabled={!newSource.name}>
|
||
下一步
|
||
</Button>
|
||
) : (
|
||
<Button onClick={handleAddSource}>测试连接并添加</Button>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
|
||
{/* Stats - 添加API数据源统计 */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||
<Database className="w-4 h-4" />
|
||
总数据源
|
||
</div>
|
||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||
<Globe className="w-4 h-4 text-green-500" />
|
||
API接入
|
||
</div>
|
||
<div className="text-2xl font-bold text-green-600">{stats.apiCount}</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||
运行中
|
||
</div>
|
||
<div className="text-2xl font-bold text-green-600">{stats.running}</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||
异常
|
||
</div>
|
||
<div className="text-2xl font-bold text-red-600">{stats.error}</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||
<FileText className="w-4 h-4" />
|
||
总记录数
|
||
</div>
|
||
<div className="text-2xl font-bold text-gray-900">{formatNumber(stats.totalRecords)}</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Filters - 更新筛选选项 */}
|
||
<div className="flex flex-col md:flex-row gap-4">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||
<Input
|
||
placeholder="搜索数据源..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-10 bg-white/60 backdrop-blur-sm"
|
||
/>
|
||
</div>
|
||
<Select value={filterType} onValueChange={setFilterType}>
|
||
<SelectTrigger className="w-full md:w-48 bg-white/60 backdrop-blur-sm">
|
||
<SelectValue placeholder="筛选类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部类型</SelectItem>
|
||
<SelectItem value="mysql">MySQL</SelectItem>
|
||
<SelectItem value="postgresql">PostgreSQL</SelectItem>
|
||
<SelectItem value="restapi">REST API</SelectItem>
|
||
<SelectItem value="webhook">Webhook</SelectItem>
|
||
<SelectItem value="sftp">SFTP文件</SelectItem>
|
||
<SelectItem value="kafka">Kafka</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Data Source List */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{filteredSources.map((source) => {
|
||
const TypeIcon = TYPE_ICONS[source.type]
|
||
const StatusConfig = STATUS_CONFIG[source.status]
|
||
const StatusIcon = StatusConfig.icon
|
||
|
||
return (
|
||
<Card
|
||
key={source.id}
|
||
className="border-none shadow-sm bg-white/60 backdrop-blur-sm hover:shadow-md transition-shadow"
|
||
>
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className={`w-10 h-10 rounded-xl ${TYPE_COLORS[source.type]} flex items-center justify-center`}
|
||
>
|
||
<TypeIcon className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-gray-900">{source.name}</h3>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<Badge variant="secondary" className="text-xs">
|
||
{TYPE_LABELS[source.type]}
|
||
</Badge>
|
||
<Badge className={`text-xs ${StatusConfig.color}`}>
|
||
<StatusIcon className={`w-3 h-3 mr-1 ${source.status === "syncing" ? "animate-spin" : ""}`} />
|
||
{StatusConfig.label}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleToggleStatus(source.id)}
|
||
className="h-8 w-8 p-0"
|
||
>
|
||
{source.status === "running" ? (
|
||
<Pause className="w-4 h-4 text-gray-500" />
|
||
) : (
|
||
<Play className="w-4 h-4 text-green-500" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0"
|
||
onClick={() => {
|
||
setSelectedSource(source)
|
||
setSettingsDialogOpen(true)
|
||
}}
|
||
>
|
||
<Settings className="w-4 h-4 text-gray-500" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0"
|
||
onClick={() => {
|
||
setSelectedSource(source)
|
||
setDeleteDialogOpen(true)
|
||
}}
|
||
>
|
||
<Trash2 className="w-4 h-4 text-red-500" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-gray-500">同步模式:</span>
|
||
<span className="text-gray-900 ml-1">
|
||
{source.syncMode === "full" ? "全量" : source.syncMode === "realtime" ? "实时" : "增量"}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-500">频率:</span>
|
||
<span className="text-gray-900 ml-1">{source.frequency}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-500">记录数:</span>
|
||
<span className="text-gray-900 ml-1 font-medium">{formatNumber(source.records)}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-500">错误:</span>
|
||
<span className={`ml-1 font-medium ${source.errorCount > 0 ? "text-red-600" : "text-green-600"}`}>
|
||
{source.errorCount}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{(source.type === "restapi" || source.type === "webhook") && (
|
||
<div className="mt-3 p-2 bg-gray-50 rounded-lg">
|
||
<code className="text-xs text-gray-600">
|
||
{source.type === "restapi"
|
||
? `${source.config.method} ${source.config.endpoint}`
|
||
: source.config.webhookUrl}
|
||
</code>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||
<span className="flex items-center gap-1">
|
||
<Clock className="w-3 h-3" />
|
||
最后同步: {source.lastSync}
|
||
</span>
|
||
<Button
|
||
variant="link"
|
||
size="sm"
|
||
className="h-auto p-0 text-blue-500"
|
||
onClick={() => {
|
||
setSelectedSource(source)
|
||
setLogsDialogOpen(true)
|
||
}}
|
||
>
|
||
查看日志
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Settings Dialog */}
|
||
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>数据源设置 - {selectedSource?.name}</DialogTitle>
|
||
<DialogDescription>配置数据源连接和同步参数</DialogDescription>
|
||
</DialogHeader>
|
||
{selectedSource && (
|
||
<Tabs defaultValue="connection" className="py-4">
|
||
<TabsList>
|
||
<TabsTrigger value="connection">连接配置</TabsTrigger>
|
||
<TabsTrigger value="sync">同步设置</TabsTrigger>
|
||
<TabsTrigger value="mapping">字段映射</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="connection" className="space-y-4 mt-4">
|
||
{selectedSource.type === "restapi" ? (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label>API端点</Label>
|
||
<Input defaultValue={selectedSource.config.endpoint} />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>请求方法</Label>
|
||
<Select defaultValue={selectedSource.config.method}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="GET">GET</SelectItem>
|
||
<SelectItem value="POST">POST</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>认证方式</Label>
|
||
<Input defaultValue={selectedSource.config.auth} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : selectedSource.type === "webhook" ? (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label>Webhook URL</Label>
|
||
<Input defaultValue={selectedSource.config.webhookUrl} readOnly />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>签名密钥</Label>
|
||
<Input type="password" defaultValue={selectedSource.config.secret} />
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>主机地址</Label>
|
||
<Input defaultValue={selectedSource.config.host} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>端口</Label>
|
||
<Input defaultValue={selectedSource.config.port} />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>数据库/Topic</Label>
|
||
<Input defaultValue={selectedSource.config.database || selectedSource.config.topic} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
<TabsContent value="sync" className="space-y-4 mt-4">
|
||
<div className="space-y-2">
|
||
<Label>同步模式</Label>
|
||
<Select defaultValue={selectedSource.syncMode}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="full">全量同步</SelectItem>
|
||
<SelectItem value="incremental">增量同步</SelectItem>
|
||
<SelectItem value="realtime">实时同步</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>同步频率</Label>
|
||
<Select defaultValue="5min">
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="realtime">实时</SelectItem>
|
||
<SelectItem value="1min">每分钟</SelectItem>
|
||
<SelectItem value="5min">每5分钟</SelectItem>
|
||
<SelectItem value="30min">每30分钟</SelectItem>
|
||
<SelectItem value="1hour">每小时</SelectItem>
|
||
<SelectItem value="1day">每天</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</TabsContent>
|
||
<TabsContent value="mapping" className="mt-4">
|
||
<div className="p-4 bg-gray-50 rounded-lg text-center text-gray-500">
|
||
字段映射配置将在连接成功后自动加载
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
)}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={() => setSettingsDialogOpen(false)}>保存</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Logs Dialog */}
|
||
<Dialog open={logsDialogOpen} onOpenChange={setLogsDialogOpen}>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>同步日志 - {selectedSource?.name}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="max-h-96 overflow-y-auto">
|
||
<div className="space-y-2">
|
||
{syncLogs.map((log) => (
|
||
<div
|
||
key={log.id}
|
||
className={`flex items-start gap-3 p-3 rounded-lg text-sm ${
|
||
log.level === "success" ? "bg-green-50" : log.level === "warning" ? "bg-yellow-50" : "bg-gray-50"
|
||
}`}
|
||
>
|
||
<span className="text-gray-400 font-mono text-xs">{log.time}</span>
|
||
<span
|
||
className={`px-2 py-0.5 rounded text-xs ${
|
||
log.level === "success"
|
||
? "bg-green-100 text-green-700"
|
||
: log.level === "warning"
|
||
? "bg-yellow-100 text-yellow-700"
|
||
: "bg-gray-100 text-gray-700"
|
||
}`}
|
||
>
|
||
{log.level.toUpperCase()}
|
||
</span>
|
||
<span className="flex-1">{log.message}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setLogsDialogOpen(false)}>
|
||
关闭
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Delete Confirmation Dialog */}
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>确认删除</DialogTitle>
|
||
<DialogDescription>确定要删除数据源 "{selectedSource?.name}" 吗?此操作不可恢复。</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button variant="destructive" onClick={handleDeleteSource}>
|
||
确认删除
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|