async task framework

This commit is contained in:
ryan
2026-06-07 23:16:46 +08:00
parent 70a13dc107
commit 589ae08318
32 changed files with 3509 additions and 182 deletions
@@ -0,0 +1,405 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import Link from "next/link"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, Layers, RefreshCw, RotateCcw} from "lucide-react"
import {AdminService, TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services"
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: "重试",
}
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 [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [executionsLoading, setExecutionsLoading] = useState(false)
const [executionsError, setExecutionsError] = useState<Error | null>(null)
const [executions, setExecutions] = useState<TaskExecution[]>([])
const [executionsTotal, setExecutionsTotal] = useState(0)
const [executionsPage, setExecutionsPage] = useState(1)
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
const [selectedExecution, setSelectedExecution] = useState<TaskExecution | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [retrying, setRetrying] = useState(false)
const fetchTaskTypes = useCallback(async () => {
try {
const data = await AdminService.getTaskTypes()
setTaskTypes(data)
} catch {
setTaskTypes([])
}
}, [])
const fetchTaskExecutions = useCallback(async (overrides?: {
page?: number
status?: TaskExecutionStatus | "all"
taskType?: string
}) => {
try {
setExecutionsLoading(true)
setExecutionsError(null)
const page = overrides?.page ?? executionsPage
const status = overrides?.status ?? executionStatus
const taskType = overrides?.taskType ?? executionTaskType
const data = await AdminService.listTaskExecutions({
page,
page_size: 10,
status: status === "all" ? undefined : status,
task_type: taskType === "all" ? undefined : taskType,
})
setExecutions(data.items || [])
setExecutionsTotal(data.total || 0)
} catch (err) {
setExecutionsError(err instanceof Error ? err : new Error("加载任务执行记录失败"))
} finally {
setExecutionsLoading(false)
}
}, [executionStatus, executionTaskType, executionsPage])
useEffect(() => {
fetchTaskTypes()
}, [fetchTaskTypes])
useEffect(() => {
fetchTaskExecutions()
}, [fetchTaskExecutions])
useEffect(() => {
setExecutionsPage(1)
}, [executionStatus, executionTaskType])
const openExecutionDetail = async (execution: TaskExecution) => {
setSelectedExecution(execution)
setDetailOpen(true)
try {
setDetailLoading(true)
const detail = await AdminService.getTaskExecution(execution.id)
setSelectedExecution(detail)
} catch (err) {
toast.error("任务详情加载失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setDetailLoading(false)
}
}
const handleRetryExecution = async () => {
if (!selectedExecution) return
try {
setRetrying(true)
const taskID = await AdminService.retryTaskExecution(selectedExecution.id)
toast.success("任务已重新下发", {
description: `新任务 ID:${ taskID }`,
})
await fetchTaskExecutions()
} catch (err) {
toast.error("任务重试失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setRetrying(false)
}
}
const totalPages = Math.max(1, Math.ceil(executionsTotal / 10))
return (
<div className="py-6 space-y-6">
<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 items-center gap-2">
<Activity className="size-5 text-muted-foreground" />
<div>
<div className="text-2xl font-semibold tracking-tight">任务日志</div>
<div className="text-xs text-muted-foreground">查看异步任务执行状况,点击任务记录查看详情</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/tasks">
<Layers className="size-4" />
任务管理
</Link>
</Button>
<Select value={executionStatus} onValueChange={(value) => setExecutionStatus(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={setExecutionTaskType}>
<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={() => fetchTaskExecutions()} 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={fetchTaskExecutions} 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">
{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">
{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={() => selectedExecution && openExecutionDetail(selectedExecution)} disabled={!selectedExecution || detailLoading}>
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新详情
</Button>
{selectedExecution?.status === "failed" && selectedExecution.retryable && selectedExecution.retry_count < selectedExecution.max_retry && (
<Button onClick={handleRetryExecution} disabled={retrying}>
{retrying ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
重试任务
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
+30 -25
View File
@@ -1,30 +1,27 @@
"use client"
import { useState, useEffect, useCallback } 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 { Spinner } from "@/components/ui/spinner"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Layers, Play, Clock, Info } from "lucide-react"
import {useCallback, useEffect, useState} from "react"
import Link from "next/link"
import {useRouter} from "next/navigation"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Calendar as CalendarIcon, Clock, FileText, Info, Layers, Play} from "lucide-react"
import { TaskMeta, AdminService, DispatchTaskRequest } from "@/lib/services"
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 {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services"
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 as CalendarIcon } from "lucide-react"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
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': {
@@ -133,6 +130,7 @@ function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate
}
export function TaskManager() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
@@ -183,16 +181,17 @@ export function TaskManager() {
if (userId) params.user_id = userId
}
await AdminService.dispatchTask(params)
const taskID = await AdminService.dispatchTask(params)
toast.success('任务下发成功', {
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列`
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
})
setDialogOpen(false)
setStartTime(undefined)
setEndTime(undefined)
setUserId("")
router.push("/admin/tasks/executions")
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
@@ -217,6 +216,12 @@ export function TaskManager() {
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/tasks/executions">
<FileText className="size-4" />
任务日志
</Link>
</Button>
</div>
<div className="space-y-6">