refactor: restructure navigation and module layout

Reorganize navigation and module structure based on new requirements.

Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
v0
2026-01-31 04:32:36 +00:00
parent 22e725887a
commit b17b488f8e
105 changed files with 20530 additions and 3622 deletions

View File

@@ -0,0 +1,348 @@
"use client"
import { useState } from "react"
import { Zap, Plus, Play, CheckCircle, Clock, AlertTriangle, FileText, Settings, Code, ArrowRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CreateCleaningRuleDialog } from "@/components/dialogs/create-cleaning-rule-dialog"
import { CleaningRuleSettingsDialog } from "@/components/dialogs/cleaning-rule-settings-dialog"
import { CleaningRuleLogsDialog } from "@/components/dialogs/cleaning-rule-logs-dialog"
interface CleaningRule {
id: string
name: string
description: string
type: "dedup" | "format" | "mapping" | "validate" | "transform"
sourceTable: string
targetTable: string
status: "active" | "inactive" | "testing"
lastRun: string
processedRecords: number
errorRate: number
}
const MOCK_RULES: CleaningRule[] = [
{
id: "1",
name: "用户手机号标准化",
description: "统一手机号格式,去除+86前缀和空格",
type: "format",
sourceTable: "raw_users",
targetTable: "clean_users",
status: "active",
lastRun: "2025-12-12 14:30:00",
processedRecords: 12895634,
errorRate: 0.02,
},
{
id: "2",
name: "交易记录去重",
description: "基于交易ID和时间戳去除重复记录",
type: "dedup",
sourceTable: "raw_transactions",
targetTable: "clean_transactions",
status: "active",
lastRun: "2025-12-12 14:25:00",
processedRecords: 89234567,
errorRate: 0.01,
},
{
id: "3",
name: "城市编码映射",
description: "将城市名称映射为标准行政区划代码",
type: "mapping",
sourceTable: "raw_users",
targetTable: "clean_users",
status: "active",
lastRun: "2025-12-12 14:20:00",
processedRecords: 45678901,
errorRate: 0.05,
},
{
id: "4",
name: "金额字段校验",
description: "校验金额字段非负且在合理范围内",
type: "validate",
sourceTable: "raw_transactions",
targetTable: "clean_transactions",
status: "testing",
lastRun: "2025-12-12 10:00:00",
processedRecords: 1000000,
errorRate: 0.12,
},
{
id: "5",
name: "时间戳转换",
description: "将各种时间格式统一转换为UTC时间戳",
type: "transform",
sourceTable: "raw_events",
targetTable: "clean_events",
status: "active",
lastRun: "2025-12-12 14:35:00",
processedRecords: 256789012,
errorRate: 0.001,
},
]
const TYPE_CONFIG = {
dedup: { label: "去重", color: "bg-blue-100 text-blue-700" },
format: { label: "格式化", color: "bg-green-100 text-green-700" },
mapping: { label: "映射", color: "bg-purple-100 text-purple-700" },
validate: { label: "校验", color: "bg-yellow-100 text-yellow-700" },
transform: { label: "转换", color: "bg-orange-100 text-orange-700" },
}
const PRESETS = [
{ name: "去除首尾空格", code: "TRIM(column)" },
{ name: "转大写", code: "UPPER(column)" },
{ name: "转小写", code: "LOWER(column)" },
{ name: "日期格式化", code: "DATE_FORMAT(column, '%Y-%m-%d')" },
{ name: "空值填充", code: "COALESCE(column, default_value)" },
{ name: "正则替换", code: "REGEXP_REPLACE(column, pattern, replacement)" },
]
export default function DataCleaningPage() {
const [rules, setRules] = useState<CleaningRule[]>(MOCK_RULES)
const [selectedTab, setSelectedTab] = useState("rules")
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showLogsDialog, setShowLogsDialog] = useState(false)
const [selectedRule, setSelectedRule] = useState<CleaningRule | null>(null)
const stats = {
totalRules: rules.length,
activeRules: rules.filter((r) => r.status === "active").length,
totalProcessed: rules.reduce((sum, r) => sum + r.processedRecords, 0),
avgErrorRate: (rules.reduce((sum, r) => sum + r.errorRate, 0) / rules.length).toFixed(3),
}
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 handleSettings = (rule: CleaningRule) => {
setSelectedRule(rule)
setShowSettingsDialog(true)
}
const handleViewLogs = (rule: CleaningRule) => {
setSelectedRule(rule)
setShowLogsDialog(true)
}
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"></p>
</div>
<Button
className="bg-gradient-to-r from-blue-500 to-purple-500 text-white"
onClick={() => setShowCreateDialog(true)}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 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">
<FileText className="w-4 h-4" />
</div>
<div className="text-2xl font-bold text-gray-900">{stats.totalRules}</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.activeRules}</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">
<Zap className="w-4 h-4" />
</div>
<div className="text-2xl font-bold text-gray-900">{formatNumber(stats.totalProcessed)}</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-yellow-500" />
</div>
<div className="text-2xl font-bold text-gray-900">{stats.avgErrorRate}%</div>
</CardContent>
</Card>
</div>
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
<TabsList className="bg-white/60 backdrop-blur-sm">
<TabsTrigger value="rules"></TabsTrigger>
<TabsTrigger value="presets"></TabsTrigger>
<TabsTrigger value="sql">SQL编辑器</TabsTrigger>
</TabsList>
<TabsContent value="rules" className="mt-4">
<div className="space-y-4">
{rules.map((rule) => (
<Card key={rule.id} className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-gray-900">{rule.name}</h3>
<Badge className={TYPE_CONFIG[rule.type].color}>{TYPE_CONFIG[rule.type].label}</Badge>
<Badge
variant="outline"
className={
rule.status === "active"
? "border-green-500 text-green-600"
: rule.status === "testing"
? "border-yellow-500 text-yellow-600"
: "border-gray-500 text-gray-600"
}
>
{rule.status === "active" ? "启用" : rule.status === "testing" ? "测试中" : "停用"}
</Badge>
</div>
<p className="text-sm text-gray-500 mb-3">{rule.description}</p>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-500">:</span>
<code className="bg-gray-100 px-2 py-0.5 rounded text-xs">{rule.sourceTable}</code>
<ArrowRight className="w-3 h-3 text-gray-400" />
<code className="bg-gray-100 px-2 py-0.5 rounded text-xs">{rule.targetTable}</code>
</div>
<div>
<span className="text-gray-500">:</span>
<span className="ml-1 font-medium">{formatNumber(rule.processedRecords)}</span>
</div>
<div>
<span className="text-gray-500">:</span>
<span
className={`ml-1 font-medium ${rule.errorRate > 0.1 ? "text-red-600" : "text-green-600"}`}
>
{rule.errorRate}%
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Play className="w-4 h-4 mr-1" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleSettings(rule)}>
<Settings className="w-4 h-4" />
</Button>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
: {rule.lastRun}
</span>
<Button
variant="link"
size="sm"
className="h-auto p-0 text-blue-500"
onClick={() => handleViewLogs(rule)}
>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="presets" className="mt-4">
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{PRESETS.map((preset, i) => (
<div key={i} className="p-4 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer">
<h4 className="font-medium text-gray-900 mb-2">{preset.name}</h4>
<code className="text-xs text-gray-600 bg-white px-2 py-1 rounded block">{preset.code}</code>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="sql" className="mt-4">
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Code className="w-5 h-5" />
SQL清洗规则编辑器
</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-gray-900 rounded-xl p-4 font-mono text-sm text-green-400 min-h-[200px]">
<pre>{`-- 示例:用户数据清洗
SELECT
user_id,
TRIM(LOWER(email)) as email,
REGEXP_REPLACE(phone, '[^0-9]', '') as phone,
COALESCE(city, '未知') as city,
DATE_FORMAT(created_at, '%Y-%m-%d') as created_date
FROM raw_users
WHERE email IS NOT NULL
AND LENGTH(phone) >= 11;`}</pre>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline"></Button>
<Button></Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Create Cleaning Rule Dialog */}
<CreateCleaningRuleDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
<CleaningRuleSettingsDialog
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
rule={
selectedRule
? {
id: selectedRule.id,
name: selectedRule.name,
description: selectedRule.description,
type: selectedRule.type,
sourceTable: selectedRule.sourceTable,
targetTable: selectedRule.targetTable,
status: selectedRule.status,
}
: undefined
}
/>
<CleaningRuleLogsDialog open={showLogsDialog} onOpenChange={setShowLogsDialog} ruleName={selectedRule?.name} />
</div>
)
}

View File

@@ -0,0 +1,12 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export default function DataGovernancePage() {
const router = useRouter()
useEffect(() => {
router.replace("/data-governance/sources")
}, [router])
return null
}

View File

@@ -0,0 +1,265 @@
"use client"
import { useState } from "react"
import { Shield, TrendingUp, TrendingDown, RefreshCw, Download } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface QualityMetric {
name: string
score: number
trend: "up" | "down" | "stable"
change: number
description: string
}
interface TableQuality {
tableName: string
completeness: number
uniqueness: number
timeliness: number
accuracy: number
overallScore: number
issues: number
lastCheck: string
}
const QUALITY_METRICS: QualityMetric[] = [
{ name: "完整性", score: 96.8, trend: "up", change: 0.5, description: "字段非空率" },
{ name: "唯一性", score: 99.2, trend: "stable", change: 0, description: "主键重复率" },
{ name: "及时性", score: 94.5, trend: "down", change: -1.2, description: "数据更新延迟" },
{ name: "准确性", score: 97.3, trend: "up", change: 0.8, description: "格式校验通过率" },
]
const TABLE_QUALITY: TableQuality[] = [
{
tableName: "users",
completeness: 98.5,
uniqueness: 100,
timeliness: 96.2,
accuracy: 99.1,
overallScore: 98.5,
issues: 2,
lastCheck: "2025-12-12 14:30:00",
},
{
tableName: "transactions",
completeness: 99.8,
uniqueness: 99.9,
timeliness: 98.5,
accuracy: 99.5,
overallScore: 99.4,
issues: 0,
lastCheck: "2025-12-12 14:30:00",
},
{
tableName: "user_behaviors",
completeness: 92.3,
uniqueness: 98.5,
timeliness: 89.2,
accuracy: 95.6,
overallScore: 93.9,
issues: 8,
lastCheck: "2025-12-12 14:25:00",
},
{
tableName: "accounts",
completeness: 97.2,
uniqueness: 100,
timeliness: 95.8,
accuracy: 98.3,
overallScore: 97.8,
issues: 1,
lastCheck: "2025-12-12 14:30:00",
},
{
tableName: "tags",
completeness: 100,
uniqueness: 100,
timeliness: 100,
accuracy: 100,
overallScore: 100,
issues: 0,
lastCheck: "2025-12-12 14:30:00",
},
]
export default function DataQualityPage() {
const [selectedPeriod, setSelectedPeriod] = useState("today")
const [isRefreshing, setIsRefreshing] = useState(false)
const [overallScore, setOverallScore] = useState(96.95)
const getScoreColor = (score: number) => {
if (score >= 95) return "text-green-600"
if (score >= 80) return "text-yellow-600"
return "text-red-600"
}
const getScoreBg = (score: number) => {
if (score >= 95) return "bg-green-100"
if (score >= 80) return "bg-yellow-100"
return "bg-red-100"
}
const refreshData = async () => {
setIsRefreshing(true)
await new Promise((r) => setTimeout(r, 1500))
setOverallScore((prev) => prev + (Math.random() - 0.5) * 0.5)
setIsRefreshing(false)
}
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"></p>
</div>
<div className="flex items-center gap-3">
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-32 bg-white/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="today"></SelectItem>
<SelectItem value="week"></SelectItem>
<SelectItem value="month"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={refreshData} disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* Overall Score */}
<Card className="border-none shadow-lg bg-gradient-to-br from-green-500 to-emerald-600 text-white">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-green-100 mb-2">
<Shield className="w-5 h-5" />
<span></span>
</div>
<div className="text-6xl font-bold">{overallScore.toFixed(1)}</div>
<p className="text-green-100 mt-2">100</p>
</div>
<div className="hidden md:block">
<div className="w-32 h-32 rounded-full border-8 border-white/20 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl font-bold">A+</div>
<div className="text-xs text-green-100"></div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Quality Metrics */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{QUALITY_METRICS.map((metric, i) => (
<Card key={i} className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">{metric.name}</span>
<div className="flex items-center gap-1">
{metric.trend === "up" ? (
<TrendingUp className="w-4 h-4 text-green-500" />
) : metric.trend === "down" ? (
<TrendingDown className="w-4 h-4 text-red-500" />
) : null}
<span
className={`text-xs ${metric.change > 0 ? "text-green-600" : metric.change < 0 ? "text-red-600" : "text-gray-500"}`}
>
{metric.change > 0 ? "+" : ""}
{metric.change}%
</span>
</div>
</div>
<div className={`text-3xl font-bold ${getScoreColor(metric.score)}`}>{metric.score}%</div>
<p className="text-xs text-gray-400 mt-1">{metric.description}</p>
<Progress value={metric.score} className="h-2 mt-2" />
</CardContent>
</Card>
))}
</div>
{/* Table Quality Details */}
<Card className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500"></th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500"></th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500"></th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500"></th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500"></th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500"></th>
<th className="text-center py-3 px-4 text-sm font-medium text-gray-500"></th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500"></th>
</tr>
</thead>
<tbody>
{TABLE_QUALITY.map((table, i) => (
<tr key={i} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<code className="text-sm font-medium text-gray-900">{table.tableName}</code>
</td>
<td className="py-3 px-4 text-center">
<span className={`text-sm font-medium ${getScoreColor(table.completeness)}`}>
{table.completeness}%
</span>
</td>
<td className="py-3 px-4 text-center">
<span className={`text-sm font-medium ${getScoreColor(table.uniqueness)}`}>
{table.uniqueness}%
</span>
</td>
<td className="py-3 px-4 text-center">
<span className={`text-sm font-medium ${getScoreColor(table.timeliness)}`}>
{table.timeliness}%
</span>
</td>
<td className="py-3 px-4 text-center">
<span className={`text-sm font-medium ${getScoreColor(table.accuracy)}`}>{table.accuracy}%</span>
</td>
<td className="py-3 px-4 text-center">
<Badge className={`${getScoreBg(table.overallScore)} ${getScoreColor(table.overallScore)}`}>
{table.overallScore}
</Badge>
</td>
<td className="py-3 px-4 text-center">
{table.issues > 0 ? (
<Badge variant="destructive">{table.issues}</Badge>
) : (
<Badge variant="secondary" className="bg-green-100 text-green-700">
0
</Badge>
)}
</td>
<td className="py-3 px-4 text-right text-xs text-gray-500">{table.lastCheck}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,933 @@
"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>
)
}

View File

@@ -0,0 +1,314 @@
"use client"
import { useState, useEffect } from "react"
import { Clock, Play, Pause, CheckCircle, XCircle, RefreshCw, Calendar, Settings, MoreHorizontal } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { ScheduleCalendarDialog } from "@/components/dialogs/schedule-calendar-dialog"
import { TaskDetailDialog } from "@/components/dialogs/task-detail-dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
interface ScheduledTask {
id: string
name: string
type: "sync" | "cleaning" | "tagging" | "model"
schedule: string
status: "running" | "completed" | "failed" | "waiting" | "paused"
progress: number
nextRun: string
lastRun: string
duration: string
logs: string[]
}
const MOCK_TASKS: ScheduledTask[] = [
{
id: "1",
name: "用户数据同步",
type: "sync",
schedule: "*/5 * * * *",
status: "running",
progress: 67,
nextRun: "-",
lastRun: "2025-12-12 14:30:00",
duration: "2分30秒",
logs: ["正在同步数据...", "已处理 67%"],
},
{
id: "2",
name: "交易数据清洗",
type: "cleaning",
schedule: "0 */1 * * *",
status: "completed",
progress: 100,
nextRun: "2025-12-12 15:00:00",
lastRun: "2025-12-12 14:00:00",
duration: "5分12秒",
logs: ["清洗完成,处理 89234567 条记录"],
},
{
id: "3",
name: "用户标签计算",
type: "tagging",
schedule: "0 2 * * *",
status: "waiting",
progress: 0,
nextRun: "2025-12-13 02:00:00",
lastRun: "2025-12-12 02:00:00",
duration: "15分38秒",
logs: ["等待执行"],
},
{
id: "4",
name: "CLV模型训练",
type: "model",
schedule: "0 3 * * 0",
status: "failed",
progress: 45,
nextRun: "2025-12-15 03:00:00",
lastRun: "2025-12-08 03:00:00",
duration: "-",
logs: ["错误:内存不足", "任务中断于 45%"],
},
{
id: "5",
name: "行为数据同步",
type: "sync",
schedule: "实时",
status: "running",
progress: 100,
nextRun: "-",
lastRun: "2025-12-12 14:35:12",
duration: "持续运行",
logs: ["Kafka消费正常", "当前lag: 125"],
},
]
const TYPE_CONFIG = {
sync: { label: "数据同步", color: "bg-blue-100 text-blue-700" },
cleaning: { label: "数据清洗", color: "bg-green-100 text-green-700" },
tagging: { label: "标签计算", color: "bg-purple-100 text-purple-700" },
model: { label: "模型训练", color: "bg-orange-100 text-orange-700" },
}
const STATUS_CONFIG = {
running: { label: "运行中", color: "bg-blue-100 text-blue-700", icon: RefreshCw },
completed: { label: "已完成", color: "bg-green-100 text-green-700", icon: CheckCircle },
failed: { label: "失败", color: "bg-red-100 text-red-700", icon: XCircle },
waiting: { label: "等待中", color: "bg-gray-100 text-gray-700", icon: Clock },
paused: { label: "已暂停", color: "bg-yellow-100 text-yellow-700", icon: Pause },
}
export default function TaskSchedulePage() {
const [tasks, setTasks] = useState<ScheduledTask[]>(MOCK_TASKS)
const [currentTime, setCurrentTime] = useState(new Date())
const [showCalendarDialog, setShowCalendarDialog] = useState(false)
const [showDetailDialog, setShowDetailDialog] = useState(false)
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date())
// 模拟进度更新
setTasks((prev) =>
prev.map((task) => {
if (task.status === "running" && task.progress < 100) {
return { ...task, progress: Math.min(task.progress + Math.random() * 2, 100) }
}
return task
}),
)
}, 2000)
return () => clearInterval(interval)
}, [])
const stats = {
total: tasks.length,
running: tasks.filter((t) => t.status === "running").length,
completed: tasks.filter((t) => t.status === "completed").length,
failed: tasks.filter((t) => t.status === "failed").length,
}
const handleViewDetail = (task: ScheduledTask) => {
setSelectedTask(task)
setShowDetailDialog(true)
}
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"></p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="px-3 py-1">
<Clock className="w-3 h-3 mr-1" />
{currentTime.toLocaleTimeString()}
</Badge>
<Button variant="outline" onClick={() => setShowCalendarDialog(true)}>
<Calendar className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 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">
<Settings 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">
<RefreshCw className="w-4 h-4 text-blue-500" />
</div>
<div className="text-2xl font-bold text-blue-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">
<CheckCircle className="w-4 h-4 text-green-500" />
</div>
<div className="text-2xl font-bold text-green-600">{stats.completed}</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">
<XCircle className="w-4 h-4 text-red-500" />
</div>
<div className="text-2xl font-bold text-red-600">{stats.failed}</div>
</CardContent>
</Card>
</div>
{/* Task List */}
<div className="space-y-4">
{tasks.map((task) => {
const StatusIcon = STATUS_CONFIG[task.status].icon
return (
<Card key={task.id} className="border-none shadow-sm bg-white/60 backdrop-blur-sm">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-gray-900">{task.name}</h3>
<Badge className={TYPE_CONFIG[task.type].color}>{TYPE_CONFIG[task.type].label}</Badge>
<Badge className={STATUS_CONFIG[task.status].color}>
<StatusIcon className={`w-3 h-3 mr-1 ${task.status === "running" ? "animate-spin" : ""}`} />
{STATUS_CONFIG[task.status].label}
</Badge>
</div>
{task.status === "running" && (
<div className="mb-3">
<div className="flex items-center justify-between text-sm text-gray-500 mb-1">
<span></span>
<span>{Math.round(task.progress)}%</span>
</div>
<Progress value={task.progress} className="h-2" />
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">:</span>
<code className="ml-1 text-xs bg-gray-100 px-1 rounded">{task.schedule}</code>
</div>
<div>
<span className="text-gray-500">:</span>
<span className="ml-1 text-gray-900">{task.nextRun}</span>
</div>
<div>
<span className="text-gray-500">:</span>
<span className="ml-1 text-gray-900">{task.lastRun}</span>
</div>
<div>
<span className="text-gray-500">:</span>
<span className="ml-1 text-gray-900">{task.duration}</span>
</div>
</div>
{task.logs.length > 0 && (
<div className="mt-3 p-2 bg-gray-50 rounded-lg">
<div className="text-xs text-gray-500 mb-1">:</div>
{task.logs.slice(-2).map((log, i) => (
<div key={i} className="text-xs text-gray-600">
{log}
</div>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{task.status === "running" ? (
<Button variant="outline" size="sm">
<Pause className="w-4 h-4" />
</Button>
) : (
<Button variant="outline" size="sm">
<Play className="w-4 h-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewDetail(task)}></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem className="text-red-600"></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* Schedule Calendar Dialog */}
<ScheduleCalendarDialog open={showCalendarDialog} onOpenChange={setShowCalendarDialog} />
<TaskDetailDialog
open={showDetailDialog}
onOpenChange={setShowDetailDialog}
task={
selectedTask
? {
id: selectedTask.id,
name: selectedTask.name,
type: selectedTask.type,
status: selectedTask.status,
progress: selectedTask.progress,
schedule: selectedTask.schedule,
lastRun: selectedTask.lastRun,
nextRun: selectedTask.nextRun,
duration: selectedTask.duration,
}
: undefined
}
/>
</div>
)
}