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; key: string;
label: string; label: string;
danger?: boolean;
icon?: React.JSX.Element; icon?: React.JSX.Element;
onClick?: (item: T) => void; onClick?: (item: T) => void;
}[] }[]
@@ -169,13 +170,18 @@ function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
typeof operations === "function" ? operations(item) : operations; typeof operations === "function" ? operations(item) : operations;
return ( return (
<div className="flex-overflow-hidden"> <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) => ( {data.map((item) => (
<div <div
key={item.id} key={item.id}
className="border-card p-4 bg-white hover:shadow-lg transition-shadow duration-200" 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">
<div
className="flex flex-col space-y-4 h-full"
onClick={() => onView?.(item)}
style={{ cursor: onView ? "pointer" : "default" }}
>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3 min-w-0"> <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-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 <h3
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate ${ 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)}
> >
{item?.name} {item?.name}
</h3> </h3>
@@ -247,6 +250,7 @@ function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
))} ))}
</div> </div>
</div> </div>
</div>
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-between pt-3 border-t border-t-gray-200"> <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 { useDebouncedEffect } from "./useDebouncedEffect";
import Loading from "@/utils/loading"; import Loading from "@/utils/loading";
import { App } from "antd"; import { App } from "antd";
export default function useFetchData<T>( export default function useFetchData<T>(
fetchFunc: (params?: any) => Promise<any>, 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 { message } = App.useApp();
// 轮询相关状态
const [isPolling, setIsPolling] = useState(false);
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
// 表格数据 // 表格数据
const [tableData, setTableData] = useState<T[]>([]); const [tableData, setTableData] = useState<T[]>([]);
// 设置加载状态 // 设置加载状态
@@ -55,10 +73,29 @@ export default function useFetchData<T>(
return arr[0]; 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; const { keyword, filter, current, pageSize } = searchParams;
if (!skipPollingRestart) {
Loading.show(); Loading.show();
setLoading(true); setLoading(true);
}
// 如果正在轮询且不是轮询触发的调用,先停止当前轮询
const wasPolling = isPolling && !skipPollingRestart;
if (wasPolling) {
clearPollingTimer();
}
try { try {
const { data } = await fetchFunc({ const { data } = await fetchFunc({
...filter, ...filter,
@@ -79,15 +116,65 @@ export default function useFetchData<T>(
result = data?.content.map(mapDataFunc) ?? []; result = data?.content.map(mapDataFunc) ?? [];
} }
setTableData(result); setTableData(result);
// 如果之前正在轮询且不是轮询触发的调用,重新开始轮询
if (wasPolling) {
const poll = () => {
pollingTimerRef.current = setTimeout(() => {
fetchData({}, true).then(() => {
if (pollingTimerRef.current) {
poll();
}
});
}, pollingInterval);
};
poll();
}
} catch (error) { } catch (error) {
console.error(error) console.error(error);
message.error("数据获取失败,请稍后重试"); message.error("数据获取失败,请稍后重试");
} finally { } finally {
Loading.hide(); Loading.hide();
setLoading(false); 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( useDebouncedEffect(
() => { () => {
fetchData(); fetchData();
@@ -96,6 +183,16 @@ export default function useFetchData<T>(
searchParams?.keyword ? 500 : 0 searchParams?.keyword ? 500 : 0
); );
// 组件卸载时清理轮询
useEffect(() => {
if (autoRefresh) {
startPolling();
}
return () => {
clearPollingTimer();
};
}, [clearPollingTimer]);
return { return {
loading, loading,
tableData, tableData,
@@ -109,5 +206,8 @@ export default function useFetchData<T>(
setPagination, setPagination,
handleFiltersChange, handleFiltersChange,
fetchData, 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), srcDatasetName: Mock.Random.ctitle(5, 15),
destDatasetId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""), destDatasetId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""),
destDatasetName: Mock.Random.ctitle(5, 15), 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"), 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"), createdAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
updatedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"), updatedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
instance: operatorList, instance: operatorList,
@@ -244,7 +248,7 @@ module.exports = function (router) {
const task = cleaningTaskList.find((j) => j.id === taskId); const task = cleaningTaskList.find((j) => j.id === taskId);
if (task) { if (task) {
task.status = "running"; task.status = "RUNNING";
task.startTime = new Date().toISOString(); task.startTime = new Date().toISOString();
res.send({ res.send({
@@ -252,7 +256,7 @@ module.exports = function (router) {
msg: "Cleaning task execution started", msg: "Cleaning task execution started",
data: { data: {
executionId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""), executionId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""),
status: "running", status: "RUNNING",
message: "Task execution started successfully", message: "Task execution started successfully",
}, },
}); });
@@ -271,7 +275,7 @@ module.exports = function (router) {
const task = cleaningTaskList.find((j) => j.id === taskId); const task = cleaningTaskList.find((j) => j.id === taskId);
if (task) { if (task) {
task.status = "pending"; task.status = "PENDING";
task.endTime = new Date().toISOString(); task.endTime = new Date().toISOString();
res.send({ res.send({

View File

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

View File

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

View File

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

View File

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

View File

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