Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d601dfbc3 | |||
| a3e098b8da |
Regular → Executable
+39
-386
@@ -2,333 +2,32 @@
|
|||||||
* 任务中心页面
|
* 任务中心页面
|
||||||
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
|
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
|
||||||
*/
|
*/
|
||||||
import { useState } from "react"
|
import { Button } from "antd"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { CloseCircleOutlined } from "@ant-design/icons"
|
||||||
import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd"
|
import { TaskFilterBar } from "./components/TaskFilterBar"
|
||||||
import {
|
import { TaskTable } from "./components/TaskTable"
|
||||||
CheckCircleOutlined,
|
import { useTaskList } from "./hooks/useTaskList"
|
||||||
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"
|
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() {
|
export default function TaskCenter() {
|
||||||
const queryClient = useQueryClient()
|
const {
|
||||||
|
statusFilter,
|
||||||
// 筛选状态
|
typeFilter,
|
||||||
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,
|
||||||
page_size: pageSize,
|
pageSize,
|
||||||
...(statusFilter !== "all" && { status: statusFilter }),
|
expandedTaskId,
|
||||||
...(typeFilter !== "all" && { task_type: typeFilter }),
|
expandedTaskDetail,
|
||||||
}
|
data,
|
||||||
|
isLoading,
|
||||||
// 获取任务列表
|
error,
|
||||||
const { data, isLoading, error } = useQuery({
|
retryLoading,
|
||||||
queryKey: ["tasks", queryParams],
|
handleStatusChange,
|
||||||
queryFn: () => getTasks(queryParams),
|
handleTypeChange,
|
||||||
refetchInterval: (query) => {
|
handlePageChange,
|
||||||
// 有进行中的任务时自动刷新
|
handleExpand,
|
||||||
const tasks = query.state.data?.items ?? []
|
handleViewDetail,
|
||||||
const hasRunning = tasks.some((t) => t.status === "running" || t.status === "waiting")
|
handleRetry,
|
||||||
return hasRunning ? 5000 : false
|
} = useTaskList()
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 重试任务
|
|
||||||
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) {
|
if (error) {
|
||||||
@@ -351,72 +50,26 @@ export default function TaskCenter() {
|
|||||||
<p className="task-subtitle">查看和管理所有生成任务与素材导入任务</p>
|
<p className="task-subtitle">查看和管理所有生成任务与素材导入任务</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
<TaskFilterBar
|
||||||
<div className="task-filters">
|
statusFilter={statusFilter}
|
||||||
{/* 状态 Tab */}
|
typeFilter={typeFilter}
|
||||||
<Tabs
|
onStatusChange={handleStatusChange}
|
||||||
activeKey={statusFilter}
|
onTypeChange={handleTypeChange}
|
||||||
onChange={(key) => {
|
/>
|
||||||
setStatusFilter(key as TaskStatus | "all")
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
items={STATUS_TABS.map((tab) => ({
|
|
||||||
key: tab.key,
|
|
||||||
label: tab.label,
|
|
||||||
}))}
|
|
||||||
className="task-status-tabs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 类型筛选 */}
|
<TaskTable
|
||||||
<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 || []}
|
dataSource={data?.items || []}
|
||||||
rowKey="id"
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
pagination={{
|
total={data?.total || 0}
|
||||||
current: page,
|
page={page}
|
||||||
pageSize,
|
pageSize={pageSize}
|
||||||
total: data?.total || 0,
|
expandedTaskId={expandedTaskId}
|
||||||
showSizeChanger: true,
|
expandedTaskDetail={expandedTaskDetail}
|
||||||
showQuickJumper: true,
|
retryLoading={retryLoading}
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
onPageChange={handlePageChange}
|
||||||
onChange: (p, ps) => {
|
onExpand={handleExpand}
|
||||||
setPage(p)
|
onRetry={handleRetry}
|
||||||
setPageSize(ps)
|
onViewDetail={handleViewDetail}
|
||||||
},
|
|
||||||
}}
|
|
||||||
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tag } from "antd"
|
||||||
|
import { ExclamationCircleOutlined } from "@ant-design/icons"
|
||||||
|
import type { TaskItem } from "@/api/tasks"
|
||||||
|
|
||||||
|
interface TaskErrorDetailProps {
|
||||||
|
record: TaskItem
|
||||||
|
detail?: TaskItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务错误详情展开行
|
||||||
|
*/
|
||||||
|
export const TaskErrorDetail: React.FC<TaskErrorDetailProps> = ({ record, detail }) => {
|
||||||
|
const d = detail || record
|
||||||
|
const errorInfo = d.error_info
|
||||||
|
|
||||||
|
if (!errorInfo && !d.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 || d.error_message) && (
|
||||||
|
<div className="task-error-row">
|
||||||
|
<span className="task-error-label">错误信息:</span>
|
||||||
|
<span className="task-error-message">
|
||||||
|
{errorInfo?.error_message || d.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tabs, Select } from "antd"
|
||||||
|
import { STATUS_TABS, TYPE_OPTIONS } from "../constants"
|
||||||
|
import type { TaskStatus } from "@/api/tasks"
|
||||||
|
|
||||||
|
interface TaskFilterBarProps {
|
||||||
|
statusFilter: TaskStatus | "all"
|
||||||
|
typeFilter: string
|
||||||
|
onStatusChange: (key: string) => void
|
||||||
|
onTypeChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务筛选栏
|
||||||
|
* 状态 Tab + 类型下拉筛选
|
||||||
|
*/
|
||||||
|
export const TaskFilterBar: React.FC<TaskFilterBarProps> = ({
|
||||||
|
statusFilter,
|
||||||
|
typeFilter,
|
||||||
|
onStatusChange,
|
||||||
|
onTypeChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="task-filters">
|
||||||
|
{/* 状态 Tab */}
|
||||||
|
<Tabs
|
||||||
|
activeKey={statusFilter}
|
||||||
|
onChange={onStatusChange}
|
||||||
|
items={STATUS_TABS.map((tab) => ({
|
||||||
|
key: tab.key,
|
||||||
|
label: tab.label,
|
||||||
|
}))}
|
||||||
|
className="task-status-tabs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 类型筛选 */}
|
||||||
|
<div className="task-type-filter">
|
||||||
|
<Select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={onTypeChange}
|
||||||
|
options={TYPE_OPTIONS}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="选择类型"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
|
||||||
|
import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
|
||||||
|
import type { ColumnsType } from "antd/es/table"
|
||||||
|
import type { TaskItem, TaskStatus } from "@/api/tasks"
|
||||||
|
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
|
||||||
|
import { formatDuration, formatTime } from "../utils"
|
||||||
|
import { TaskErrorDetail } from "./TaskErrorDetail"
|
||||||
|
|
||||||
|
interface TaskTableProps {
|
||||||
|
dataSource: TaskItem[]
|
||||||
|
loading: boolean
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
expandedTaskId: string | null
|
||||||
|
expandedTaskDetail: TaskItem | null
|
||||||
|
retryLoading: boolean
|
||||||
|
onPageChange: (page: number, pageSize: number) => void
|
||||||
|
onExpand: (expanded: boolean, record: TaskItem) => void
|
||||||
|
onRetry: (id: string) => void
|
||||||
|
onViewDetail: (record: TaskItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表表格
|
||||||
|
* 含列定义、分页、展开行
|
||||||
|
*/
|
||||||
|
export const TaskTable: React.FC<TaskTableProps> = ({
|
||||||
|
dataSource,
|
||||||
|
loading,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
expandedTaskId,
|
||||||
|
expandedTaskDetail,
|
||||||
|
retryLoading,
|
||||||
|
onPageChange,
|
||||||
|
onExpand,
|
||||||
|
onRetry,
|
||||||
|
onViewDetail,
|
||||||
|
}) => {
|
||||||
|
// 表格列定义
|
||||||
|
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={() => onRetry(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<RedoOutlined />}
|
||||||
|
loading={retryLoading}
|
||||||
|
className="task-retry-btn"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (record.status === "failed") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
onClick={() => onViewDetail(record)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <span className="task-action-placeholder">-</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={dataSource}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: onPageChange,
|
||||||
|
}}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: (record) => (
|
||||||
|
<TaskErrorDetail record={record} detail={expandedTaskDetail} />
|
||||||
|
),
|
||||||
|
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
|
||||||
|
onExpand,
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React from "react"
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
import type { TaskStatus } from "@/api/tasks"
|
||||||
|
|
||||||
|
/** 状态 Tab 配置 */
|
||||||
|
export 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: "已取消" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 类型筛选选项 */
|
||||||
|
export const TYPE_OPTIONS = [
|
||||||
|
{ value: "all", label: "全部类型" },
|
||||||
|
{ value: "generation", label: "生成任务" },
|
||||||
|
{ value: "ingest", label: "素材导入" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 状态标签配置 */
|
||||||
|
export 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 />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务类型标签 */
|
||||||
|
export const TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
generation: { label: "生成任务", color: "blue" },
|
||||||
|
ingest: { label: "素材导入", color: "green" },
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import {
|
||||||
|
getTasks,
|
||||||
|
retryTask,
|
||||||
|
type TaskItem,
|
||||||
|
type TaskStatus,
|
||||||
|
type TaskListParams,
|
||||||
|
} from "@/api/tasks"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表业务 Hook
|
||||||
|
* 封装筛选状态、数据获取、重试操作
|
||||||
|
*/
|
||||||
|
export const useTaskList = () => {
|
||||||
|
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 handleStatusChange = useCallback((key: string) => {
|
||||||
|
setStatusFilter(key as TaskStatus | "all")
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 类型筛选变化
|
||||||
|
const handleTypeChange = useCallback((value: string) => {
|
||||||
|
setTypeFilter(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 分页变化
|
||||||
|
const handlePageChange = useCallback((p: number, ps: number) => {
|
||||||
|
setPage(p)
|
||||||
|
setPageSize(ps)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 展开查看详情
|
||||||
|
const handleExpand = useCallback((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 handleViewDetail = useCallback((record: TaskItem) => {
|
||||||
|
setExpandedTaskId(record.id)
|
||||||
|
setExpandedTaskDetail(record)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
statusFilter,
|
||||||
|
typeFilter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
expandedTaskId,
|
||||||
|
expandedTaskDetail,
|
||||||
|
// 数据
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
// Mutation
|
||||||
|
retryLoading: retryMutation.isPending,
|
||||||
|
// 操作
|
||||||
|
handleStatusChange,
|
||||||
|
handleTypeChange,
|
||||||
|
handlePageChange,
|
||||||
|
handleExpand,
|
||||||
|
handleViewDetail,
|
||||||
|
handleRetry: retryMutation.mutate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** 格式化耗时 */
|
||||||
|
export 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}分`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时间 */
|
||||||
|
export 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user