415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Calendar,
|
|
Plus,
|
|
Play,
|
|
Pause,
|
|
RefreshCw,
|
|
Clock,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
Settings,
|
|
Database,
|
|
Zap,
|
|
Target,
|
|
} from "lucide-react"
|
|
|
|
// 任务接口
|
|
interface Task {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
type: 'sync' | 'clean' | 'tag' | 'export'
|
|
source: string
|
|
target: string
|
|
schedule: string
|
|
enabled: boolean
|
|
lastRun?: string
|
|
lastStatus?: 'success' | 'failed' | 'running'
|
|
nextRun?: string
|
|
processedCount?: number
|
|
}
|
|
|
|
// 预定义任务
|
|
const DEFAULT_TASKS: Task[] = [
|
|
{
|
|
id: 'task_1',
|
|
name: '腾讯QQ数据同步',
|
|
description: '从KR_腾讯同步QQ手机关联数据到用户估值表',
|
|
type: 'sync',
|
|
source: 'KR_腾讯',
|
|
target: 'KR.用户估值',
|
|
schedule: '每小时',
|
|
enabled: true,
|
|
lastRun: '10分钟前',
|
|
lastStatus: 'success',
|
|
nextRun: '50分钟后',
|
|
processedCount: 1250000
|
|
},
|
|
{
|
|
id: 'task_2',
|
|
name: '微博UID关联',
|
|
description: '从KR_微博同步UID与手机号关联',
|
|
type: 'sync',
|
|
source: 'KR_微博',
|
|
target: 'KR.用户估值',
|
|
schedule: '每天凌晨',
|
|
enabled: true,
|
|
lastRun: '昨天 02:00',
|
|
lastStatus: 'success',
|
|
nextRun: '明天 02:00',
|
|
processedCount: 5800000
|
|
},
|
|
{
|
|
id: 'task_3',
|
|
name: '存客宝CRM同步',
|
|
description: '从MySQL同步存客宝用户数据',
|
|
type: 'sync',
|
|
source: 'cunkebao_v3',
|
|
target: 'KR_存客宝.用户资产统一视图',
|
|
schedule: '每5分钟',
|
|
enabled: true,
|
|
lastRun: '2分钟前',
|
|
lastStatus: 'success',
|
|
nextRun: '3分钟后',
|
|
processedCount: 350
|
|
},
|
|
{
|
|
id: 'task_4',
|
|
name: '手机号格式化清洗',
|
|
description: '清洗手机号格式,补充归属地信息',
|
|
type: 'clean',
|
|
source: 'KR.用户估值',
|
|
target: 'KR.用户估值',
|
|
schedule: '每小时',
|
|
enabled: true,
|
|
lastRun: '30分钟前',
|
|
lastStatus: 'success',
|
|
processedCount: 95000
|
|
},
|
|
{
|
|
id: 'task_5',
|
|
name: 'RFM评分计算',
|
|
description: '计算用户RFM评分并更新用户等级',
|
|
type: 'tag',
|
|
source: 'KR.用户估值',
|
|
target: 'KR.用户估值',
|
|
schedule: '每天凌晨',
|
|
enabled: true,
|
|
lastRun: '昨天 03:00',
|
|
lastStatus: 'success',
|
|
processedCount: 20000000
|
|
},
|
|
{
|
|
id: 'task_6',
|
|
name: '流量包导出',
|
|
description: '导出高价值用户流量包',
|
|
type: 'export',
|
|
source: 'KR.用户估值',
|
|
target: '文件系统',
|
|
schedule: '按需',
|
|
enabled: false,
|
|
lastRun: '3天前',
|
|
lastStatus: 'success',
|
|
processedCount: 50000
|
|
},
|
|
]
|
|
|
|
export default function TaskSchedulePage() {
|
|
const [tasks, setTasks] = useState<Task[]>(DEFAULT_TASKS)
|
|
const [loading, setLoading] = useState(false)
|
|
const [showAddDialog, setShowAddDialog] = useState(false)
|
|
const [runningTask, setRunningTask] = useState<string | null>(null)
|
|
|
|
// 任务类型配置
|
|
const TASK_TYPES = {
|
|
sync: { label: '数据同步', color: 'bg-blue-100 text-blue-700', icon: RefreshCw },
|
|
clean: { label: '数据清洗', color: 'bg-green-100 text-green-700', icon: Zap },
|
|
tag: { label: '标签计算', color: 'bg-purple-100 text-purple-700', icon: Target },
|
|
export: { label: '数据导出', color: 'bg-orange-100 text-orange-700', icon: Database },
|
|
}
|
|
|
|
// 执行任务
|
|
const runTask = async (taskId: string) => {
|
|
setRunningTask(taskId)
|
|
// 模拟执行
|
|
await new Promise(r => setTimeout(r, 2000))
|
|
setTasks(tasks.map(t => t.id === taskId ? {
|
|
...t,
|
|
lastRun: '刚刚',
|
|
lastStatus: 'success' as const
|
|
} : t))
|
|
setRunningTask(null)
|
|
}
|
|
|
|
// 切换任务状态
|
|
const toggleTask = (taskId: string) => {
|
|
setTasks(tasks.map(t => t.id === taskId ? { ...t, enabled: !t.enabled } : t))
|
|
}
|
|
|
|
const formatNumber = (num: number): string => {
|
|
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
|
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`
|
|
return num.toLocaleString()
|
|
}
|
|
|
|
const getStatusIcon = (status?: string) => {
|
|
switch (status) {
|
|
case 'success':
|
|
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
case 'failed':
|
|
return <XCircle className="h-4 w-4 text-red-500" />
|
|
case 'running':
|
|
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
|
default:
|
|
return <Clock className="h-4 w-4 text-gray-400" />
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
|
|
<div className="p-6 space-y-6">
|
|
{/* 顶部标题 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">任务调度</h1>
|
|
<p className="text-sm text-gray-500 mt-1">管理数据同步、清洗、计算等定时任务</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="outline" onClick={() => setLoading(true)}>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
刷新
|
|
</Button>
|
|
<Button onClick={() => setShowAddDialog(true)}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
新建任务
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 统计卡片 */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<Card className="border-0 shadow-sm bg-white/80">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">任务总数</p>
|
|
<p className="text-2xl font-bold">{tasks.length}</p>
|
|
</div>
|
|
<Calendar className="h-8 w-8 text-blue-500 opacity-50" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-0 shadow-sm bg-white/80">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">运行中</p>
|
|
<p className="text-2xl font-bold text-green-600">{tasks.filter(t => t.enabled).length}</p>
|
|
</div>
|
|
<Play className="h-8 w-8 text-green-500 opacity-50" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-0 shadow-sm bg-white/80">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">今日执行</p>
|
|
<p className="text-2xl font-bold">128</p>
|
|
</div>
|
|
<CheckCircle2 className="h-8 w-8 text-purple-500 opacity-50" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-0 shadow-sm bg-white/80">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">处理数据</p>
|
|
<p className="text-2xl font-bold">{formatNumber(tasks.reduce((sum, t) => sum + (t.processedCount || 0), 0))}</p>
|
|
</div>
|
|
<Database className="h-8 w-8 text-orange-500 opacity-50" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 任务列表 */}
|
|
<div className="space-y-3">
|
|
{tasks.map((task) => {
|
|
const typeConfig = TASK_TYPES[task.type]
|
|
const TypeIcon = typeConfig.icon
|
|
const isRunning = runningTask === task.id
|
|
|
|
return (
|
|
<Card key={task.id} className="border-0 shadow-sm bg-white/80">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`p-2.5 rounded-xl ${typeConfig.color.replace('text-', 'bg-').replace('-700', '-100')}`}>
|
|
<TypeIcon className={`h-5 w-5 ${typeConfig.color.split(' ')[1]}`} />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-gray-900">{task.name}</h3>
|
|
<Badge className={typeConfig.color}>{typeConfig.label}</Badge>
|
|
{!task.enabled && <Badge variant="outline" className="text-gray-500">已暂停</Badge>}
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-0.5">{task.description}</p>
|
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
|
<span className="flex items-center gap-1">
|
|
<Database className="h-3 w-3" />
|
|
{task.source} → {task.target}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{task.schedule}
|
|
</span>
|
|
{task.lastRun && (
|
|
<span className="flex items-center gap-1">
|
|
{getStatusIcon(task.lastStatus)}
|
|
上次: {task.lastRun}
|
|
</span>
|
|
)}
|
|
{task.processedCount && (
|
|
<span>处理: {formatNumber(task.processedCount)} 条</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
checked={task.enabled}
|
|
onCheckedChange={() => toggleTask(task.id)}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => runTask(task.id)}
|
|
disabled={isRunning}
|
|
>
|
|
{isRunning ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Play className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
<Button variant="ghost" size="sm">
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* 新建任务弹窗 */}
|
|
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>新建任务</DialogTitle>
|
|
<DialogDescription>创建新的数据处理任务</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>任务名称</Label>
|
|
<Input placeholder="输入任务名称" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>任务类型</Label>
|
|
<Select defaultValue="sync">
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sync">数据同步</SelectItem>
|
|
<SelectItem value="clean">数据清洗</SelectItem>
|
|
<SelectItem value="tag">标签计算</SelectItem>
|
|
<SelectItem value="export">数据导出</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>执行频率</Label>
|
|
<Select defaultValue="hourly">
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="5min">每5分钟</SelectItem>
|
|
<SelectItem value="hourly">每小时</SelectItem>
|
|
<SelectItem value="daily">每天</SelectItem>
|
|
<SelectItem value="manual">手动触发</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>数据源</Label>
|
|
<Select>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="选择数据源" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="kr_tencent">KR_腾讯</SelectItem>
|
|
<SelectItem value="kr_weibo">KR_微博</SelectItem>
|
|
<SelectItem value="kr_ckb">KR_存客宝</SelectItem>
|
|
<SelectItem value="kr_valuation">KR.用户估值</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>目标</Label>
|
|
<Select>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="选择目标" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="kr_valuation">KR.用户估值</SelectItem>
|
|
<SelectItem value="kr_ckb_view">KR_存客宝.用户资产统一视图</SelectItem>
|
|
<SelectItem value="file">文件导出</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowAddDialog(false)}>取消</Button>
|
|
<Button onClick={() => setShowAddDialog(false)}>创建任务</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|