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,13 +170,18 @@ 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">
<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">
@@ -192,10 +198,7 @@ function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
<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)}
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate`}
>
{item?.name}
</h3>
@@ -247,6 +250,7 @@ function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
))}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-3 border-t border-t-gray-200">

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,10 +73,29 @@ export default function useFetchData<T>(
return arr[0];
}
async function fetchData(extraParams = {}) {
// 清除轮询定时器
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,
@@ -79,15 +116,65 @@ export default function useFetchData<T>(
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)
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) {