refactor(frontend): colocate admin components and split service layer

Move route-specific admin components from components/common/admin into
app/(main)/admin/<feature>/components/. Split AdminService god object into
domain services, fix UploadService to use BaseService, add AdminUploadService,
consolidate DB export into DbManageService, and migrate consumers to the
unified services entry.
This commit is contained in:
ryan
2026-06-18 10:55:14 +08:00
parent de8a21a49f
commit beae8d2dd9
56 changed files with 594 additions and 888 deletions
@@ -0,0 +1,374 @@
"use client"
import {useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
import services from "@/lib/services"
import type {TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"
import {Spinner} from "@/components/ui/spinner"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: "等待中",
running: "执行中",
succeeded: "成功",
failed: "失败",
}
const TRIGGER_LABELS: Record<string, string> = {
system: "系统",
manual: "手动",
retry: "重试",
schedule: "定时",
}
const PAGE_SIZE = 10
function formatDateTime(value?: string | null) {
if (!value) return "-"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return format(date, "yyyy-MM-dd HH:mm:ss")
}
function formatDuration(duration: number) {
if (!duration) return "-"
if (duration < 1000) return `${ duration }ms`
return `${ (duration / 1000).toFixed(2) }s`
}
function statusVariant(status: TaskExecutionStatus) {
if (status === "failed") return "destructive"
if (status === "succeeded") return "secondary"
return "outline"
}
export function TaskExecutionsManager() {
const queryClient = useQueryClient()
const [executionsPage, setExecutionsPage] = useState(1)
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null)
const [executionPreview, setExecutionPreview] = useState<TaskExecution | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryFn: () => services.adminTask.getTaskTypes(),
})
const executionsQuery = useQuery({
queryKey: ["admin", "task-executions", executionsPage, executionStatus, executionTaskType],
queryFn: () => services.adminTask.listTaskExecutions({
page: executionsPage,
page_size: PAGE_SIZE,
status: executionStatus === "all" ? undefined : executionStatus,
task_type: executionTaskType === "all" ? undefined : executionTaskType,
}),
})
const detailQuery = useQuery({
queryKey: ["admin", "task-execution", selectedExecutionId],
queryFn: () => services.adminTask.getTaskExecution(selectedExecutionId!),
enabled: detailOpen && !!selectedExecutionId,
})
const retryMutation = useMutation({
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success("任务已重新下发", {
description: `新任务 ID:${ taskID }`,
})
void queryClient.invalidateQueries({ queryKey: ["admin", "task-executions"] })
},
onError: (err: Error) => {
toast.error("任务重试失败", {
description: err.message || "未知错误",
})
},
})
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? []
const executions = executionsQuery.data?.items ?? []
const executionsTotal = executionsQuery.data?.total ?? 0
const executionsLoading = executionsQuery.isPending || executionsQuery.isFetching
const executionsError = executionsQuery.isError ? executionsQuery.error : null
const selectedExecution = detailQuery.data ?? executionPreview
const detailLoading = detailQuery.isFetching
const totalPages = useMemo(
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
[executionsTotal],
)
const openExecutionDetail = (execution: TaskExecution) => {
setExecutionPreview(execution)
setSelectedExecutionId(execution.id)
setDetailOpen(true)
}
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
setExecutionStatus(value)
setExecutionsPage(1)
}
const handleTaskTypeChange = (value: string) => {
setExecutionTaskType(value)
setExecutionsPage(1)
}
return (
<div className="space-y-6">
<br/>
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Select value={executionStatus} onValueChange={(value) => handleStatusChange(value as TaskExecutionStatus | "all")}>
<SelectTrigger size="sm" className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending">等待中</SelectItem>
<SelectItem value="running">执行中</SelectItem>
<SelectItem value="succeeded">成功</SelectItem>
<SelectItem value="failed">失败</SelectItem>
</SelectContent>
</Select>
<Select value={executionTaskType} onValueChange={handleTaskTypeChange}>
<SelectTrigger size="sm" className="w-[180px]">
<SelectValue placeholder="任务类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部任务</SelectItem>
{taskTypes.map((task) => (
<SelectItem key={task.type} value={task.type}>{task.name || task.type}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => void executionsQuery.refetch()} disabled={executionsLoading}>
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新
</Button>
</div>
</div>
{executionsError ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={executionsError} onRetry={() => void executionsQuery.refetch()} className="justify-center" />
</div>
) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
) : executions.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
) : (
<div className="rounded-lg border bg-card">
<Table className="min-w-[900px]">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[180px]">任务</TableHead>
<TableHead className="w-[100px]">状态</TableHead>
<TableHead className="w-[110px]">触发</TableHead>
<TableHead className="w-[120px]">重试</TableHead>
<TableHead className="w-[120px]">耗时</TableHead>
<TableHead className="min-w-[220px]">结果/错误</TableHead>
<TableHead className="w-[170px]">创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((execution) => (
<TableRow
key={execution.id}
className="cursor-pointer"
onClick={() => openExecutionDetail(execution)}
>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{execution.task_name || execution.task_type}</span>
<span className="font-mono text-[11px] text-muted-foreground">{execution.task_id}</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{execution.retry_count}/{execution.max_retry}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatDuration(execution.duration)}
</TableCell>
<TableCell className="max-w-[320px] truncate text-xs text-muted-foreground">
{execution.error_message || execution.result || "-"}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{formatDateTime(execution.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
disabled={executionsPage <= 1 || executionsLoading}
>
<ChevronLeft className="size-4" />
上一页
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
disabled={executionsPage >= totalPages || executionsLoading}
>
下一页
<ChevronRight className="size-4" />
</Button>
</div>
</div>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className="w-full p-0 sm:max-w-[640px]">
<SheetHeader className="border-b">
<SheetTitle>任务执行详情</SheetTitle>
<SheetDescription>
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
{detailLoading && !selectedExecution ? (
<LoadingStateWithBorder icon={Activity} description="加载任务详情中..." />
) : selectedExecution ? (
<div className="space-y-5 py-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">状态</div>
<div className="mt-2">
<Badge variant={statusVariant(selectedExecution.status)}>
{STATUS_LABELS[selectedExecution.status] || selectedExecution.status}
</Badge>
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">触发来源</div>
<div className="mt-2 text-sm font-medium">
{TRIGGER_LABELS[selectedExecution.triggered_by] || selectedExecution.triggered_by}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">重试次数</div>
<div className="mt-2 font-mono text-sm">
{selectedExecution.retry_count}/{selectedExecution.max_retry}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-xs text-muted-foreground">耗时</div>
<div className="mt-2 font-mono text-sm">
{formatDuration(selectedExecution.duration)}
</div>
</div>
</div>
<div className="grid gap-2">
<Label>任务标识</Label>
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all">
{selectedExecution.task_id}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<Label>创建时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.created_at)}</div>
</div>
<div className="grid gap-2">
<Label>开始时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
</div>
<div className="grid gap-2">
<Label>结束时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
</div>
<div className="grid gap-2">
<Label>更新时间</Label>
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
</div>
</div>
<div className="grid gap-2">
<Label>执行结果</Label>
<div className="min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all">
{selectedExecution.result || "-"}
</div>
</div>
{selectedExecution.error_message && (
<div className="grid gap-2">
<Label>错误信息</Label>
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all">
{selectedExecution.error_message}
</div>
</div>
)}
<div className="grid gap-2">
<Label>Payload</Label>
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
{selectedExecution.payload || "{}"}
</pre>
</div>
<div className="grid gap-2">
<Label>执行日志</Label>
<pre className="min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap">
{selectedExecution.log || "暂无日志"}
</pre>
</div>
</div>
) : (
<EmptyStateWithBorder icon={Activity} description="未选择任务记录" />
)}
</div>
<SheetFooter className="border-t">
<Button variant="outline" onClick={() => void detailQuery.refetch()} disabled={!selectedExecutionId || detailLoading}>
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新详情
</Button>
{selectedExecution && selectedExecution.status === "failed" && selectedExecution.retryable && (
<Button onClick={() => retryMutation.mutate(selectedExecution.id)} disabled={retryMutation.isPending}>
{retryMutation.isPending ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
重试任务
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
@@ -0,0 +1,444 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
import type {DispatchTaskRequest, TaskMeta} from "@/lib/services/admin"
import services from "@/lib/services"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import {Badge} from "@/components/ui/badge"
import {cn} from "@/lib/utils"
import {format} from "date-fns"
import {zhCN} from "date-fns/locale"
import {Calendar} from "@/components/ui/calendar"
import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
'order_sync': {
icon: Layers,
color: "text-blue-600 dark:text-blue-400",
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
},
'user_gamification': {
icon: Play,
color: "text-amber-600 dark:text-amber-400",
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
},
'dispute_auto_refund': {
icon: Clock,
color: "text-rose-600 dark:text-rose-400",
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
}
}
const DEFAULT_TASK_CONFIG = {
icon: Layers,
color: "text-zinc-600 dark:text-zinc-400",
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
}
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
const timeString = date ? format(date, "HH:mm:ss") : ""
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) {
setDate(undefined)
return
}
const updated = new Date(newDate)
if (date) {
updated.setHours(date.getHours())
updated.setMinutes(date.getMinutes())
updated.setSeconds(date.getSeconds())
} else {
updated.setHours(0, 0, 0, 0)
}
setDate(updated)
}
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value
if (!newTime) return
if (date) {
const [hours, minutes, seconds] = newTime.split(':').map(Number)
const updated = new Date(date)
updated.setHours(hours || 0)
updated.setMinutes(minutes || 0)
updated.setSeconds(seconds || 0)
setDate(updated)
} else {
const today = new Date()
const [hours, minutes, seconds] = newTime.split(':').map(Number)
today.setHours(hours || 0)
today.setMinutes(minutes || 0)
today.setSeconds(seconds || 0)
setDate(today)
}
}
return (
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal text-xs h-8",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-1 size-3" />
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
</div>
<div className="w-[120px]">
<Input
type="time"
step="1"
value={timeString}
onChange={handleTimeChange}
className="text-xs font-mono"
/>
</div>
</div>
)
}
export function TaskManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [dispatching, setDispatching] = useState(false)
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
const [userId, setUserId] = useState("")
const [paramValues, setParamValues] = useState<Record<string, string>>({})
const fetchTaskTypes = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await services.adminTask.getTaskTypes()
setTaskTypes(data)
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchTaskTypes()
}, [fetchTaskTypes])
useEffect(() => {
if (selectedTaskType) {
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
if (targetTask?.params) {
const initialValues: Record<string, string> = {}
targetTask.params.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
} else {
setParamValues({})
}
}
}, [selectedTaskType, taskTypes])
const handleDispatch = async () => {
if (!selectedTaskType) return
try {
setDispatching(true)
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const params: DispatchTaskRequest = {
task_type: selectedTaskType
}
if (targetTask?.supports_time) {
if (startTime) params.start_time = startTime.toISOString()
if (endTime) params.end_time = endTime.toISOString()
}
if (targetTask?.type === 'user_gamification') {
if (userId) params.user_id = userId
}
// Handle dynamic parameters — type coercion (number vs string) is
// centralised in buildTaskPayload; do not inline it here.
if (targetTask?.params && targetTask.params.length > 0) {
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
if (error) {
toast.error(error)
setDispatching(false)
return
}
params.payload = payload ?? undefined
}
const taskID = await services.adminTask.dispatchTask(params)
toast.success('任务下发成功', {
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
})
setDialogOpen(false)
setStartTime(undefined)
setEndTime(undefined)
setUserId("")
setParamValues({})
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setDispatching(false)
}
}
const openDispatchDialog = (type: string) => {
setSelectedTaskType(type)
setDialogOpen(true)
}
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
return (
<div className="space-y-6">
<br/>
<div className="space-y-6">
{error ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
</div>
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{taskTypes.map((task, index) => {
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
return (
<div
key={`${ task.type }-${ index }`}
className={cn(
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
config.gradient
)}
>
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
{task.description}
</p>
</div>
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
{task.queue}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
类型:{task.type}
</Badge>
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
重试:{task.max_retry}
</Badge>
</div>
</div>
<div className="pt-4 mt-1">
<Button
className="w-full h-7 text-xs"
variant="secondary"
onClick={() => openDispatchDialog(task.type)}
>
<Play className="size-3 mr-1" />
立即执行
</Button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>执行任务</Label>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
{(() => {
const meta = getSelectedTaskMeta()
if (!meta) return null
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
const Icon = config.icon
return (
<>
<div className={cn("p-2", config.color)}>
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium">{meta.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
</div>
</>
)
})()}
</div>
</div>
{(() => {
const targetTask = getSelectedTaskMeta()
if (targetTask?.supports_time) {
return (
<>
<div className="grid gap-2">
<Label>开始时间</Label>
<DatePickerWithTime date={startTime} setDate={setStartTime} />
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
</div>
<div className="grid gap-2">
<Label>结束时间</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
</div>
</>
)
}
return null
})()}
{(() => {
const targetTask = getSelectedTaskMeta()
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
return null
}
return (
<div className="space-y-4">
{targetTask.params.map((param) => (
<div key={param.name} className="grid gap-2">
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1">
{param.label}
{param.required && <span className="text-destructive font-bold">*</span>}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className="text-xs min-h-[80px]"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
) : param.type === 'boolean' ? (
<div className="flex items-center gap-2 pt-1 h-9">
<Switch
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
/>
<span className="text-xs text-muted-foreground">
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
</span>
</div>
) : (
<Input
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className="text-xs"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
)}
{param.description && (
<p className="text-[10px] text-muted-foreground">{param.description}</p>
)}
</div>
))}
</div>
)
})()}
{(() => {
const targetTask = getSelectedTaskMeta()
const hasTime = targetTask?.supports_time
const hasParams = targetTask?.params && targetTask.params.length > 0
if (!hasTime && !hasParams) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4" />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
</div>
)
}
return null
})()}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
{dispatching ? '下发中' : '启动'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,473 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Textarea} from "@/components/ui/textarea"
import {Switch} from "@/components/ui/switch"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
import type {CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services/admin"
import services from "@/lib/services"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
export function TaskSchedulesManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [schedules, setSchedules] = useState<Schedule[]>([])
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
// Modal States
const [dialogOpen, setDialogOpen] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null)
// Delete States
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
// Form Fields
const [name, setName] = useState("")
const [selectedTaskType, setSelectedTaskType] = useState("")
const [cron, setCron] = useState("")
const [isActive, setIsActive] = useState(true)
const [paramValues, setParamValues] = useState<Record<string, string>>({})
// Fetch Schedules & Task Types
const fetchData = useCallback(async () => {
try {
setLoading(true)
setError(null)
const [schedulesData, taskTypesData] = await Promise.all([
services.adminTask.listSchedules(),
services.adminTask.getTaskTypes()
])
setSchedules(schedulesData || [])
setTaskTypes(taskTypesData || [])
} catch (err) {
setError(err instanceof Error ? err : new Error("加载定时任务配置失败"))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Track task type selection to initialize parameters
useEffect(() => {
if (selectedTaskType) {
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
if (editingSchedule && editingSchedule.task_type === selectedTaskType) {
// Parse existing payload when editing the same task type
try {
const parsed = JSON.parse(editingSchedule.payload || "{}")
const values: Record<string, string> = {}
targetTask?.params?.forEach(p => {
values[p.name] = String(parsed[p.name] ?? "")
})
setParamValues(values)
} catch {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
}
} else {
const initialValues: Record<string, string> = {}
targetTask?.params?.forEach(p => {
initialValues[p.name] = ""
})
setParamValues(initialValues)
}
} else {
setParamValues({})
}
}, [selectedTaskType, taskTypes, editingSchedule])
const handleToggleActive = async (schedule: Schedule) => {
try {
const updated = await services.adminTask.updateSchedule(schedule.id, {
name: schedule.name,
task_type: schedule.task_type,
cron: schedule.cron,
payload: schedule.payload,
is_active: !schedule.is_active
})
setSchedules(prev => prev.map(s => s.id === schedule.id ? updated : s))
toast.success(updated.is_active ? `已启用定时任务:${updated.name}` : `已停用定时任务:${updated.name}`)
} catch (err) {
toast.error("修改任务状态失败", {
description: err instanceof Error ? err.message : "未知错误"
})
}
}
const openCreateDialog = () => {
setEditingSchedule(null)
setName("")
setSelectedTaskType(taskTypes[0]?.type || "")
setCron("0 */2 * * *")
setIsActive(true)
setParamValues({})
setDialogOpen(true)
}
const openEditDialog = (schedule: Schedule) => {
setEditingSchedule(schedule)
setName(schedule.name)
setSelectedTaskType(schedule.task_type)
setCron(schedule.cron)
setIsActive(schedule.is_active)
setDialogOpen(true)
}
const handleSubmit = async () => {
if (!name.trim()) {
toast.error("任务名称不能为空")
return
}
if (!cron.trim()) {
toast.error("Cron 表达式不能为空")
return
}
try {
setSubmitLoading(true)
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
let payloadStr = '{}'
if (targetTask?.params && targetTask.params.length > 0) {
const { payload, error } = buildTaskPayload(targetTask.params, paramValues)
if (error) {
toast.error(error)
setSubmitLoading(false)
return
}
payloadStr = payload ?? '{}'
}
if (editingSchedule) {
const req: UpdateScheduleRequest = {
name,
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const updated = await services.adminTask.updateSchedule(editingSchedule.id, req)
setSchedules(prev => prev.map(s => s.id === editingSchedule.id ? updated : s))
toast.success("定时任务更新成功")
} else {
const req: CreateScheduleRequest = {
name,
task_type: selectedTaskType,
cron,
payload: payloadStr,
is_active: isActive
}
const created = await services.adminTask.createSchedule(req)
setSchedules(prev => [created, ...prev])
toast.success("定时任务创建成功")
}
setDialogOpen(false)
} catch (err) {
toast.error("提交失败", {
description: err instanceof Error ? err.message : "未知错误"
})
} finally {
setSubmitLoading(false)
}
}
const openDeleteDialog = (id: string) => {
setDeletingId(id)
setDeleteOpen(true)
}
const handleDelete = async () => {
if (!deletingId) return
try {
setDeleteLoading(true)
await services.adminTask.deleteSchedule(deletingId)
setSchedules(prev => prev.filter(s => s.id !== deletingId))
toast.success("定时任务删除成功")
setDeleteOpen(false)
} catch (err) {
toast.error("删除失败", {
description: err instanceof Error ? err.message : "未知错误"
})
} finally {
setDeleteLoading(false)
}
}
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
const getTaskName = (type: string) => {
const meta = taskTypes.find(t => t.type === type)
return meta ? meta.name : type
}
return (
<div className="space-y-6">
<br/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新
</Button>
<Button size="sm" onClick={openCreateDialog} disabled={taskTypes.length === 0} variant={'secondary'}>
<Plus className="size-4 mr-1" />
新增定时任务
</Button>
</div>
</div>
{error ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={error} onRetry={fetchData} className="justify-center" />
</div>
) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder icon={Clock} description="加载定时任务配置中..." />
) : schedules.length === 0 ? (
<EmptyStateWithBorder icon={Clock} description="暂无定时任务配置,点击上方按钮新增" />
) : (
<div className="rounded-lg border bg-card">
<Table className="min-w-[800px]">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[180px]">任务名称</TableHead>
<TableHead className="w-[160px]">关联异步任务</TableHead>
<TableHead className="w-[120px]">Cron 表达式</TableHead>
<TableHead className="min-w-[200px]">执行参数 (Payload)</TableHead>
<TableHead className="w-[100px] text-center">启用状态</TableHead>
<TableHead className="w-[120px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell className="font-medium">{schedule.name}</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">{getTaskName(schedule.task_type)}</span>
<span className="font-mono text-[10px] text-muted-foreground">{schedule.task_type}</span>
</div>
</TableCell>
<TableCell className="font-mono text-xs text-blue-600 dark:text-blue-400 font-semibold">
{schedule.cron}
</TableCell>
<TableCell className="max-w-[300px] truncate">
<code className="text-xs bg-muted/60 px-1 py-0.5 rounded font-mono">
{schedule.payload || "{}"}
</code>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<Switch
checked={schedule.is_active}
onCheckedChange={() => handleToggleActive(schedule)}
/>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => openEditDialog(schedule)}
>
<Edit2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive/90"
onClick={() => openDeleteDialog(schedule.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Add / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{editingSchedule ? "修改定时任务" : "新增定时任务"}</DialogTitle>
<DialogDescription>
配置定时任务的调度参数和运行载荷。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="sched-name">任务名称</Label>
<Input
id="sched-name"
placeholder="例如:每日数据清理"
value={name}
onChange={(e) => setName(e.target.value)}
className="text-xs"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-type">关联异步任务</Label>
<select
id="sched-type"
value={selectedTaskType}
onChange={(e) => setSelectedTaskType(e.target.value)}
disabled={!!editingSchedule}
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{taskTypes.map(t => (
<option key={t.type} value={t.type}>
{t.name} ({t.type})
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="sched-cron">Cron 表达式</Label>
<Input
id="sched-cron"
placeholder="e.g. 0 */2 * * * 或 @daily"
value={cron}
onChange={(e) => setCron(e.target.value)}
className="text-xs font-mono"
/>
<p className="text-[10px] text-muted-foreground">使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="sched-active">启用状态</Label>
<p className="text-[10px] text-muted-foreground">决定此定时任务是否会定时触发执行。</p>
</div>
<Switch
id="sched-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
{(() => {
const targetTask = getSelectedTaskMeta()
if (!targetTask || !targetTask.params || targetTask.params.length === 0) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4 shrink-0" />
<span>所选任务类型不需要运行参数参数。</span>
</div>
)
}
return (
<div className="space-y-4 pt-2 border-t">
<Label className="text-xs font-semibold">运行参数配置 (Payload)</Label>
{targetTask.params.map((param) => (
<div key={param.name} className="grid gap-2 pl-2 border-l-2 border-muted">
<Label htmlFor={`param-${param.name}`} className="flex items-center gap-1 text-xs">
{param.label}
{param.required && <span className="text-destructive font-bold">*</span>}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className="text-xs min-h-[70px]"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
) : param.type === 'boolean' ? (
<div className="flex items-center gap-2 pt-1 h-9">
<Switch
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) => setParamValues(prev => ({ ...prev, [param.name]: checked ? 'true' : 'false' }))}
/>
<span className="text-xs text-muted-foreground">
{paramValues[param.name] === 'true' ? '开启' : '关闭'}
</span>
</div>
) : (
<Input
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className="text-xs"
value={paramValues[param.name] || ""}
onChange={(e) => setParamValues(prev => ({ ...prev, [param.name]: e.target.value }))}
/>
)}
{param.description && (
<p className="text-[10px] text-muted-foreground">{param.description}</p>
)}
</div>
))}
</div>
)
})()}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={submitLoading} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleSubmit} disabled={submitLoading} className="h-8 text-xs">
{submitLoading && <Spinner className="size-3 mr-1" />}
{editingSchedule ? '保存修改' : '确认创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>删除定时任务</DialogTitle>
<DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={() => setDeleteOpen(false)} disabled={deleteLoading} className="h-8 text-xs">
取消
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteLoading} className="h-8 text-xs">
{deleteLoading && <Spinner className="size-3 mr-1" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}