78d1c88ca1
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
451 lines
12 KiB
TypeScript
451 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>
|
||
);
|
||
}
|