Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 6d601dfbc3 Merge branch 'develop' into refactor/task-center
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m7s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m11s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m37s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 3m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m25s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 23s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 16s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:34:48 +08:00
xiaoxia a3e098b8da refactor(task-center): 三阶段重构 TaskCenter(423→76行, -82%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 31s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m37s
AI Code Review / AI Code Review (pull_request) Successful in 2m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m14s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- Phase 1: 抽 constants(STATUS_CONFIG/TYPE_LABELS等) + utils(formatDuration/formatTime)
- Phase 2: 抽 3 个 UI 组件(TaskFilterBar/TaskTable/TaskErrorDetail)
- Phase 3: 抽 useTaskList 业务 Hook(筛选+查询+重试)
- 主文件 423→76 行,职责清晰,表格列定义独立维护
2026-07-26 17:47:24 +08:00
13 changed files with 804 additions and 811 deletions
+258 -46
View File
@@ -1,88 +1,300 @@
/**
* 任务历史页面 — V21 设计系统
* 页面头部 + 圆角胶囊 Tab 筛选(含计数)+ 卡片式任务列表 + 分页 + 空状态
* 使用 useQuery 对接后端真实 APIapi/tasks.ts
*/
import React from "react"
import { PageHeader, LoadingState, ErrorState, EmptyState } from "./components/States"
import { HistoryTabs, TaskItem, Pagination } from "./components/TaskList"
import { useTaskHistory } from "./hooks/useTaskHistory"
import React, { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Button } from "@/components/ui"
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks"
import "./history.css"
/* ============================================================
* 类型 & 常量
* ============================================================ */
type TaskStatus = "completed" | "processing" | "pending" | "failed"
const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
}
/** 将后端 status 字符串映射为前端 TaskStatus */
const normalizeStatus = (s: string): TaskStatus => {
const map: Record<string, TaskStatus> = {
completed: "completed",
succeeded: "completed",
success: "completed",
processing: "processing",
running: "processing",
pending: "pending",
queued: "pending",
failed: "failed",
error: "failed",
}
return map[s] ?? "pending"
}
/** 格式化日期 */
const formatDate = (iso?: string | null): string => {
if (!iso) return "—"
const d = new Date(iso)
if (isNaN(d.getTime())) return "—"
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/* ============================================================
* Tab 配置
* ============================================================ */
interface TabConfig {
key: string
label: string
statusFilter?: TaskStatus
}
const tabs: TabConfig[] = [
{ key: "all", label: "全部" },
{ key: "processing", label: "进行中", statusFilter: "processing" },
{ key: "completed", label: "已完成", statusFilter: "completed" },
{ key: "failed", label: "失败", statusFilter: "failed" },
]
/* ============================================================
* 分页配置
* ============================================================ */
const PAGE_SIZE = 10
/* ============================================================
* 组件
* ============================================================ */
const TaskHistory: React.FC = () => {
const [activeTab, setActiveTab] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const queryClient = useQueryClient()
// ── 获取任务列表 ──
const {
activeTab,
currentPage,
totalPages,
tabs,
tabCounts,
paginatedTasks,
data: tasks = [],
isLoading,
isError,
error,
retryLoading,
setCurrentPage,
handleTabChange,
handleRetry,
handleView,
refetch,
} = useTaskHistory()
} = useQuery<TaskItem[], Error>({
queryKey: ["tasks"],
queryFn: getUserTasks,
staleTime: 30_000,
})
// Loading 状态
// ── 重试任务 mutation ──
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] })
},
})
// 将后端数据映射为页面展示用的结构
const mappedTasks = tasks.map((t) => ({
id: t.id,
name: t.user_message || t.task_type,
type: t.task_type,
template: t.template_id,
status: normalizeStatus(t.status),
date: formatDate(t.created_at),
duration: undefined as string | undefined,
progress: t.progress,
retryable: t.retryable,
errorMessage: t.error_message,
}))
// 获取当前 Tab 的筛选状态
const currentTab = tabs.find((t) => t.key === activeTab)
const statusFilter = currentTab?.statusFilter
// 过滤任务
const filteredTasks = statusFilter
? mappedTasks.filter((t) => t.status === statusFilter)
: mappedTasks
// 计算各 Tab 的数量
const tabCounts: Record<string, number> = {
all: mappedTasks.length,
processing: mappedTasks.filter((t) => t.status === "processing").length,
completed: mappedTasks.filter((t) => t.status === "completed").length,
failed: mappedTasks.filter((t) => t.status === "failed").length,
}
// 分页
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE)
const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
// 切换 Tab 时重置页码
const handleTabChange = (key: string) => {
setActiveTab(key)
setCurrentPage(1)
}
// 重试任务
const handleRetry = (taskId: string) => {
retryMutation.mutate(taskId)
}
// 查看任务详情
const handleView = (taskId: string) => {
// TODO: 跳转到任务详情页(待路由实现)
console.log("查看任务:", taskId)
}
// ── Loading 状态 ──
if (isLoading) {
return (
<div className="xx-history-page">
<PageHeader />
<LoadingState />
<div className="xx-history-header">
<h2></h2>
<p></p>
</div>
<div className="xx-history-empty">
<div className="xx-history-empty-icon"></div>
<h3>...</h3>
</div>
</div>
)
}
// Error 状态
// ── Error 状态 ──
if (isError) {
return (
<div className="xx-history-page">
<PageHeader />
<ErrorState message={error?.message} onRetry={() => refetch()} />
<div className="xx-history-header">
<h2></h2>
<p></p>
</div>
<div className="xx-history-empty">
<div className="xx-history-empty-icon"></div>
<h3></h3>
<p>{error?.message || "网络异常,请稍后重试"}</p>
<Button buttonType="primary" buttonSize="md" onClick={() => refetch()}>
</Button>
</div>
</div>
)
}
const totalCount = tabCounts[activeTab] ?? paginatedTasks.length
return (
<div className="xx-history-page">
<PageHeader />
{/* ── 页面头部 ──────────────────────────────────────────── */}
<div className="xx-history-header">
<h2></h2>
<p></p>
</div>
<HistoryTabs
tabs={tabs}
activeTab={activeTab}
tabCounts={tabCounts}
onChange={handleTabChange}
/>
{/* ── Tab 切换 ──────────────────────────────────────────── */}
<div className="xx-history-tabs">
{tabs.map((tab) => (
<button
key={tab.key}
className={`xx-history-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTabChange(tab.key)}
>
{tab.label}
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
</button>
))}
</div>
{/* 任务列表 */}
{/* ── 任务列表 ──────────────────────────────────────────── */}
{paginatedTasks.length === 0 ? (
<EmptyState activeTab={activeTab} />
<div className="xx-history-empty">
<div className="xx-history-empty-icon">📭</div>
<h3></h3>
<p>{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}</p>
</div>
) : (
<div className="xx-history-task-list">
{paginatedTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onRetry={handleRetry}
onView={handleView}
retryLoading={retryLoading}
/>
<div key={task.id} className="xx-history-task-item">
{/* 任务信息 */}
<div className="xx-history-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
{/* 状态标签 */}
<span className={`xx-history-status xx-history-status--${task.status}`}>
{statusLabel[task.status]}
</span>
{/* 时间区 */}
<div className="xx-history-task-time">
<span>{task.date}</span>
{task.status === "completed" ? (
<span></span>
) : task.status === "processing" ? (
<span> {task.progress}%</span>
) : task.status === "failed" ? (
<span>{task.errorMessage || "请重试"}</span>
) : (
<span></span>
)}
</div>
{/* 操作按钮 */}
<div className="xx-history-task-action">
{task.status === "failed" && task.retryable ? (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => handleRetry(task.id)}
disabled={retryMutation.isPending}
>
{retryMutation.isPending ? "重试中..." : "重试"}
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" onClick={() => handleView(task.id)}>
</Button>
)}
</div>
</div>
))}
</div>
)}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
total={totalCount}
onChange={setCurrentPage}
/>
{/* ── 分页 ──────────────────────────────────────────────── */}
{totalPages > 1 && (
<div className="xx-history-pagination">
<button
className="xx-history-page-btn"
disabled={currentPage === 1}
onClick={() => setCurrentPage(currentPage - 1)}
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
className={`xx-history-page-btn${currentPage === page ? " active" : ""}`}
onClick={() => setCurrentPage(page)}
>
{page}
</button>
))}
<button
className="xx-history-page-btn"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(currentPage + 1)}
>
</button>
<span className="xx-history-page-info"> {filteredTasks.length} </span>
</div>
)}
</div>
)
}
@@ -1,52 +0,0 @@
import React from "react"
import { Button } from "@/components/ui"
interface LoadingStateProps {
title?: string
}
/** 加载状态 */
export const LoadingState: React.FC<LoadingStateProps> = ({ title = "加载中..." }) => (
<div className="xx-history-empty">
<div className="xx-history-empty-icon"></div>
<h3>{title}</h3>
</div>
)
interface ErrorStateProps {
message?: string
onRetry: () => void
}
/** 错误状态 */
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
<div className="xx-history-empty">
<div className="xx-history-empty-icon"></div>
<h3></h3>
<p>{message || "网络异常,请稍后重试"}</p>
<Button buttonType="primary" buttonSize="md" onClick={onRetry}>
</Button>
</div>
)
interface EmptyStateProps {
activeTab?: string
}
/** 空状态 */
export const EmptyState: React.FC<EmptyStateProps> = ({ activeTab = "all" }) => (
<div className="xx-history-empty">
<div className="xx-history-empty-icon">📭</div>
<h3></h3>
<p>{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}</p>
</div>
)
/** 页面头部 */
export const PageHeader: React.FC = () => (
<div className="xx-history-header">
<h2></h2>
<p></p>
</div>
)
@@ -1,142 +0,0 @@
import React from "react"
import { Button } from "@/components/ui"
import type { TabConfig } from "../constants"
import type { MappedTask } from "../hooks/useTaskHistory"
import { statusLabel } from "../constants"
interface HistoryTabsProps {
tabs: TabConfig[]
activeTab: string
tabCounts: Record<string, number>
onChange: (key: string) => void
}
/** Tab 切换栏 */
export const HistoryTabs: React.FC<HistoryTabsProps> = ({
tabs,
activeTab,
tabCounts,
onChange,
}) => (
<div className="xx-history-tabs">
{tabs.map((tab) => (
<button
key={tab.key}
className={`xx-history-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => onChange(tab.key)}
>
{tab.label}
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
</button>
))}
</div>
)
interface TaskItemProps {
task: MappedTask
onRetry: (id: string) => void
onView: (id: string) => void
retryLoading?: boolean
}
/** 单个任务卡片 */
export const TaskItem: React.FC<TaskItemProps> = ({ task, onRetry, onView, retryLoading }) => {
const getSubText = () => {
switch (task.status) {
case "completed":
return "完成"
case "processing":
return `进度 ${task.progress}%`
case "failed":
return task.errorMessage || "请重试"
default:
return "等待中"
}
}
return (
<div className="xx-history-task-item">
{/* 任务信息 */}
<div className="xx-history-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
{/* 状态标签 */}
<span className={`xx-history-status xx-history-status--${task.status}`}>
{statusLabel[task.status]}
</span>
{/* 时间区 */}
<div className="xx-history-task-time">
<span>{task.date}</span>
<span>{getSubText()}</span>
</div>
{/* 操作按钮 */}
<div className="xx-history-task-action">
{task.status === "failed" && task.retryable ? (
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onRetry(task.id)}
disabled={retryLoading}
>
{retryLoading ? "重试中..." : "重试"}
</Button>
) : (
<Button buttonType="ghost" buttonSize="sm" onClick={() => onView(task.id)}>
</Button>
)}
</div>
</div>
)
}
interface PaginationProps {
currentPage: number
totalPages: number
total: number
onChange: (page: number) => void
}
/** 分页组件 */
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
total,
onChange,
}) => {
if (totalPages <= 1) return null
return (
<div className="xx-history-pagination">
<button
className="xx-history-page-btn"
disabled={currentPage === 1}
onClick={() => onChange(currentPage - 1)}
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
className={`xx-history-page-btn${currentPage === page ? " active" : ""}`}
onClick={() => onChange(page)}
>
{page}
</button>
))}
<button
className="xx-history-page-btn"
disabled={currentPage === totalPages}
onClick={() => onChange(currentPage + 1)}
>
</button>
<span className="xx-history-page-info"> {total} </span>
</div>
)
}
-28
View File
@@ -1,28 +0,0 @@
/** 任务状态 */
export type TaskStatus = "completed" | "processing" | "pending" | "failed"
/** 状态标签 */
export const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
}
/** Tab 配置 */
export interface TabConfig {
key: string
label: string
statusFilter?: TaskStatus
}
/** 默认 Tab 列表 */
export const TABS: TabConfig[] = [
{ key: "all", label: "全部" },
{ key: "processing", label: "进行中", statusFilter: "processing" },
{ key: "completed", label: "已完成", statusFilter: "completed" },
{ key: "failed", label: "失败", statusFilter: "failed" },
]
/** 每页数量 */
export const PAGE_SIZE = 10
@@ -1,131 +0,0 @@
import { useState, useMemo, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks"
import { TABS, PAGE_SIZE, type TabConfig } from "../constants"
import { normalizeStatus, formatDate } from "../utils"
/** 映射后的任务列表项 */
export interface MappedTask {
id: string
name: string
type: string
template?: string
status: "completed" | "processing" | "pending" | "failed"
date: string
progress?: number
retryable?: boolean
errorMessage?: string
}
/**
* 任务历史业务 Hook
*/
export const useTaskHistory = () => {
const [activeTab, setActiveTab] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const queryClient = useQueryClient()
// 获取任务列表
const {
data: tasks = [],
isLoading,
isError,
error,
refetch,
} = useQuery<TaskItem[], Error>({
queryKey: ["tasks"],
queryFn: getUserTasks,
staleTime: 30_000,
})
// 重试 mutation
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] })
},
})
// 映射后端数据
const mappedTasks: MappedTask[] = useMemo(
() =>
tasks.map((t) => ({
id: t.id,
name: t.user_message || t.task_type,
type: t.task_type,
template: t.template_id,
status: normalizeStatus(t.status),
date: formatDate(t.created_at),
progress: t.progress,
retryable: t.retryable,
errorMessage: t.error_message,
})),
[tasks],
)
// 当前 Tab 筛选
const currentTabConfig: TabConfig | undefined = TABS.find((t) => t.key === activeTab)
const statusFilter = currentTabConfig?.statusFilter
// 过滤任务
const filteredTasks = useMemo(
() => (statusFilter ? mappedTasks.filter((t) => t.status === statusFilter) : mappedTasks),
[mappedTasks, statusFilter],
)
// 各 Tab 计数
const tabCounts: Record<string, number> = useMemo(
() => ({
all: mappedTasks.length,
processing: mappedTasks.filter((t) => t.status === "processing").length,
completed: mappedTasks.filter((t) => t.status === "completed").length,
failed: mappedTasks.filter((t) => t.status === "failed").length,
}),
[mappedTasks],
)
// 分页
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE)
const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
// 切换 Tab
const handleTabChange = useCallback((key: string) => {
setActiveTab(key)
setCurrentPage(1)
}, [])
// 重试
const handleRetry = useCallback(
(taskId: string) => {
retryMutation.mutate(taskId)
},
[retryMutation],
)
// 查看详情
const handleView = useCallback((taskId: string) => {
// TODO: 跳转到任务详情页(待路由实现)
console.log("查看任务:", taskId)
}, [])
return {
// 状态
activeTab,
currentPage,
totalPages,
// 数据
tabs: TABS,
tabCounts,
paginatedTasks,
isLoading,
isError,
error,
retryLoading: retryMutation.isPending,
// 操作
setCurrentPage,
handleTabChange,
handleRetry,
handleView,
refetch,
}
}
-26
View File
@@ -1,26 +0,0 @@
import type { TaskStatus } from "./constants"
/** 将后端 status 字符串映射为前端 TaskStatus */
export const normalizeStatus = (s: string): TaskStatus => {
const map: Record<string, TaskStatus> = {
completed: "completed",
succeeded: "completed",
success: "completed",
processing: "processing",
running: "processing",
pending: "pending",
queued: "pending",
failed: "failed",
error: "failed",
}
return map[s] ?? "pending"
}
/** 格式化日期 */
export const formatDate = (iso?: string | null): string => {
if (!iso) return "—"
const d = new Date(iso)
if (isNaN(d.getTime())) return "—"
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
+39 -386
View File
@@ -2,333 +2,32 @@
* 任务中心页面
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
*/
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 { Button } from "antd"
import { CloseCircleOutlined } from "@ant-design/icons"
import { TaskFilterBar } from "./components/TaskFilterBar"
import { TaskTable } from "./components/TaskTable"
import { useTaskList } from "./hooks/useTaskList"
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 = {
const {
statusFilter,
typeFilter,
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>
)
}
pageSize,
expandedTaskId,
expandedTaskDetail,
data,
isLoading,
error,
retryLoading,
handleStatusChange,
handleTypeChange,
handlePageChange,
handleExpand,
handleViewDetail,
handleRetry,
} = useTaskList()
// 错误处理
if (error) {
@@ -351,72 +50,26 @@ export default function TaskCenter() {
<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"
/>
<TaskFilterBar
statusFilter={statusFilter}
typeFilter={typeFilter}
onStatusChange={handleStatusChange}
onTypeChange={handleTypeChange}
/>
{/* 类型筛选 */}
<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}
<TaskTable
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>
),
}}
total={data?.total || 0}
page={page}
pageSize={pageSize}
expandedTaskId={expandedTaskId}
expandedTaskDetail={expandedTaskDetail}
retryLoading={retryLoading}
onPageChange={handlePageChange}
onExpand={handleExpand}
onRetry={handleRetry}
onViewDetail={handleViewDetail}
/>
</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>
),
}}
/>
)
}
+69
View File
@@ -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,
}
}
+23
View File
@@ -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",
})
}