feat: enhance useFetchData hook with polling functionality and improve task progress tracking

This commit is contained in:
chenghh-9609
2025-10-23 09:56:06 +08:00
parent 69b9517181
commit 17960f674f
9 changed files with 261 additions and 181 deletions

View File

@@ -37,6 +37,7 @@ interface CardViewProps<T> {
| {
key: string;
label: string;
danger?: boolean;
icon?: React.JSX.Element;
onClick?: (item: T) => void;
}[]
@@ -169,82 +170,85 @@ function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
typeof operations === "function" ? operations(item) : operations;
return (
<div className="flex-overflow-hidden">
<div className="flex-overflow-auto grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
<div className="overflow-auto grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{data.map((item) => (
<div
key={item.id}
className="border-card p-4 bg-white hover:shadow-lg transition-shadow duration-200"
>
<div className="flex flex-col space-y-4 h-full">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3 min-w-0">
{item?.icon && (
<div
className={`flex-shrink-0 w-12 h-12 ${
item?.iconColor ||
"bg-gradient-to-br from-blue-100 to-blue-200"
} rounded-lg flex items-center justify-center`}
>
{item?.icon}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate ${
onView ? "cursor-pointer hover:text-blue-600" : ""
}`}
onClick={() => onView?.(item)}
<div
className="flex flex-col space-y-4 h-full"
onClick={() => onView?.(item)}
style={{ cursor: onView ? "pointer" : "default" }}
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3 min-w-0">
{item?.icon && (
<div
className={`flex-shrink-0 w-12 h-12 ${
item?.iconColor ||
"bg-gradient-to-br from-blue-100 to-blue-200"
} rounded-lg flex items-center justify-center`}
>
{item?.name}
</h3>
{item?.status && (
<Tag color={item?.status?.color}>
<div className="flex items-center gap-2 text-xs py-0.5">
<span>{item?.status?.icon}</span>
<span>{item?.status?.label}</span>
</div>
</Tag>
)}
{item?.icon}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate`}
>
{item?.name}
</h3>
{item?.status && (
<Tag color={item?.status?.color}>
<div className="flex items-center gap-2 text-xs py-0.5">
<span>{item?.status?.icon}</span>
<span>{item?.status?.label}</span>
</div>
</Tag>
)}
</div>
</div>
</div>
{onFavorite && (
<StarFilled
style={{
fontSize: "16px",
color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db",
cursor: "pointer",
}}
onClick={() => onFavorite?.(item)}
/>
)}
</div>
{onFavorite && (
<StarFilled
style={{
fontSize: "16px",
color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db",
cursor: "pointer",
}}
onClick={() => onFavorite?.(item)}
/>
)}
</div>
<div className="flex-1 flex flex-col justify-end">
{/* Tags */}
<TagsRenderer tags={item?.tags || []} />
<div className="flex-1 flex flex-col justify-end">
{/* Tags */}
<TagsRenderer tags={item?.tags || []} />
{/* Description */}
<p className="text-gray-600 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2">
<Tooltip title={item?.description}>
{item?.description}
</Tooltip>
</p>
{/* Description */}
<p className="text-gray-600 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2">
<Tooltip title={item?.description}>
{item?.description}
</Tooltip>
</p>
{/* Statistics */}
<div className="grid grid-cols-2 gap-4 py-3">
{item?.statistics?.map((stat, idx) => (
<div key={idx}>
<div className="text-sm text-gray-500">
{stat?.label}:
{/* Statistics */}
<div className="grid grid-cols-2 gap-4 py-3">
{item?.statistics?.map((stat, idx) => (
<div key={idx}>
<div className="text-sm text-gray-500">
{stat?.label}:
</div>
<div className="text-base font-semibold text-gray-900">
{stat?.value}
</div>
</div>
<div className="text-base font-semibold text-gray-900">
{stat?.value}
</div>
</div>
))}
))}
</div>
</div>
</div>

View File

@@ -1,14 +1,32 @@
// 首页数据获取
import { useState } from "react";
// 支持轮询功能,使用示例:
// const { fetchData, startPolling, stopPolling, isPolling } = useFetchData(
// fetchFunction,
// mapFunction,
// 5000 // 5秒轮询一次,默认30秒
// false // 是否自动开始轮询,默认 true
// );
//
// startPolling(); // 开始轮询
// stopPolling(); // 停止轮询
// 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时
import { useState, useRef, useEffect, useCallback } from "react";
import { useDebouncedEffect } from "./useDebouncedEffect";
import Loading from "@/utils/loading";
import { App } from "antd";
export default function useFetchData<T>(
fetchFunc: (params?: any) => Promise<any>,
mapDataFunc: (data: any) => T = (data) => data as T
mapDataFunc: (data: any) => T = (data) => data as T,
pollingInterval: number = 30000, // 默认30秒轮询一次
autoRefresh: boolean = true
) {
const { message } = App.useApp();
// 轮询相关状态
const [isPolling, setIsPolling] = useState(false);
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
// 表格数据
const [tableData, setTableData] = useState<T[]>([]);
// 设置加载状态
@@ -55,39 +73,108 @@ export default function useFetchData<T>(
return arr[0];
}
async function fetchData(extraParams = {}) {
const { keyword, filter, current, pageSize } = searchParams;
Loading.show();
setLoading(true);
try {
const { data } = await fetchFunc({
...filter,
...extraParams,
keyword,
type: getFirstOfArray(filter?.type) || undefined,
status: getFirstOfArray(filter?.status) || undefined,
tags: filter?.tags?.length ? filter.tags.join(",") : undefined,
page: current - 1,
size: pageSize,
});
setPagination((prev) => ({
...prev,
total: data?.totalElements || 0,
}));
let result = [];
if (mapDataFunc) {
result = data?.content.map(mapDataFunc) ?? [];
}
setTableData(result);
} catch (error) {
console.error(error)
message.error("数据获取失败,请稍后重试");
} finally {
Loading.hide();
setLoading(false);
// 清除轮询定时器
const clearPollingTimer = useCallback(() => {
if (pollingTimerRef.current) {
clearTimeout(pollingTimerRef.current);
pollingTimerRef.current = null;
}
}
}, []);
const fetchData = useCallback(
async (extraParams = {}, skipPollingRestart = false) => {
const { keyword, filter, current, pageSize } = searchParams;
if (!skipPollingRestart) {
Loading.show();
setLoading(true);
}
// 如果正在轮询且不是轮询触发的调用,先停止当前轮询
const wasPolling = isPolling && !skipPollingRestart;
if (wasPolling) {
clearPollingTimer();
}
try {
const { data } = await fetchFunc({
...filter,
...extraParams,
keyword,
type: getFirstOfArray(filter?.type) || undefined,
status: getFirstOfArray(filter?.status) || undefined,
tags: filter?.tags?.length ? filter.tags.join(",") : undefined,
page: current - 1,
size: pageSize,
});
setPagination((prev) => ({
...prev,
total: data?.totalElements || 0,
}));
let result = [];
if (mapDataFunc) {
result = data?.content.map(mapDataFunc) ?? [];
}
setTableData(result);
// 如果之前正在轮询且不是轮询触发的调用,重新开始轮询
if (wasPolling) {
const poll = () => {
pollingTimerRef.current = setTimeout(() => {
fetchData({}, true).then(() => {
if (pollingTimerRef.current) {
poll();
}
});
}, pollingInterval);
};
poll();
}
} catch (error) {
console.error(error);
message.error("数据获取失败,请稍后重试");
} finally {
Loading.hide();
setLoading(false);
}
},
[
searchParams,
fetchFunc,
mapDataFunc,
isPolling,
clearPollingTimer,
pollingInterval,
message,
]
);
// 开始轮询
const startPolling = useCallback(() => {
clearPollingTimer();
setIsPolling(true);
const poll = () => {
pollingTimerRef.current = setTimeout(() => {
fetchData({}, true).then(() => {
if (pollingTimerRef.current) {
poll();
}
});
}, pollingInterval);
};
poll();
}, [pollingInterval, clearPollingTimer, fetchData]);
// 停止轮询
const stopPolling = useCallback(() => {
clearPollingTimer();
setIsPolling(false);
}, [clearPollingTimer]);
// 搜索参数变化时,自动刷新数据
// keyword 变化时,防抖500ms后刷新
useDebouncedEffect(
() => {
fetchData();
@@ -96,6 +183,16 @@ export default function useFetchData<T>(
searchParams?.keyword ? 500 : 0
);
// 组件卸载时清理轮询
useEffect(() => {
if (autoRefresh) {
startPolling();
}
return () => {
clearPollingTimer();
};
}, [clearPollingTimer]);
return {
loading,
tableData,
@@ -109,5 +206,8 @@ export default function useFetchData<T>(
setPagination,
handleFiltersChange,
fetchData,
isPolling,
startPolling,
stopPolling,
};
}

View File

@@ -1,56 +0,0 @@
import {
DatabaseOutlined,
BarChartOutlined,
FileTextOutlined,
ThunderboltOutlined,
PictureOutlined,
CalculatorOutlined,
SwapOutlined,
} from "@ant-design/icons";
import { FileImage, FileText, Music, Repeat, Video } from "lucide-react";
// 模板类型选项
export const templateTypes = [
{
value: "text",
label: "文本",
icon: FileText,
description: "处理文本数据的清洗模板",
},
{
value: "image",
label: "图片",
icon: FileImage,
description: "处理图像数据的清洗模板",
},
{
value: "video",
label: "视频",
icon: Video,
description: "处理视频数据的清洗模板",
},
{
value: "audio",
label: "音频",
icon: Music,
description: "处理音频数据的清洗模板",
},
{
value: "image-to-text",
label: "图片转文本",
icon: Repeat,
description: "图像识别转文本的处理模板",
},
];
// 算子分类
export const OPERATOR_CATEGORIES = {
data: { name: "数据清洗", icon: <DatabaseOutlined />, color: "#1677ff" },
ml: { name: "机器学习", icon: <ThunderboltOutlined />, color: "#722ed1" },
vision: { name: "计算机视觉", icon: <PictureOutlined />, color: "#52c41a" },
nlp: { name: "自然语言处理", icon: <FileTextOutlined />, color: "#faad14" },
analysis: { name: "数据分析", icon: <BarChartOutlined />, color: "#f5222d" },
transform: { name: "数据转换", icon: <SwapOutlined />, color: "#13c2c2" },
io: { name: "输入输出", icon: <FileTextOutlined />, color: "#595959" },
math: { name: "数学计算", icon: <CalculatorOutlined />, color: "#fadb14" },
};

View File

@@ -93,9 +93,13 @@ function cleaningTaskItem() {
srcDatasetName: Mock.Random.ctitle(5, 15),
destDatasetId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""),
destDatasetName: Mock.Random.ctitle(5, 15),
progress: Mock.Random.float(0, 100, 2, 2),
progress: {
finishedFileNum: Mock.Random.integer(0, 100),
process: Mock.Random.integer(0, 100),
totalFileNum: 100,
},
startedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
endedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
finishedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
createdAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
updatedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
instance: operatorList,
@@ -244,7 +248,7 @@ module.exports = function (router) {
const task = cleaningTaskList.find((j) => j.id === taskId);
if (task) {
task.status = "running";
task.status = "RUNNING";
task.startTime = new Date().toISOString();
res.send({
@@ -252,7 +256,7 @@ module.exports = function (router) {
msg: "Cleaning task execution started",
data: {
executionId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""),
status: "running",
status: "RUNNING",
message: "Task execution started successfully",
},
});
@@ -271,7 +275,7 @@ module.exports = function (router) {
const task = cleaningTaskList.find((j) => j.id === taskId);
if (task) {
task.status = "pending";
task.status = "PENDING";
task.endTime = new Date().toISOString();
res.send({

View File

@@ -104,12 +104,14 @@ export default function TaskList() {
key: "name",
fixed: "left",
width: 150,
ellipsis: true,
},
{
title: "源数据集",
dataIndex: "srcDatasetId",
key: "srcDatasetId",
width: 150,
ellipsis: true,
render: (_, record: CleansingTask) => {
return (
<Button
@@ -128,6 +130,7 @@ export default function TaskList() {
dataIndex: "destDatasetId",
key: "destDatasetId",
width: 150,
ellipsis: true,
render: (_, record: CleansingTask) => {
return (
<Button
@@ -147,47 +150,68 @@ export default function TaskList() {
key: "status",
width: 100,
render: (status: any) => {
return <Badge color={status.color} text={status.label} />;
return <Badge color={status?.color} text={status?.label} />;
},
},
{
title: "开始时间",
dataIndex: "startedAt",
key: "startedAt",
width: 180,
},
{
title: "结束时间",
dataIndex: "finishedAt",
key: "finishedAt",
width: 180,
},
{
title: "进度",
dataIndex: "progress",
key: "progress",
dataIndex: "process",
key: "process",
width: 200,
render: (progress: number) => (
<Progress percent={progress} size="small" />
),
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
title: "已处理文件数",
dataIndex: "finishedFileNum",
key: "finishedFileNum",
width: 150,
align: "right",
ellipsis: true,
},
{
title: "总文件数",
dataIndex: "totalFileNum",
key: "totalFileNum",
width: 150,
align: "right",
ellipsis: true,
},
{
title: "执行耗时",
dataIndex: "duration",
key: "duration",
width: 180,
ellipsis: true,
},
{
title: "开始时间",
dataIndex: "startedAt",
key: "startedAt",
width: 180,
ellipsis: true,
},
{
title: "结束时间",
dataIndex: "finishedAt",
key: "finishedAt",
width: 180,
ellipsis: true,
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
ellipsis: true,
},
{
title: "数据量变化",
dataIndex: "dataSizeChange",
key: "dataSizeChange",
width: 180,
ellipsis: true,
render: (_: any, record: CleansingTask) => {
if (record.before !== undefined && record.after !== undefined) {
return `${record.before}${record.after}`;
@@ -232,6 +256,7 @@ export default function TaskList() {
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
onClearFilters={() => setSearchParams({ ...searchParams, filter: {} })}
/>
{/* Task List */}
{viewMode === "card" ? (

View File

@@ -95,6 +95,7 @@ export const mapTask = (task: CleansingTask) => {
const createdAt = formatDateTime(task.createdAt);
return {
...task,
...task.progress,
createdAt,
startedAt,
finishedAt,
@@ -105,18 +106,18 @@ export const mapTask = (task: CleansingTask) => {
before,
after,
statistics: [
{ label: "进度", value: `${task.progress || 0}%` },
{ label: "进度", value: `${task?.progress?.process || 0}%` },
{
label: "执行耗时",
value: duration,
},
{
label: "处理前数据大小",
value: task.beforeSize ? formatBytes(task.beforeSize) : "--",
label: "处理文件数",
value: task?.progress?.finishedFileNum || 0,
},
{
label: "处理后数据大小",
value: task.afterSize ? formatBytes(task.afterSize) : "--",
label: "总文件数",
value: task?.progress?.totalFileNum || 0,
},
],
lastModified: formatDateTime(task.createdAt),

View File

@@ -158,6 +158,7 @@ export default function DatasetManagementPage() {
{
key: "delete",
label: "删除",
danger: true,
icon: <DeleteOutlined />,
onClick: (item: Dataset) => handleDeleteDataset(item.id),
},

View File

@@ -202,7 +202,7 @@ class Request {
try {
const errorData = await processedResponse.json();
error.data = errorData;
message.error(`请求失败,错误信息: ${processedResponse.statusText}`);
// message.error(`请求失败,错误信息: ${processedResponse.statusText}`);
} catch {
// 忽略JSON解析错误
}

View File

@@ -64,6 +64,7 @@ export function formatExecutionDuration(
}
export const formatDuration = (seconds: number): string => {
if (seconds < 0) return "--";
if (seconds < 60) {
return `${seconds}`;
} else if (seconds < 3600) {