deepdata: core product code (backend, frontend, runtime, deepflow module)

This commit is contained in:
figmar
2026-08-30 19:23:38 +08:00
commit bd3db30be9
1405 changed files with 271727 additions and 0 deletions
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import {Breadcrumb, App, Tabs} from "antd";
import {
Play,
Pause,
Clock,
CheckCircle,
AlertCircle,
Trash2,
Activity, LayoutList,
} from "lucide-react";
import DetailHeader from "@/components/DetailHeader";
import { Link, useNavigate, useParams } from "react-router";
import {
deleteCleaningTaskByIdUsingDelete,
executeCleaningTaskUsingPost,
queryCleaningTaskByIdUsingGet, queryCleaningTaskLogByIdUsingGet, queryCleaningTaskResultByIdUsingGet,
stopCleaningTaskUsingPost,
} from "../cleansing.api";
import {getTaskStatusMap, mapTask} from "../cleansing.const";
import {CleansingResult, TaskStatus} from "@/pages/DataCleansing/cleansing.model";
import BasicInfo from "./components/BasicInfo";
import OperatorTable from "./components/OperatorTable";
import FileTable from "./components/FileTable";
import LogsTable from "./components/LogsTable";
import {formatExecutionDuration} from "@/utils/unit.ts";
import {ReloadOutlined} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
// 任务详情页面组件
export default function CleansingTaskDetail() {
const { t } = useTranslation();
const { id = "" } = useParams(); // 获取动态路由参数
const { message } = App.useApp();
const navigate = useNavigate();
const fetchTaskDetail = async () => {
if (!id) return;
try {
const { data } = await queryCleaningTaskByIdUsingGet(id);
setTask(mapTask(data, t));
} catch (error) {
message.error(t("dataCleansing.task.messages.taskDetailFailed"));
navigate("/data/cleansing");
}
};
const pauseTask = async () => {
await stopCleaningTaskUsingPost(id);
message.success(t("dataCleansing.task.messages.taskPaused"));
fetchTaskDetail();
};
const startTask = async () => {
await executeCleaningTaskUsingPost(id);
message.success(t("dataCleansing.task.messages.taskStarted"));
fetchTaskDetail();
};
const deleteTask = async () => {
await deleteCleaningTaskByIdUsingDelete(id);
message.success(t("dataCleansing.task.messages.taskDeleted"));
navigate("/data/cleansing");
};
const [result, setResult] = useState<CleansingResult[]>();
const fetchTaskResult = async () => {
if (!id) return;
try {
const { data } = await queryCleaningTaskResultByIdUsingGet(id);
setResult(data);
} catch (error) {
message.error(t("dataCleansing.task.messages.fetchTaskResultFailed"));
navigate("/data/cleansing/task-detail/" + id);
}
};
const [taskLog, setTaskLog] = useState();
const fetchTaskLog = async (retryCount: number) => {
if (!id) return;
try {
const { data } = await queryCleaningTaskLogByIdUsingGet(id, retryCount);
setTaskLog(data);
} catch (error) {
message.error(t("dataCleansing.task.messages.fetchTaskLogFailed"));
navigate("/data/cleansing/task-detail/" + id);
}
};
const handleRefresh = async () => {
fetchTaskDetail();
{activeTab === "files" && await fetchTaskResult()}
{activeTab === "logs" && await fetchTaskLog(task.retryCount)}
};
useEffect(() => {
fetchTaskDetail();
}, [id, t]);
const [task, setTask] = useState(null);
const [activeTab, setActiveTab] = useState("basic");
const headerData = {
...task,
icon: <LayoutList className="w-8 h-8" />,
status: getTaskStatusMap(t)[task?.status],
createdAt: task?.createdAt,
lastUpdated: task?.updatedAt,
};
const statistics = [
{
icon: <Clock className="w-4 h-4 text-blue-500" />,
label: t("dataCleansing.detail.statistics.totalDuration"),
value: formatExecutionDuration(task?.startedAt, task?.finishedAt) || "--",
},
{
icon: <CheckCircle className="w-4 h-4 text-green-500" />,
label: t("dataCleansing.detail.statistics.successFiles"),
value: task?.progress?.succeedFileNum || 0,
},
{
icon: <AlertCircle className="w-4 h-4 text-red-500" />,
label: t("dataCleansing.detail.statistics.failedFiles"),
value: task?.progress?.failedFileNum || 0,
},
{
icon: <Activity className="w-4 h-4 text-purple-500" />,
label: t("dataCleansing.detail.statistics.successRate"),
value: task?.progress?.process ? task?.progress?.process + "%" : "--",
},
];
const operations = [
...(task?.status?.value === TaskStatus.RUNNING
? [
{
key: "pause",
label: t("dataCleansing.actions.pauseTask"),
icon: <Pause className="w-4 h-4" />,
onClick: pauseTask,
},
]
: []),
...([TaskStatus.PENDING, TaskStatus.STOPPED, TaskStatus.FAILED, TaskStatus.PARTIAL_SUCCESS].includes(task?.status?.value)
? [
{
key: "start",
label: t("dataCleansing.actions.start"),
icon: <Play className="w-4 h-4" />,
onClick: startTask,
},
]
: []),
{
key: "refresh",
label: t("dataCleansing.actions.refreshTask"),
icon: <ReloadOutlined className="w-4 h-4" />,
onClick: handleRefresh,
},
{
key: "delete",
label: t("dataCleansing.actions.deleteTask"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onClick: deleteTask,
},
];
const tabList = [
{
key: "basic",
label: t("dataCleansing.detail.tabs.basicInfo"),
},
{
key: "operators",
label: t("dataCleansing.detail.tabs.operators"),
},
{
key: "files",
label: t("dataCleansing.detail.tabs.files"),
},
{
key: "logs",
label: t("dataCleansing.detail.tabs.logs"),
},
];
const breadItems = [
{
title: <Link to="/data/cleansing">{t("dataCleansing.detail.breadcrumb.dataProcessing")}</Link>,
},
{
title: t("dataCleansing.detail.breadcrumb.taskDetail"),
},
];
return (
<>
<Breadcrumb items={breadItems} />
<div className="mb-4 mt-4">
<DetailHeader
data={headerData}
statistics={statistics}
operations={operations}
/>
</div>
<div className="flex-overflow-auto p-6 pt-2 bg-white rounded-md shadow">
<Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
<div className="h-full flex-1 overflow-auto">
{activeTab === "basic" && (
<BasicInfo task={task} />
)}
{activeTab === "operators" && <OperatorTable task={task} />}
{activeTab === "files" && <FileTable result={result} fetchTaskResult={fetchTaskResult} />}
{activeTab === "logs" && <LogsTable taskLog={taskLog} fetchTaskLog={fetchTaskLog} retryCount={task.retryCount} taskName={task.name} taskStatus={task?.status?.value} />}
</div>
</div>
</>
);
}
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import {Breadcrumb, App, Tabs} from "antd";
import {
Trash2,
LayoutList,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import DetailHeader from "@/components/DetailHeader";
import { Link, useNavigate, useParams } from "react-router";
import {
deleteCleaningTemplateByIdUsingDelete,
queryCleaningTemplateByIdUsingGet,
} from "../cleansing.api";
import {mapTemplate} from "../cleansing.const";
import TemplateOperatorTable from "./components/TemplateOperatorTable";
import {EditOutlined, ReloadOutlined, NumberOutlined} from "@ant-design/icons";
// 任务详情页面组件
export default function CleansingTemplateDetail() {
const { t } = useTranslation();
const { id = "" } = useParams(); // 获取动态路由参数
const { message } = App.useApp();
const navigate = useNavigate();
const [template, setTemplate] = useState();
const fetchTemplateDetail = async () => {
if (!id) return;
try {
const { data } = await queryCleaningTemplateByIdUsingGet(id);
setTemplate(mapTemplate(data, t));
} catch (error) {
message.error(t("dataCleansing.template.messages.templateDetailFailed"));
navigate("/data/cleansing");
}
};
const deleteTemplate = async () => {
await deleteCleaningTemplateByIdUsingDelete(id);
message.success(t("dataCleansing.template.messages.templateDeleted"));
navigate("/data/cleansing");
};
const handleRefresh = async () => {
fetchTemplateDetail();
};
useEffect(() => {
fetchTemplateDetail();
}, [id]);
const [activeTab, setActiveTab] = useState("operators");
const headerData = {
...template,
icon: <LayoutList className="w-8 h-8" />,
createdAt: template?.createdAt,
lastUpdated: template?.updatedAt,
};
const statistics = [
{
icon: <NumberOutlined className="w-4 h-4 text-green-500" />,
label: t("dataCleansing.detail.statistics.operatorCount"),
value: template?.instance?.length || 0,
},
];
const operations = [
{
key: "update",
label: t("dataCleansing.actions.updateTemplate"),
icon: <EditOutlined className="w-4 h-4" />,
onClick: () => navigate(`/data/cleansing/update-template/${id}`),
},
{
key: "refresh",
label: t("dataCleansing.actions.refreshTemplate"),
icon: <ReloadOutlined className="w-4 h-4" />,
onClick: handleRefresh,
},
{
key: "delete",
label: t("dataCleansing.actions.deleteTemplate"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onClick: deleteTemplate,
},
];
const tabList = [
{
key: "operators",
label: t("dataCleansing.detail.tabs.operators"),
},
];
const breadItems = [
{
title: <Link to="/data/cleansing">{t("dataCleansing.detail.breadcrumb.dataProcessing")}</Link>,
},
{
title: t("dataCleansing.detail.breadcrumb.templateDetail"),
},
];
return (
<>
<Breadcrumb items={breadItems} />
<div className="mb-4 mt-4">
<DetailHeader
data={headerData}
statistics={statistics}
operations={operations}
/>
</div>
<div className="flex-overflow-auto p-6 pt-2 bg-white rounded-md shadow">
<Tabs activeKey={activeTab} items={tabList} onChange={setActiveTab} />
<div className="h-full flex-1 overflow-auto">
<TemplateOperatorTable template={template} />
</div>
</div>
</>
);
}
@@ -0,0 +1,151 @@
import {CleansingTask, TaskStatus} from "@/pages/DataCleansing/cleansing.model";
import { Button, Card, Descriptions, Progress } from "antd";
import { Activity, AlertCircle, BookOpen, CheckCircle, Clock } from "lucide-react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import {formatExecutionDuration} from "@/utils/unit.ts";
import {CardHeader, CardTitle} from "@/components/Card.tsx";
export default function BasicInfo({ task }: { task: CleansingTask }) {
const navigate = useNavigate();
const { t } = useTranslation();
const descriptionItems = [
{
key: "id",
label: t("dataCleansing.detail.basicInfo.taskId"),
children: <span className="font-mono">{task?.id}</span>,
},
{ key: "name", label: t("dataCleansing.detail.basicInfo.taskName"), children: task?.name },
{
key: "dataset",
label: t("dataCleansing.detail.basicInfo.srcDataset"),
children: (
<Button
style={{ paddingLeft: 0, marginLeft: 0 }}
type="link"
size="small"
onClick={() =>
navigate("/data/management/detail/" + task?.srcDatasetId)
}
>
{task?.srcDatasetName}
</Button>
),
},
{
key: "targetDataset",
label: t("dataCleansing.detail.basicInfo.destDataset"),
children: (
<Button
style={{ paddingLeft: 0, marginLeft: 0 }}
type="link"
size="small"
onClick={() =>
navigate("/data/management/detail/" + task?.destDatasetId)
}
>
{task?.destDatasetName}
</Button>
),
},
{ key: "startTime", label: t("dataCleansing.detail.basicInfo.startTime"), children: task?.startedAt },
{
key: "description",
label: t("dataCleansing.detail.basicInfo.description"),
children: (
<span className="text-gray-600">{task?.description || "--"}</span>
),
span: 2,
},
{ key: "finishedTime", label: t("dataCleansing.detail.basicInfo.endTime"), children: task?.finishedAt },
{ key: "retryCount", label: t("dataCleansing.detail.basicInfo.retryCount"), children: task?.retryCount },
];
return (
<>
{/* 执行摘要 */}
<Card className="mb-6">
<CardHeader className="p-1 pb-4">
<CardTitle className="flex items-center gap-2">
<BookOpen className="w h text-blue-600" />
{t("dataCleansing.detail.basicInfo.executionSummary")}
</CardTitle>
</CardHeader>
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-4 bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg">
<Clock className="w-8 h-8 text-blue-500 mb-2 mx-auto" />
<div className="text-xl font-bold text-blue-500">
{formatExecutionDuration(task?.startedAt, task?.finishedAt) || "--"}
</div>
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.totalDuration")}</div>
</div>
<div className="text-center p-4 bg-gradient-to-br from-green-50 to-green-100 rounded-lg">
<CheckCircle className="w-8 h-8 text-green-500 mb-2 mx-auto" />
<div className="text-xl font-bold text-green-500">
{task?.progress?.succeedFileNum ?? 0}
</div>
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.successFiles")}</div>
</div>
<div className="text-center p-4 bg-gradient-to-br from-red-50 to-red-100 rounded-lg">
<AlertCircle className="w-8 h-8 text-red-500 mb-2 mx-auto" />
<div className="text-xl font-bold text-red-500">
{(task?.status.value === TaskStatus.RUNNING || task?.status.value === TaskStatus.PENDING) ?
(task?.progress?.failedFileNum ?? 0) :
Math.max(0, (task?.progress?.totalFileNum ?? 0) - (task?.progress?.succeedFileNum ?? 0))}
</div>
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.failedFiles")}</div>
</div>
<div className="text-center p-4 bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg">
<Activity className="w-8 h-8 text-purple-500 mb-2 mx-auto" />
<div className="text-xl font-bold text-purple-500">
{task?.progress?.successRate ? task?.progress?.successRate + "%" : "--"}
</div>
<div className="text-sm text-gray-600">{t("dataCleansing.detail.statistics.successRate")}</div>
</div>
</div>
</Card>
{/* 基本信息 */}
<Card>
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t("dataCleansing.detail.basicInfo.basicInformation")}</h3>
<Descriptions
column={2}
bordered={false}
size="middle"
styles={{ label: { fontWeight: 500, color: "#555" }, content: { fontSize: 14 } }}
items={descriptionItems}
></Descriptions>
</div>
{/* 处理进度 */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t("dataCleansing.detail.basicInfo.processingProgress")}</h3>
{ task?.status?.value === TaskStatus.FAILED ? (
<Progress percent={task?.progress?.process} size="small" status="exception" />
) : task?.status?.value === TaskStatus.PARTIAL_SUCCESS ? (
<Progress percent={task?.progress?.process} size="small" strokeColor="#f59e0b" />
) : (
<Progress percent={task?.progress?.process} size="small"/>
)}
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-green-500 rounded-full inline-block" />
<span>{t("dataCleansing.detail.basicInfo.completed", { count: task?.progress?.succeedFileNum ?? 0 })}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-blue-500 rounded-full inline-block" />
<span>{t("dataCleansing.detail.basicInfo.processing", { count: (task?.status.value === TaskStatus.RUNNING || task?.status.value === TaskStatus.PENDING) ?
Math.max(0, (task?.progress?.totalFileNum ?? 0) - (task?.progress?.succeedFileNum ?? 0)) : 0 })}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-red-500 rounded-full inline-block" />
<span>{t("dataCleansing.detail.basicInfo.failed", { count: (task?.status.value === TaskStatus.RUNNING || task?.status.value === TaskStatus.PENDING) ?
(task?.progress?.failedFileNum ?? 0) :
Math.max(0, (task?.progress?.totalFileNum ?? 0) - (task?.progress?.succeedFileNum ?? 0)) })}</span>
</div>
</div>
</div>
</Card>
</>
);
}
@@ -0,0 +1,568 @@
import {Button, Modal, Table, Badge, Input, Popover} from "antd";
import { Download } from "lucide-react";
import {useEffect, useState} from "react";
import {useParams} from "react-router";
import {TaskStatus} from "@/pages/DataCleansing/cleansing.model.ts";
import {getTaskStatusMap} from "@/pages/DataCleansing/cleansing.const.tsx";
import { useTranslation } from "react-i18next";
// 渲染 JSON 值的辅助组件
function JsonValue({ value, depth = 0 }: { value: any; depth?: number }) {
if (value === null) {
return <span className="text-gray-500">null</span>;
}
if (value === undefined) {
return <span className="text-gray-500">undefined</span>;
}
if (typeof value === 'boolean') {
return <span className="text-purple-600">{value.toString()}</span>;
}
if (typeof value === 'number') {
return <span className="text-blue-600">{value}</span>;
}
if (typeof value === 'string') {
// 处理换行符,保留空白格式
const lines = value.split('\n');
if (lines.length > 1) {
return (
<span className="text-green-700">
{lines.map((line, i) => (
<span key={i}>
{line}
{i < lines.length - 1 && <br />}
</span>
))}
</span>
);
}
return <span className="text-green-700">{value}</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className="text-gray-500">[]</span>;
}
return (
<div className="pl-3 border-l-2 border-gray-200">
{value.map((item, i) => (
<div key={i} className="py-0.5">
<span className="text-gray-400 mr-1">[{i}]</span>
<JsonValue value={item} depth={depth + 1} />
</div>
))}
</div>
);
}
if (typeof value === 'object') {
const keys = Object.keys(value);
if (keys.length === 0) {
return <span className="text-gray-500">{"{}"}</span>;
}
return (
<div className="pl-3 border-l-2 border-gray-200">
{keys.map((key) => (
<div key={key} className="py-0.5">
<span className="text-blue-800 font-medium">{key}</span>
<span className="text-gray-500 mx-1">:</span>
<JsonValue value={value[key]} depth={depth + 1} />
</div>
))}
</div>
);
}
return <span>{String(value)}</span>;
}
// 模拟文件列表数据
export default function FileTable({result, fetchTaskResult}) {
const { id = "" } = useParams();
const { t } = useTranslation();
const [showFileCompareDialog, setShowFileCompareDialog] = useState(false);
const [selectedFile, setSelectedFile] = useState<any>(null);
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
useEffect(() => {
fetchTaskResult();
}, [id]);
const handleSelectAllFiles = (checked: boolean) => {
if (checked) {
setSelectedFileIds(result.map((file) => file.srcFileId));
} else {
setSelectedFileIds([]);
}
};
const handleSelectFile = (fileId: string, checked: boolean) => {
if (checked) {
setSelectedFileIds([...selectedFileIds, fileId]);
} else {
setSelectedFileIds(selectedFileIds.filter((id) => id !== fileId));
}
};
const handleViewFileCompare = (file: any) => {
setSelectedFile(file);
setShowFileCompareDialog(true);
};
const handleDownload = async () => {
try {
const response = await fetch(`/api/cleaning/tasks/${id}/result/download`);
if (!response.ok) {
throw new Error('Download failed');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `task_${id}_files.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Download failed:', error);
message.error(t("dataCleansing.detail.fileTable.downloadFilesFailed"));
}
};
function formatFileSize(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
const fileColumns = [
{
title: (
<input
type="checkbox"
checked={
selectedFileIds.length === result?.length && result?.length > 0
}
onChange={(e) => handleSelectAllFiles(e.target.checked)}
className="w-4 h-4"
/>
),
dataIndex: "select",
key: "select",
width: 50,
render: (_text: string, record: any) => (
<input
type="checkbox"
checked={selectedFileIds.includes(record.srcFileId)}
onChange={(e) => handleSelectFile(record.srcFileId, e.target.checked)}
className="w-4 h-4"
/>
),
},
{
title: t("dataCleansing.detail.fileTable.fileName"),
dataIndex: "srcName",
key: "srcName",
width: 200,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
}: any) => (
<div className="p-4 w-64">
<Input
placeholder={t("dataCleansing.detail.fileTable.searchFileName")}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
className="mb-2"
/>
<div className="flex gap-2">
<Button size="small" onClick={() => confirm()}>
{t("dataCleansing.actions.search")}
</Button>
<Button size="small" onClick={() => clearFilters()}>
{t("dataCleansing.actions.reset")}
</Button>
</div>
</div>
),
onFilter: (value: string, record: any) =>
record.srcName.toLowerCase().includes(value.toLowerCase()),
render: (text: string) => (
<span>{text?.replace(/\.[^/.]+$/, "")}</span>
),
},
{
title: t("dataCleansing.detail.fileTable.processedFileName"),
dataIndex: "destName",
key: "destName",
width: 200,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
}: any) => (
<div className="p-4 w-64">
<Input
placeholder={t("dataCleansing.detail.fileTable.searchFileName")}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
className="mb-2"
/>
<div className="flex gap-2">
<Button size="small" onClick={() => confirm()}>
{t("dataCleansing.actions.search")}
</Button>
<Button size="small" onClick={() => clearFilters()}>
{t("dataCleansing.actions.reset")}
</Button>
</div>
</div>
),
onFilter: (value: string, record: any) =>
record.destName.toLowerCase().includes(value.toLowerCase()),
render: (text: string) => (
<span>{text?.replace(/\.[^/.]+$/, "")}</span>
),
},
{
title: t("dataCleansing.detail.fileTable.fileType"),
dataIndex: "srcType",
key: "srcType",
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
}: any) => (
<div className="p-4 w-64">
<Input
placeholder={t("dataCleansing.detail.fileTable.searchFileType")}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
className="mb-2"
/>
<div className="flex gap-2">
<Button size="small" onClick={() => confirm()}>
{t("dataCleansing.actions.search")}
</Button>
<Button size="small" onClick={() => clearFilters()}>
{t("dataCleansing.actions.reset")}
</Button>
</div>
</div>
),
onFilter: (value: string, record: any) =>
record.srcType.toLowerCase().includes(value.toLowerCase()),
render: (text: string) => (
<span className="font-mono text-sm">{text}</span>
),
},
{
title: t("dataCleansing.detail.fileTable.processedFileType"),
dataIndex: "destType",
key: "destType",
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
}: any) => (
<div className="p-4 w-64">
<Input
placeholder={t("dataCleansing.detail.fileTable.searchFileType")}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
className="mb-2"
/>
<div className="flex gap-2">
<Button size="small" onClick={() => confirm()}>
{t("dataCleansing.actions.search")}
</Button>
<Button size="small" onClick={() => clearFilters()}>
{t("dataCleansing.actions.reset")}
</Button>
</div>
</div>
),
onFilter: (value: string, record: any) =>
record.destType.toLowerCase().includes(value.toLowerCase()),
render: (text: string) => (
<span className="font-mono text-sm">{text || "-"}</span>
),
},
{
title: t("dataCleansing.detail.fileTable.beforeSize"),
dataIndex: "srcSize",
key: "srcSize",
sorter: (a: any, b: any) => {
const getSizeInBytes = (size: string) => {
if (!size || size === "-") return 0;
const num = Number.parseFloat(size);
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
if (size.includes("MB")) return num * 1024 * 1024;
if (size.includes("KB")) return num * 1024;
return num;
};
return getSizeInBytes(a.originalSize) - getSizeInBytes(b.originalSize);
},
render: (number: number) => (
<span className="font-mono text-sm">{formatFileSize(number)}</span>
),
},
{
title: t("dataCleansing.detail.fileTable.afterSize"),
dataIndex: "destSize",
key: "destSize",
sorter: (a: any, b: any) => {
const getSizeInBytes = (size: string) => {
if (!size || size === "-") return 0;
const num = Number.parseFloat(size);
if (size.includes("GB")) return num * 1024 * 1024 * 1024;
if (size.includes("MB")) return num * 1024 * 1024;
if (size.includes("KB")) return num * 1024;
return num;
};
return (
getSizeInBytes(a.processedSize) - getSizeInBytes(b.processedSize)
);
},
render: (number: number) => (
<span className="font-mono text-sm">{formatFileSize(number)}</span>
),
},
{
title: t("dataCleansing.detail.fileTable.result"),
dataIndex: "result",
key: "result",
width: 120,
render: (text: string, record: any) => {
// 如果结果为空或特殊值,则不展示
if (!text || text === '' || text === '{}' || text === '[]' ||
text === 'null' || text === 'undefined' || text.trim() === '') {
return <span className="text-gray-400">-</span>;
}
// 尝试解析JSON
try {
const parsed = JSON.parse(text);
// 如果是空对象或空数组则不展示
if (Array.isArray(parsed) && parsed.length === 0) {
return <span className="text-gray-400">-</span>;
}
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
return <span className="text-gray-400">-</span>;
}
// 检查 reason 字段:如果只有 reason 且为 null/空,则不展示
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
const keys = Object.keys(parsed);
// 如果只有 reason 字段且为空
if (keys.length > 0 && keys.includes('reason')) {
const reason = parsed['reason'];
if (reason === null || reason === undefined || reason === '' ||
(typeof reason === 'string' && reason.trim() === '')) {
return <span className="text-gray-400">-</span>;
}
}
}
// 有内容需要展示,显示 {...} 点击查看详情
return (
<Popover
content={
<div className="max-w-md max-h-64 overflow-auto text-sm bg-gray-50 p-3 rounded">
<JsonValue value={parsed} />
</div>
}
title={t("dataCleansing.detail.fileTable.resultDetail")}
trigger="click"
>
<span className="font-mono text-sm text-blue-600 cursor-pointer hover:text-blue-800 underline">
{"{...}"}
</span>
</Popover>
);
} catch {
// 普通字符串:如果为空则不展示
if (text.trim() === '') {
return <span className="text-gray-400">-</span>;
}
// 有内容的普通字符串,点击查看详情(支持换行)
const lines = text.split('\n');
return (
<Popover
content={
<div className="max-w-md max-h-64 overflow-auto text-sm bg-gray-50 p-3 rounded whitespace-pre-wrap">
{lines.map((line, i) => (
<span key={i}>
{line}
{i < lines.length - 1 && <br />}
</span>
))}
</div>
}
title={t("dataCleansing.detail.fileTable.resultDetail")}
trigger="click"
>
<span className="font-mono text-sm text-blue-600 cursor-pointer hover:text-blue-800 underline">
{"{...}"}
</span>
</Popover>
);
}
}
},
{
title: t("dataCleansing.detail.fileTable.status"),
dataIndex: "status",
key: "status",
filters: [
{ text: t("dataCleansing.status.completed"), value: "COMPLETED" },
{ text: t("dataCleansing.status.failed"), value: "FAILED" },
],
onFilter: (value: string, record: any) => record.status === value,
render: (status: string) => (
<Badge
status={
status === "COMPLETED"
? "success"
: "error"
}
text={getTaskStatusMap(t)[status as TaskStatus].label}
/>
),
},
{
title: t("dataCleansing.detail.fileTable.actions"),
key: "action",
width: 150,
render: (_text: string, record: any) => (
<div className="flex">
{record.status === "COMPLETED" ? (
<Button
type="link"
size="small"
onClick={() => handleViewFileCompare(record)}
>
{t("dataCleansing.actions.compare")}
</Button>
) : (
<Button
type="link"
size="small"
disabled
>
{t("dataCleansing.actions.compare")}
</Button>
)}
<Button
type="link"
size="small"
onClick={() => handleDownload()}
>
{t("dataCleansing.actions.download")}
</Button>
</div>
),
},
];
return (
<>
{selectedFileIds.length > 0 && (
<div className="mb-4 flex justify-between items-center">
<span className="text-sm text-gray-600">
{t("dataCleansing.detail.fileTable.selectedFilesCount", { count: selectedFileIds.length })}
</span>
<Button
onClick={handleDownload}
size="small"
type="primary"
icon={<Download className="w-4 h-4 mr-2" />}
>
{t("dataCleansing.actions.download")}
</Button>
</div>
)}
<Table
columns={fileColumns}
dataSource={result}
pagination={{ pageSize: 10, showSizeChanger: true }}
size="middle"
rowKey={(record) => record.srcFileId || record.instanceId}
/>
{/* 文件对比弹窗 */}
<Modal
open={showFileCompareDialog}
onCancel={() => setShowFileCompareDialog(false)}
footer={null}
width={900}
title={<span>{t("dataCleansing.detail.fileTable.compareDialogTitle", {fileName: selectedFile?.fileName})}</span>}
>
<div className="grid grid-cols-2 gap-6 py-6">
<div>
<h4 className="font-medium text-gray-900">{t("dataCleansing.detail.fileTable.beforeProcessing")}</h4>
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
<div className="text-center text-gray-500">
<div className="w-16 h-16 bg-gray-300 rounded-lg mx-auto mb-2" />
<div className="text-sm">{t("dataCleansing.detail.fileTable.originalFilePreview")}</div>
<div className="text-xs text-gray-400">
{t("dataCleansing.detail.fileTable.size")}: {formatFileSize(selectedFile?.srcSize)}
</div>
</div>
</div>
<div className="text-sm text-gray-600 mt-3 space-y-1">
<div>
<span className="font-medium">{t("dataCleansing.detail.fileTable.fileFormat")}:</span> {selectedFile?.srcType}
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">{t("dataCleansing.detail.fileTable.afterProcessing")}</h4>
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50 min-h-48 flex items-center justify-center">
<div className="text-center text-gray-500">
<div className="w-16 h-16 bg-blue-300 rounded-lg mx-auto mb-2" />
<div className="text-sm">{t("dataCleansing.detail.fileTable.processedFilePreview")}</div>
<div className="text-xs text-gray-400">
{t("dataCleansing.detail.fileTable.size")}: {formatFileSize(selectedFile?.destSize)}
</div>
</div>
</div>
<div className="text-sm text-gray-600 mt-3 space-y-1">
<div>
<span className="font-medium">{t("dataCleansing.detail.fileTable.fileFormat")}:</span> {selectedFile?.destType}
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 mt-6 pt-4">
<h4 className="font-medium text-gray-900 mb-3">{t("dataCleansing.detail.fileTable.processingEffect")}</h4>
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="bg-green-50 p-4 rounded-lg">
<div className="font-medium text-green-700">{t("dataCleansing.detail.fileTable.sizeOptimization")}</div>
<div className="text-green-600">{t("dataCleansing.detail.fileTable.reduced", {percent: (100 * (selectedFile?.srcSize - selectedFile?.destSize) / selectedFile?.srcSize).toFixed(2)})}</div>
</div>
</div>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,202 @@
import { useEffect, useState, useRef, useMemo } from "react";
import { useParams } from "react-router";
import { FileClock, Download } from "lucide-react";
import { useTranslation } from "react-i18next";
import { App } from "antd";
import VirtualList from 'rc-virtual-list';
import { streamCleaningTaskLog, downloadCleaningTaskLog } from "../../cleansing.api";
import { TaskStatus } from "../../cleansing.model";
interface LogEntry {
level: string;
message: string;
}
interface LogEntryWithIndex extends LogEntry {
index: number;
}
export default function LogsTable({
taskLog: initialLogs,
fetchTaskLog,
retryCount,
taskName,
taskStatus
}: {
taskLog: LogEntry[],
fetchTaskLog: () => Promise<LogEntry[]>,
retryCount: number,
taskName: string,
taskStatus?: TaskStatus
}) {
const { id = "" } = useParams();
const { t } = useTranslation();
const [selectedLog, setSelectedLog] = useState(retryCount + 1);
const [streamingLogs, setStreamingLogs] = useState<LogEntry[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const { message } = App.useApp();
// Only stream when task is RUNNING and viewing the latest run
const shouldStream = taskStatus === TaskStatus.RUNNING && selectedLog - 1 === retryCount;
useEffect(() => {
if (shouldStream) {
startStreaming();
} else {
stopStreaming();
fetchTaskLog(selectedLog - 1).then(() => {
// Static logs loaded, safe to clear streaming logs now
setStreamingLogs([]);
});
}
return () => stopStreaming();
}, [id, selectedLog, retryCount, shouldStream]);
const startStreaming = () => {
stopStreaming();
setStreamingLogs([]);
setIsStreaming(true);
const eventSource = streamCleaningTaskLog(id, selectedLog - 1);
eventSourceRef.current = eventSource;
eventSource.onmessage = (event) => {
try {
const logEntry: LogEntry = JSON.parse(event.data);
if (logEntry.message === "[END_OF_STREAM]" || logEntry.message === "[HEARTBEAT]") {
if (logEntry.message === "[END_OF_STREAM]") {
// Don't clear streamingLogs immediately - keep them visible
// while the static fetch completes
eventSourceRef.current?.close();
eventSourceRef.current = null;
setIsStreaming(false);
}
return;
}
setStreamingLogs(prev => [...prev, logEntry]);
} catch (e) {
console.error("Failed to parse log entry:", e);
}
};
eventSource.onerror = (error) => {
console.error("SSE error:", error);
stopStreaming();
};
};
const stopStreaming = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsStreaming(false);
};
// Use streaming logs only when actively streaming, otherwise use initial logs
// Keep streamingLogs visible until initialLogs are populated (avoids blank flash)
const displayLogs = isStreaming || streamingLogs.length > 0
? (streamingLogs.length > 0 ? streamingLogs : initialLogs)
: initialLogs;
// Add index to logs for virtual list key
const logsWithIndex: LogEntryWithIndex[] = useMemo(() => {
return (displayLogs || []).map((log, index) => ({ ...log, index }));
}, [displayLogs]);
// Get log level color class
const getLevelClass = (level: string) => {
if (level === "ERROR" || level === "FATAL") return "text-red-500";
if (level === "WARNING" || level === "WARN") return "text-yellow-500";
return "text-green-500";
};
const handleSelectChange = (value: number) => {
setSelectedLog(value);
setStreamingLogs([]);
};
const handleDownload = async () => {
try {
const blob = await downloadCleaningTaskLog(id, selectedLog - 1);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${taskName}_${t("dataCleansing.logTable.nthRun", { num: selectedLog })}.log`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("Failed to download log:", error);
message.error(t("dataCleansing.detail.logTable.downloadFailed"));
}
};
return displayLogs?.length > 0 || isStreaming ? (
<>
<div className="flex items-center justify-between pb-3">
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-500">{t("dataCleansing.detail.logTable.selectRun")}:</label>
<select
value={selectedLog}
onChange={(e) => handleSelectChange(Number(e.target.value))}
className="bg-gray-700 border border-gray-600 !text-white text-sm rounded-md focus:ring-blue-500 focus:border-blue-500 block px-2.5 py-1.5 min-w-[120px]"
>
{Array.from({ length: retryCount + 1 }, (_, i) => retryCount + 1 - i).map((num) => (
<option key={num} value={num}>
{t("dataCleansing.detail.logTable.currentDisplay", { num: num })}
</option>
))}
</select>
{isStreaming && (
<span className="text-xs text-blue-400 animate-pulse">{t("dataCleansing.detail.logTable.streaming")}</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-s text-gray-500 px-2">{t("dataCleansing.detail.logTable.nthRun", { num: selectedLog })}</span>
<button
onClick={handleDownload}
disabled={isStreaming}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 border border-blue-500 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
<Download className="w-4 h-4" />
<span>{t("dataCleansing.detail.logTable.download")}</span>
</button>
</div>
</div>
<div className="text-gray-300 p-4 border border-gray-700 bg-gray-800 rounded-lg">
<div className="font-mono text-sm">
<VirtualList
data={logsWithIndex}
height={580}
itemHeight={22}
itemKey="index"
>
{(log: LogEntryWithIndex) => (
<div className="flex gap-3 py-0.5">
<span className={`min-w-20 ${getLevelClass(log.level)}`}>
[{log.level}]
</span>
<span className="text-gray-100">{log.message}</span>
</div>
)}
</VirtualList>
{isStreaming && (
<div className="flex gap-3 animate-pulse py-0.5">
<span className="text-gray-400">...</span>
</div>
)}
</div>
</div>
</>
) : (
<div className="text-center py-12">
<FileClock className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t("dataCleansing.detail.logTable.noLogs")}
</h3>
</div>
);
}
@@ -0,0 +1,145 @@
import {Badge, Button, Input, Table, Typography} from "antd";
import {useNavigate} from "react-router";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/Card"
import { GitBranch } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TaskStatus } from "@/pages/DataCleansing/cleansing.model";
export default function OperatorTable({ task }: { task: any }) {
const navigate = useNavigate();
const { t } = useTranslation();
const operatorColumns = [
{
title: t("dataCleansing.detail.operatorTable.serialNumber"),
dataIndex: "index",
key: "index",
width: 80,
render: (text: any, record: any, index: number) => index + 1,
},
{
title: t("dataCleansing.detail.operatorTable.operatorName"),
dataIndex: "name",
key: "name",
render: (text, record) => (
<Typography.Link
onClick={() => navigate(`/data/operator-market/plugin-detail/${record.id}`)}
>
{text}
</Typography.Link>
),
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }: any) => (
<div className="p-4 w-64">
<Input
placeholder={t("dataCleansing.detail.operatorTable.searchOperatorName")}
value={selectedKeys[0]}
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
onPressEnter={() => confirm()}
className="mb-2"
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => confirm()}>
{t("dataCleansing.actions.search")}
</Button>
<Button size="sm" onClick={() => clearFilters()}>
{t("dataCleansing.actions.reset")}
</Button>
</div>
</div>
),
onFilter: (value: string, record: any) => record.name.toLowerCase().includes(value.toLowerCase()),
},
{
title: t("dataCleansing.detail.operatorTable.startTime"),
dataIndex: "startTime",
key: "startTime",
sorter: (a: any, b: any) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
},
{
title: t("dataCleansing.detail.operatorTable.endTime"),
dataIndex: "endTime",
key: "endTime",
sorter: (a: any, b: any) => new Date(a.endTime).getTime() - new Date(b.endTime).getTime(),
},
{
title: t("dataCleansing.detail.operatorTable.duration"),
dataIndex: "duration",
key: "duration",
},
{
title: t("dataCleansing.detail.operatorTable.processedFiles"),
dataIndex: "processedFiles",
key: "processedFiles",
sorter: (a: any, b: any) => a.processedFiles - b.processedFiles,
},
{
title: t("dataCleansing.detail.operatorTable.successRate"),
dataIndex: "successRate",
key: "successRate",
sorter: (a: any, b: any) => a.successRate - b.successRate,
render: (rate: number) => `${rate}%`,
},
{
title: t("dataCleansing.detail.operatorTable.status"),
dataIndex: "status",
key: "status",
filters: [
{ text: t("dataCleansing.detail.operatorTable.completed"), value: "completed" },
{ text: t("dataCleansing.detail.operatorTable.failed"), value: "failed" },
{ text: t("dataCleansing.detail.operatorTable.running"), value: "running" },
{ text: t("dataCleansing.detail.operatorTable.partialSuccess"), value: "partialSuccess" },
],
onFilter: (value: string, record: any) => record.statusValue === value,
render: (statusObj: { label: string; value: TaskStatus }) => {
let badgeStatus: "default" | "processing" | "success" | "error" | "warning" = "default";
if (statusObj?.value === TaskStatus.COMPLETED) {
badgeStatus = "success";
} else if (statusObj?.value === TaskStatus.RUNNING) {
badgeStatus = "processing";
} else if (statusObj?.value === TaskStatus.PARTIAL_SUCCESS) {
badgeStatus = "warning";
} else if (statusObj?.value === TaskStatus.FAILED) {
badgeStatus = "error";
}
return <Badge status={badgeStatus} text={statusObj?.label} />;
},
},
]
return task?.instance?.length > 0 && (
<>
<Card className="bg-white/70 backdrop-blur-sm border-0 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5 text-purple-600" />
{t("dataCleansing.detail.operatorTable.title")}
</CardTitle>
<CardDescription>{t("dataCleansing.detail.operatorTable.description")}</CardDescription>
</CardHeader>
<CardContent>
<Table
columns={operatorColumns}
dataSource={Object.values(task?.instance).map((item) => ({
id: item?.id,
name: item?.name,
startTime: new Date(task?.startedAt).toLocaleTimeString(),
endTime: task?.finishedAt
? new Date(task.finishedAt).toLocaleTimeString()
: '-',
duration: task.duration,
status: task.status,
statusValue: task.status.value,
processedFiles: task.progress.finishedFileNum,
successRate: task?.progress.successRate,
}))}
rowKey="id"
pagination={false}
size="middle"
/>
</CardContent>
</Card>
</>
);
}
@@ -0,0 +1,25 @@
import {Steps, Typography} from "antd";
import {useNavigate} from "react-router";
export default function TemplateOperatorTable({ template }: { template: any }) {
const navigate = useNavigate();
return template?.instance?.length > 0 && (
<>
<Steps
progressDot
direction="vertical"
items={Object.values(template?.instance).map((item) => ({
title: <Typography.Link
onClick={() => navigate(`/data/operator-market/plugin-detail/${item?.id}`)}
>
{item?.name}
</Typography.Link>,
description: item?.description,
status: "finish"
}))}
className="overflow-auto"
/>
</>
);
}