0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/**
|
||
* 任务中心页面
|
||
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
|
||
*/
|
||
import { useState } from "react"
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||
import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd"
|
||
import {
|
||
CheckCircleOutlined,
|
||
ClockCircleOutlined,
|
||
SyncOutlined,
|
||
CloseCircleOutlined,
|
||
ExclamationCircleOutlined,
|
||
MinusCircleOutlined,
|
||
RedoOutlined,
|
||
InfoCircleOutlined,
|
||
} from "@ant-design/icons"
|
||
import type { ColumnsType } from "antd/es/table"
|
||
import {
|
||
getTasks,
|
||
retryTask,
|
||
type TaskItem,
|
||
type TaskStatus,
|
||
type TaskListParams,
|
||
} from "@/api/tasks"
|
||
import "./tasks.css"
|
||
|
||
/* ──────────── 常量 ──────────── */
|
||
|
||
/** 状态 Tab 配置 */
|
||
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
|
||
{ key: "all", label: "全部" },
|
||
{ key: "waiting", label: "等待中" },
|
||
{ key: "running", label: "进行中" },
|
||
{ key: "completed", label: "已完成" },
|
||
{ key: "failed", label: "失败" },
|
||
{ key: "cancelled", label: "已取消" },
|
||
]
|
||
|
||
/** 类型筛选选项 */
|
||
const TYPE_OPTIONS = [
|
||
{ value: "all", label: "全部类型" },
|
||
{ value: "generation", label: "生成任务" },
|
||
{ value: "ingest", label: "素材导入" },
|
||
]
|
||
|
||
/** 状态标签配置 */
|
||
const STATUS_CONFIG: Record<TaskStatus, { label: string; color: string; icon: React.ReactNode }> = {
|
||
pending: {
|
||
label: "等待中",
|
||
color: "default",
|
||
icon: <ClockCircleOutlined />,
|
||
},
|
||
waiting: {
|
||
label: "排队中",
|
||
color: "processing",
|
||
icon: <SyncOutlined spin />,
|
||
},
|
||
running: {
|
||
label: "进行中",
|
||
color: "processing",
|
||
icon: <SyncOutlined spin />,
|
||
},
|
||
completed: {
|
||
label: "已完成",
|
||
color: "success",
|
||
icon: <CheckCircleOutlined />,
|
||
},
|
||
failed: {
|
||
label: "失败",
|
||
color: "error",
|
||
icon: <CloseCircleOutlined />,
|
||
},
|
||
cancelled: {
|
||
label: "已取消",
|
||
color: "default",
|
||
icon: <MinusCircleOutlined />,
|
||
},
|
||
}
|
||
|
||
/** 任务类型标签 */
|
||
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
||
generation: { label: "生成任务", color: "blue" },
|
||
ingest: { label: "素材导入", color: "green" },
|
||
}
|
||
|
||
/* ──────────── 工具函数 ──────────── */
|
||
|
||
/** 格式化耗时 */
|
||
const formatDuration = (seconds?: number): string => {
|
||
if (!seconds) return "-"
|
||
if (seconds < 60) return `${Math.round(seconds)}秒`
|
||
const minutes = Math.floor(seconds / 60)
|
||
const secs = Math.round(seconds % 60)
|
||
if (minutes < 60) return `${minutes}分${secs}秒`
|
||
const hours = Math.floor(minutes / 60)
|
||
const mins = minutes % 60
|
||
return `${hours}小时${mins}分`
|
||
}
|
||
|
||
/** 格式化时间 */
|
||
const formatTime = (dateStr?: string | null): string => {
|
||
if (!dateStr) return "-"
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleString("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})
|
||
}
|
||
|
||
/* ──────────── 主组件 ──────────── */
|
||
|
||
export default function TaskCenter() {
|
||
const queryClient = useQueryClient()
|
||
|
||
// 筛选状态
|
||
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all")
|
||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(20)
|
||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null)
|
||
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(null)
|
||
|
||
// 查询参数
|
||
const queryParams: TaskListParams = {
|
||
page,
|
||
page_size: pageSize,
|
||
...(statusFilter !== "all" && { status: statusFilter }),
|
||
...(typeFilter !== "all" && { task_type: typeFilter }),
|
||
}
|
||
|
||
// 获取任务列表
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ["tasks", queryParams],
|
||
queryFn: () => getTasks(queryParams),
|
||
refetchInterval: (query) => {
|
||
// 有进行中的任务时自动刷新
|
||
const tasks = query.state.data?.items ?? []
|
||
const hasRunning = tasks.some((t) => t.status === "running" || t.status === "waiting")
|
||
return hasRunning ? 5000 : false
|
||
},
|
||
})
|
||
|
||
// 重试任务
|
||
const retryMutation = useMutation({
|
||
mutationFn: retryTask,
|
||
onSuccess: () => {
|
||
message.success("任务已重新提交")
|
||
queryClient.invalidateQueries({ queryKey: ["tasks"] })
|
||
},
|
||
onError: () => {
|
||
message.error("重试失败,请检查任务状态")
|
||
},
|
||
})
|
||
|
||
// 展开查看详情
|
||
const handleExpand = async (expanded: boolean, record: TaskItem) => {
|
||
if (!expanded) {
|
||
setExpandedTaskId(null)
|
||
setExpandedTaskDetail(null)
|
||
return
|
||
}
|
||
setExpandedTaskId(record.id)
|
||
// 如果是失败任务,获取详情(含 error_info)
|
||
if (record.status === "failed" && record.error_info) {
|
||
setExpandedTaskDetail(record)
|
||
}
|
||
}
|
||
|
||
// 表格列定义
|
||
const columns: ColumnsType<TaskItem> = [
|
||
{
|
||
title: "任务ID",
|
||
dataIndex: "id",
|
||
key: "id",
|
||
width: 120,
|
||
ellipsis: true,
|
||
render: (id: string) => (
|
||
<Tooltip title={id}>
|
||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||
</Tooltip>
|
||
),
|
||
},
|
||
{
|
||
title: "类型",
|
||
dataIndex: "task_type",
|
||
key: "task_type",
|
||
width: 100,
|
||
render: (type: string) => {
|
||
const config = TYPE_LABELS[type] || { label: type, color: "default" }
|
||
return <Tag color={config.color}>{config.label}</Tag>
|
||
},
|
||
},
|
||
{
|
||
title: "状态",
|
||
dataIndex: "status",
|
||
key: "status",
|
||
width: 120,
|
||
render: (status: TaskStatus, record: TaskItem) => {
|
||
const config = STATUS_CONFIG[status] || {
|
||
label: status,
|
||
color: "default",
|
||
icon: null,
|
||
}
|
||
return (
|
||
<Tag color={config.color} icon={config.icon} className="task-status-tag">
|
||
{config.label}
|
||
{status === "running" && record.progress > 0 && (
|
||
<span className="task-progress"> {record.progress}%</span>
|
||
)}
|
||
</Tag>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: "当前步骤",
|
||
dataIndex: "current_step",
|
||
key: "current_step",
|
||
width: 150,
|
||
ellipsis: true,
|
||
render: (step: string) => <span className="task-step">{step || "-"}</span>,
|
||
},
|
||
{
|
||
title: "耗时",
|
||
dataIndex: "duration_seconds",
|
||
key: "duration_seconds",
|
||
width: 100,
|
||
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
|
||
},
|
||
{
|
||
title: "创建时间",
|
||
dataIndex: "created_at",
|
||
key: "created_at",
|
||
width: 120,
|
||
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
|
||
},
|
||
{
|
||
title: "操作",
|
||
key: "action",
|
||
width: 100,
|
||
fixed: "right",
|
||
render: (_: unknown, record: TaskItem) => {
|
||
if (record.status === "failed" && record.retryable) {
|
||
return (
|
||
<Popconfirm
|
||
title="确认重试"
|
||
description="确定要重试这个失败的任务吗?"
|
||
onConfirm={() => retryMutation.mutate(record.id)}
|
||
okText="确定"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<RedoOutlined />}
|
||
loading={retryMutation.isPending}
|
||
className="task-retry-btn"
|
||
>
|
||
重试
|
||
</Button>
|
||
</Popconfirm>
|
||
)
|
||
}
|
||
if (record.status === "failed") {
|
||
return (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<InfoCircleOutlined />}
|
||
onClick={() => {
|
||
setExpandedTaskId(record.id)
|
||
setExpandedTaskDetail(record)
|
||
}}
|
||
>
|
||
详情
|
||
</Button>
|
||
)
|
||
}
|
||
return <span className="task-action-placeholder">-</span>
|
||
},
|
||
},
|
||
]
|
||
|
||
// 展开行渲染(错误详情)
|
||
const expandedRowRender = (record: TaskItem) => {
|
||
const detail = expandedTaskDetail || record
|
||
const errorInfo = detail.error_info
|
||
|
||
if (!errorInfo && !detail.error_message) {
|
||
return <div className="task-expand-empty">暂无错误详情</div>
|
||
}
|
||
|
||
return (
|
||
<div className="task-error-detail">
|
||
<div className="task-error-header">
|
||
<ExclamationCircleOutlined className="task-error-icon" />
|
||
<span>错误详情</span>
|
||
</div>
|
||
<div className="task-error-body">
|
||
{errorInfo?.error_type && (
|
||
<div className="task-error-row">
|
||
<span className="task-error-label">错误类型:</span>
|
||
<Tag color="error">{errorInfo.error_type}</Tag>
|
||
</div>
|
||
)}
|
||
{(errorInfo?.error_message || detail.error_message) && (
|
||
<div className="task-error-row">
|
||
<span className="task-error-label">错误信息:</span>
|
||
<span className="task-error-message">
|
||
{errorInfo?.error_message || detail.error_message}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{errorInfo?.failed_step && (
|
||
<div className="task-error-row">
|
||
<span className="task-error-label">失败阶段:</span>
|
||
<span>{errorInfo.failed_step}</span>
|
||
</div>
|
||
)}
|
||
{errorInfo?.stack_trace && (
|
||
<div className="task-error-row task-error-stack">
|
||
<span className="task-error-label">堆栈信息:</span>
|
||
<pre>{errorInfo.stack_trace}</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 错误处理
|
||
if (error) {
|
||
return (
|
||
<div className="task-center">
|
||
<div className="task-error">
|
||
<CloseCircleOutlined />
|
||
<p>加载任务列表失败</p>
|
||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="task-center">
|
||
{/* 页面标题 */}
|
||
<div className="task-header">
|
||
<h1 className="task-title">任务中心</h1>
|
||
<p className="task-subtitle">查看和管理所有生成任务与素材导入任务</p>
|
||
</div>
|
||
|
||
{/* 筛选栏 */}
|
||
<div className="task-filters">
|
||
{/* 状态 Tab */}
|
||
<Tabs
|
||
activeKey={statusFilter}
|
||
onChange={(key) => {
|
||
setStatusFilter(key as TaskStatus | "all")
|
||
setPage(1)
|
||
}}
|
||
items={STATUS_TABS.map((tab) => ({
|
||
key: tab.key,
|
||
label: tab.label,
|
||
}))}
|
||
className="task-status-tabs"
|
||
/>
|
||
|
||
{/* 类型筛选 */}
|
||
<div className="task-type-filter">
|
||
<Select
|
||
value={typeFilter}
|
||
onChange={(value) => {
|
||
setTypeFilter(value)
|
||
setPage(1)
|
||
}}
|
||
options={TYPE_OPTIONS}
|
||
style={{ width: 140 }}
|
||
placeholder="选择类型"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 任务表格 */}
|
||
<Table
|
||
columns={columns}
|
||
dataSource={data?.items || []}
|
||
rowKey="id"
|
||
loading={isLoading}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total || 0,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
onChange: (p, ps) => {
|
||
setPage(p)
|
||
setPageSize(ps)
|
||
},
|
||
}}
|
||
expandable={{
|
||
expandedRowRender,
|
||
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
|
||
onExpand: handleExpand,
|
||
rowExpandable: (record) =>
|
||
record.status === "failed" && (!!record.error_info || !!record.error_message),
|
||
}}
|
||
scroll={{ x: 800 }}
|
||
className="task-table"
|
||
locale={{
|
||
emptyText: (
|
||
<div className="task-empty">
|
||
<ClockCircleOutlined />
|
||
<p>暂无任务记录</p>
|
||
</div>
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|