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:
348
app/data-governance/cleaning/page.tsx
Normal file
348
app/data-governance/cleaning/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
app/data-governance/page.tsx
Normal file
12
app/data-governance/page.tsx
Normal 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
|
||||
}
|
||||
265
app/data-governance/quality/page.tsx
Normal file
265
app/data-governance/quality/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
app/data-governance/sources/loading.tsx
Normal file
3
app/data-governance/sources/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
933
app/data-governance/sources/page.tsx
Normal file
933
app/data-governance/sources/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
314
app/data-governance/tasks/page.tsx
Normal file
314
app/data-governance/tasks/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user