init datamate

This commit is contained in:
Dallas98
2025-10-21 23:00:48 +08:00
commit 1c97afed7d
692 changed files with 135442 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import { useState } from "react";
import { Steps, Button, message, Form } from "antd";
import { SaveOutlined } from "@ant-design/icons";
import { Link, useNavigate } from "react-router";
import { ArrowLeft } from "lucide-react";
import { createCleaningTaskUsingPost } from "../cleansing.api";
import CreateTaskStepOne from "./components/CreateTaskStepOne";
import { useCreateStepTwo } from "./hooks/useCreateStepTwo";
import { DatasetType } from "@/pages/DataManagement/dataset.model";
export default function CleansingTaskCreate() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [taskConfig, setTaskConfig] = useState({
name: "",
description: "",
srcDatasetId: "",
srcDatasetName: "",
destDatasetName: "",
destDatasetType: DatasetType.TEXT,
type: DatasetType.TEXT,
});
const {
renderStepTwo,
selectedOperators,
currentStep,
handlePrev,
handleNext,
} = useCreateStepTwo();
const handleSave = async () => {
const task = {
...taskConfig,
instance: selectedOperators.map((item) => ({
id: item.id,
overrides: {
...item.defaultParams,
...item.overrides,
},
})),
};
navigate("/data/cleansing?view=task");
await createCleaningTaskUsingPost(task);
message.success("任务已创建");
};
const canProceed = () => {
switch (currentStep) {
case 1: {
const values = form.getFieldsValue();
return (
values.name &&
values.srcDatasetId &&
values.destDatasetName &&
values.destDatasetType
);
}
case 2:
return selectedOperators.length > 0;
default:
return false;
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<CreateTaskStepOne
form={form}
taskConfig={taskConfig}
setTaskConfig={setTaskConfig}
/>
);
case 2:
return renderStepTwo;
default:
return null;
}
};
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<Link to="/data/cleansing">
<Button type="text">
<ArrowLeft className="w-4 h-4 mr-1" />
</Button>
</Link>
<h1 className="text-xl font-bold"></h1>
</div>
<div className="w-1/2">
<Steps
size="small"
current={currentStep - 1}
items={[{ title: "基本信息" }, { title: "算子编排" }]}
/>
</div>
</div>
{/* Step Content */}
<div className="h-full mb-4 flex flex-col overflow-auto flex-1 bg-white rounded shadow-sm">
<div className="flex-1 overflow-auto m-6">{renderStepContent()}</div>
<div className="flex justify-end p-6 gap-3 border-t border-gray-200">
<Button onClick={() => navigate("/data/cleansing")}></Button>
{currentStep > 1 && <Button onClick={handlePrev}></Button>}
{currentStep === 2 ? (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
disabled={!canProceed()}
>
</Button>
) : (
<Button
type="primary"
onClick={handleNext}
disabled={!canProceed()}
>
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useState } from "react";
import { Card, Button, Steps, Form, Divider } from "antd";
import { Link, useNavigate } from "react-router";
import { ArrowLeft } from "lucide-react";
import { createCleaningTemplateUsingPost } from "../cleansing.api";
import CleansingTemplateStepOne from "./components/CreateTemplateStepOne";
import { useCreateStepTwo } from "./hooks/useCreateStepTwo";
export default function CleansingTemplateCreate() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [templateConfig, setTemplateConfig] = useState({
name: "",
description: "",
});
const handleSave = async () => {
const template = {
...templateConfig,
instance: selectedOperators.map((item) => ({
id: item.id,
overrides: {
...item.defaultParams,
...item.overrides,
},
})),
};
await createCleaningTemplateUsingPost(template);
navigate("/data/cleansing?view=template");
};
const {
renderStepTwo,
selectedOperators,
currentStep,
handlePrev,
handleNext,
} = useCreateStepTwo();
const canProceed = () => {
const values = form.getFieldsValue();
switch (currentStep) {
case 1:
return values.name;
case 2:
return selectedOperators.length > 0;
default:
return false;
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<CleansingTemplateStepOne
form={form}
templateConfig={templateConfig}
setTemplateConfig={setTemplateConfig}
/>
);
case 2:
return renderStepTwo;
default:
return null;
}
};
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<Link to="/data/cleansing">
<Button type="text">
<ArrowLeft className="w-4 h-4 mr-1" />
</Button>
</Link>
<h1 className="text-xl font-bold"></h1>
</div>
<div className="w-1/2">
<Steps
size="small"
current={currentStep}
items={[{ title: "基本信息" }, { title: "算子编排" }]}
/>
</div>
</div>
<div className="h-full mb-4 flex flex-col overflow-auto flex-1 bg-white rounded shadow-sm">
<div className="flex-1 overflow-auto m-6">{renderStepContent()}</div>
<div className="flex justify-end p-6 gap-3 border-t border-gray-200">
<Button onClick={() => navigate("/data/cleansing")}></Button>
{currentStep > 1 && <Button onClick={handlePrev}></Button>}
{currentStep === 2 ? (
<Button
type="primary"
onClick={handleSave}
disabled={!canProceed()}
>
</Button>
) : (
<Button
type="primary"
onClick={handleNext}
disabled={!canProceed()}
>
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,411 @@
/* PreciseDragDrop.css */
.precise-drag-drop {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #333;
}
.header {
text-align: center;
margin-bottom: 40px;
}
.header h1 {
color: #2c3e50;
margin-bottom: 10px;
font-weight: 600;
font-size: 2rem;
}
.header p {
color: #7f8c8d;
font-size: 1.1rem;
}
.containers {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 40px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.container.drag-over {
border-color: #3498db;
background-color: #f8fafc;
transform: scale(1.02);
}
.container-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container-header h2 {
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
gap: 8px;
}
.count {
background: rgba(255, 255, 255, 0.2);
padding: 6px 12px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.clear-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.3s ease;
}
.clear-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
.items-list {
padding: 20px;
min-height: 500px;
max-height: 600px;
overflow-y: auto;
position: relative;
}
.item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
margin-bottom: 8px;
background: white;
border-radius: 8px;
border-left: 4px solid var(--item-color);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
cursor: grab;
position: relative;
}
.item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.item.dragging {
opacity: 0.6;
cursor: grabbing;
transform: rotate(3deg) scale(1.05);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.item.drag-over.insert-above {
border-top: 2px dashed var(--item-color);
margin-top: 4px;
}
.item.drag-over.insert-below {
border-bottom: 2px dashed var(--item-color);
margin-bottom: 4px;
}
.item-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.item-index {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background-color: var(--item-color);
color: white;
border-radius: 50%;
font-size: 0.9rem;
font-weight: bold;
flex-shrink: 0;
}
.item-icon {
font-size: 1.3rem;
flex-shrink: 0;
}
.item-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.item-title {
font-weight: 500;
font-size: 1rem;
}
.priority-tag {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
width: fit-content;
}
.priority-high {
background: #ffebee;
color: #c62828;
}
.priority-medium {
background: #fff3e0;
color: #ef6c00;
}
.priority-low {
background: #e8f5e8;
color: #2e7d32;
}
.item-type {
background: #f1f3f4;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
color: #666;
text-transform: capitalize;
flex-shrink: 0;
}
.item-actions {
display: flex;
align-items: center;
}
.drag-handle {
color: #bdc3c7;
font-size: 16px;
cursor: grab;
padding: 8px;
user-select: none;
border-radius: 4px;
transition: all 0.3s ease;
}
.drag-handle:hover {
color: #7f8c8d;
background: #f5f5f5;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: #95a5a6;
}
.empty-state p {
margin: 0 0 8px 0;
font-size: 1.1rem;
font-weight: 500;
}
.empty-state span {
font-size: 0.9rem;
}
/* 插入位置指示器 */
.insert-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
margin: 4px 0;
opacity: 0.8;
animation: pulse 1.5s infinite;
}
.insert-indicator.above {
margin-bottom: 0;
}
.insert-indicator.below {
margin-top: 0;
}
.indicator-line {
flex: 1;
height: 2px;
background: linear-gradient(90deg, transparent, var(--item-color, #3498db), transparent);
}
.indicator-arrow {
color: var(--item-color, #3498db);
font-weight: bold;
font-size: 0.9rem;
padding: 0 8px;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.instructions {
background: #f8f9fa;
padding: 25px;
border-radius: 12px;
border-left: 4px solid #3498db;
}
.instructions h3 {
margin-top: 0;
color: #2c3e50;
text-align: center;
margin-bottom: 20px;
font-size: 1.3rem;
}
.instruction-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.instruction {
display: flex;
align-items: flex-start;
gap: 15px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.instruction:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.instruction .icon {
font-size: 1.8rem;
flex-shrink: 0;
}
.instruction strong {
display: block;
margin-bottom: 5px;
color: #2c3e50;
font-size: 1rem;
}
.instruction p {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
line-height: 1.4;
}
/* 动画效果 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.item {
animation: slideIn 0.3s ease;
}
/* 滚动条样式 */
.items-list::-webkit-scrollbar {
width: 6px;
}
.items-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.items-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.items-list::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.containers {
grid-template-columns: 1fr;
gap: 20px;
}
.precise-drag-drop {
padding: 15px;
}
.instruction-grid {
grid-template-columns: 1fr;
}
.container-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.header-actions {
align-self: flex-end;
}
.item-content {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.item-info {
width: 100%;
}
}

View File

@@ -0,0 +1,430 @@
import React, { useState } from "react";
import "./DragDrop.css";
const PreciseDragDrop = () => {
// 初始数据
const [leftItems, setLeftItems] = useState([
{
id: 1,
title: "需求分析",
type: "analysis",
color: "#4CAF50",
priority: "high",
},
{
id: 2,
title: "UI设计",
type: "design",
color: "#2196F3",
priority: "medium",
},
{
id: 3,
title: "前端开发",
type: "development",
color: "#FF9800",
priority: "high",
},
{
id: 4,
title: "后端开发",
type: "development",
color: "#9C27B0",
priority: "high",
},
{
id: 5,
title: "功能测试",
type: "testing",
color: "#3F51B5",
priority: "medium",
},
{
id: 6,
title: "部署上线",
type: "deployment",
color: "#009688",
priority: "low",
},
]);
const [rightItems, setRightItems] = useState([
{
id: 7,
title: "项目启动",
type: "planning",
color: "#E91E63",
priority: "high",
},
]);
const [draggingItem, setDraggingItem] = useState(null);
const [insertPosition, setInsertPosition] = useState(null); // 'above' 或 'below'
// 处理拖拽开始
const handleDragStart = (e, item, source) => {
setDraggingItem({ ...item, source });
e.dataTransfer.effectAllowed = "move";
setTimeout(() => {
e.target.classList.add("dragging");
}, 0);
};
// 处理拖拽结束
const handleDragEnd = (e) => {
setDraggingItem(null);
setInsertPosition(null);
e.target.classList.remove("dragging");
};
// 处理容器拖拽经过
const handleContainerDragOver = (e) => {
e.preventDefault();
};
// 处理容器拖拽离开
const handleContainerDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setInsertPosition(null);
}
};
// 处理项目拖拽经过(用于精确插入)
const handleItemDragOver = (e, itemId) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const mouseY = e.clientY;
const elementMiddle = rect.top + rect.height;
// 判断鼠标在元素的上半部分还是下半部分
const newPosition = mouseY < elementMiddle ? "above" : "below";
setInsertPosition(newPosition);
};
// 处理项目拖拽离开
const handleItemDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setInsertPosition(null);
}
};
// 处理放置到右侧容器空白区域
const handleDropToRightContainer = (e) => {
e.preventDefault();
if (!draggingItem) return;
// 如果是从左侧拖拽过来的
if (draggingItem.source === "left") {
// 检查是否已存在
const exists = rightItems.some((item) => item.id === draggingItem.id);
if (!exists) {
setRightItems((prev) => [
...prev,
{
...draggingItem,
source: "right",
},
]);
setLeftItems((prev) =>
prev.filter((item) => item.id !== draggingItem.id)
);
}
}
resetDragState();
};
// 处理放置到右侧容器的特定位置
const handleDropToRightItem = (e, targetItemId) => {
e.preventDefault();
e.stopPropagation();
if (!draggingItem) return;
// 从左侧拖拽到右侧的精确插入
if (draggingItem.source === "left") {
const targetIndex = rightItems.findIndex(
(item) => item.id === targetItemId
);
if (targetIndex !== -1) {
const insertIndex =
insertPosition === "above" ? targetIndex : targetIndex + 1;
// 检查是否已存在
const exists = rightItems.some((item) => item.id === draggingItem.id);
if (!exists) {
const newRightItems = [...rightItems];
newRightItems.splice(insertIndex, 0, {
...draggingItem,
source: "right",
});
setRightItems(newRightItems);
setLeftItems((prev) =>
prev.filter((item) => item.id !== draggingItem.id)
);
}
}
}
// 右侧容器内的重新排序
else if (draggingItem.source === "right") {
const draggedIndex = rightItems.findIndex(
(item) => item.id === draggingItem.id
);
const targetIndex = rightItems.findIndex(
(item) => item.id === targetItemId
);
if (
draggedIndex !== -1 &&
targetIndex !== -1 &&
draggedIndex !== targetIndex
) {
const newItems = [...rightItems];
const [draggedItem] = newItems.splice(draggedIndex, 1);
// 计算正确的插入位置
let insertIndex =
insertPosition === "above" ? targetIndex : targetIndex + 1;
if (draggedIndex < insertIndex) {
insertIndex--; // 调整插入位置,因为已经移除了原元素
}
newItems.splice(insertIndex, 0, draggedItem);
setRightItems(newItems);
}
}
resetDragState();
};
// 处理拖拽回左侧容器
const handleDropToLeft = (e) => {
e.preventDefault();
if (!draggingItem || draggingItem.source !== "right") return;
setRightItems((prev) => prev.filter((item) => item.id !== draggingItem.id));
setLeftItems((prev) => [
...prev,
{
...draggingItem,
source: "left",
},
]);
resetDragState();
};
// 重置拖拽状态
const resetDragState = () => {
setDraggingItem(null);
setInsertPosition(null);
};
// 清空右侧容器
const clearRightContainer = () => {
setLeftItems((prev) => [
...prev,
...rightItems.map((item) => ({
...item,
source: "left",
})),
]);
setRightItems([]);
};
// 获取类型图标
const getTypeIcon = (type) => {
switch (type) {
case "analysis":
return "📊";
case "design":
return "🎨";
case "development":
return "💻";
case "testing":
return "🧪";
case "deployment":
return "🚀";
case "planning":
return "📋";
default:
return "📌";
}
};
// 获取优先级标签
const getPriorityLabel = (priority) => {
switch (priority) {
case "high":
return { label: "高优先级", class: "priority-high" };
case "medium":
return { label: "中优先级", class: "priority-medium" };
case "low":
return { label: "低优先级", class: "priority-low" };
default:
return { label: "普通", class: "priority-medium" };
}
};
return (
<div className="precise-drag-drop">
<div className="header">
<h1></h1>
<p></p>
</div>
<div className="containers">
{/* 左侧容器 - 待办事项 */}
<div
className={`container left-container `}
onDragOver={(e) => handleContainerDragOver(e, "left")}
onDragLeave={handleContainerDragLeave}
onDrop={handleDropToLeft}
>
<div className="container-header">
<h2>📋 </h2>
<span className="count">{leftItems.length} </span>
</div>
<div className="items-list">
{leftItems.map((item) => (
<div
key={item.id}
className="item"
draggable
onDragStart={(e) => handleDragStart(e, item, "left")}
onDragEnd={handleDragEnd}
style={{ "--item-color": item.color }}
>
<div className="item-content">
<span className="item-icon">{getTypeIcon(item.type)}</span>
<div className="item-info">
<span className="item-title">{item.title}</span>
<span
className={`priority-tag ${
getPriorityLabel(item.priority).class
}`}
>
{getPriorityLabel(item.priority).label}
</span>
</div>
</div>
<div className="item-type">{item.type}</div>
</div>
))}
{leftItems.length === 0 && (
<div className="empty-state">
<p>🎉 </p>
<span></span>
</div>
)}
</div>
</div>
{/* 右侧容器 - 进行中的任务 */}
<div
className={`container right-container`}
onDragOver={(e) => handleContainerDragOver(e, "right")}
onDragLeave={handleContainerDragLeave}
onDrop={handleDropToRightContainer}
>
<div className="container-header">
<h2>🚀 </h2>
<div className="header-actions">
<span className="count">{rightItems.length} </span>
{rightItems.length > 0 && (
<button className="clear-btn" onClick={clearRightContainer}>
</button>
)}
</div>
</div>
<div className="items-list">
{rightItems.length === 0 ? (
<div className="empty-state">
<p>📥 </p>
<span></span>
</div>
) : (
rightItems.map((item, index) => (
<div
key={item.id}
className={`item `}
draggable
onDragStart={(e) => handleDragStart(e, item, "right")}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleItemDragOver(e, item.id)}
onDragLeave={handleItemDragLeave}
onDrop={(e) => handleDropToRightItem(e, item.id)}
style={{ "--item-color": item.color }}
>
<div className="item-content">
<span className="item-index">{index + 1}</span>
<span className="item-icon">{getTypeIcon(item.type)}</span>
<div className="item-info">
<span className="item-title">{item.title}</span>
<span
className={`priority-tag ${
getPriorityLabel(item.priority).class
}`}
>
{getPriorityLabel(item.priority).label}
</span>
</div>
</div>
<div className="item-actions">
<span className="drag-handle"></span>
</div>
</div>
))
)}
</div>
</div>
</div>
<div className="instructions">
<h3>🎯 </h3>
<div className="instruction-grid">
<div className="instruction">
<span className="icon">🎯</span>
<div>
<strong></strong>
<p></p>
</div>
</div>
<div className="instruction">
<span className="icon">🔄</span>
<div>
<strong></strong>
<p></p>
</div>
</div>
<div className="instruction">
<span className="icon">📤</span>
<div>
<strong></strong>
<p></p>
</div>
</div>
<div className="instruction">
<span className="icon">🧹</span>
<div>
<strong></strong>
<p>使"清空所有"</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default PreciseDragDrop;

View File

@@ -0,0 +1,119 @@
import RadioCard from "@/components/RadioCard";
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import {
datasetTypeMap,
datasetTypes,
} from "@/pages/DataManagement/dataset.const";
import {
Dataset,
DatasetSubType,
DatasetType,
} from "@/pages/DataManagement/dataset.model";
import { Input, Select, Form } from "antd";
import TextArea from "antd/es/input/TextArea";
import { Database } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
export default function CreateTaskStepOne({
form,
taskConfig,
setTaskConfig,
}: {
form: any;
taskConfig: {
name: string;
description: string;
datasetId: string;
destDatasetName: string;
type: DatasetType;
destDatasetType: DatasetSubType;
};
setTaskConfig: (config: any) => void;
}) {
const [datasets, setDatasets] = useState<Dataset[]>([]);
const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet({ page: 0, size: 1000 });
setDatasets(data.content || []);
};
useEffect(() => {
fetchDatasets();
}, []);
const handleValuesChange = (currentValue, allValues) => {
const [key, value] = Object.entries(currentValue)[0];
let dataset = null;
if (key === "srcDatasetId") {
dataset = datasets.find((d) => d.id === value);
setTaskConfig({
...taskConfig,
...allValues,
srcDatasetName: dataset?.name || "",
});
} else {
setTaskConfig({ ...taskConfig, ...allValues });
}
};
return (
<Form
layout="vertical"
form={form}
initialValues={taskConfig}
onValuesChange={handleValuesChange}
>
<h2 className="font-medium text-gray-900 text-base mb-2"></h2>
<Form.Item label="名称" name="name" required>
<Input placeholder="输入清洗任务名称" />
</Form.Item>
<Form.Item label="描述" name="description">
<TextArea placeholder="描述清洗任务的目标和要求" rows={4} />
</Form.Item>
<h2 className="font-medium text-gray-900 pt-6 mb-2 text-base">
</h2>
<Form.Item label="源数据集" name="srcDatasetId" required>
<Select
placeholder="请选择源数据集"
options={datasets.map((dataset) => ({
label: (
<div className="flex items-center justify-between gap-3 py-2">
<div className="flex items-center font-sm text-gray-900">
<span>
{dataset.icon || <Database className="w-4 h-4 mr-2" />}
</span>
<span>{dataset.name}</span>
</div>
<div className="text-xs text-gray-500">
{datasetTypeMap[dataset?.datasetType]?.label}
</div>
</div>
),
value: dataset.id,
}))}
/>
</Form.Item>
<Form.Item label="目标数据集名称" name="destDatasetName" required>
<Input placeholder="输入目标数据集名称" />
</Form.Item>
<Form.Item
label="目标数据集类型"
name="destDatasetType"
rules={[{ required: true, message: "请选择目标数据集类型" }]}
>
<RadioCard
options={datasetTypes}
value={taskConfig.destDatasetType}
onChange={(type) => {
form.setFieldValue("destDatasetType", type);
setTaskConfig({
...taskConfig,
destDatasetType: type as DatasetSubType,
});
}}
/>
</Form.Item>
</Form>
);
}

View File

@@ -0,0 +1,38 @@
import { Input, Form } from "antd";
const { TextArea } = Input;
export default function CreateTemplateStepOne({
form,
templateConfig,
setTemplateConfig,
}: {
form: any;
templateConfig: { name: string; description: string; type: string };
setTemplateConfig: React.Dispatch<
React.SetStateAction<{ name: string; description: string; type: string }>
>;
}) {
const handleValuesChange = (_, allValues) => {
setTemplateConfig({ ...templateConfig, ...allValues });
};
return (
<Form
form={form}
layout="vertical"
initialValues={templateConfig}
onValuesChange={handleValuesChange}
>
<Form.Item
label="模板名称"
name="name"
rules={[{ required: true, message: "请输入模板名称" }]}
>
<Input placeholder="输入模板名称" />
</Form.Item>
<Form.Item label="模板描述" name="description">
<TextArea placeholder="描述模板的用途和特点" rows={4} />
</Form.Item>
</Form>
);
}

View File

@@ -0,0 +1,81 @@
import React from "react";
import { Tag, Divider, Form } from "antd";
import ParamConfig from "./ParamConfig";
import { Settings } from "lucide-react";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
// OperatorConfig/OperatorTemplate 类型需根据主文件实际导入
interface OperatorConfigProps {
selectedOp: OperatorI;
renderParamConfig?: (
operator: OperatorI,
paramKey: string,
param: any
) => React.ReactNode;
handleConfigChange?: (
operatorId: string,
paramKey: string,
value: any
) => void;
}
const OperatorConfig: React.FC<OperatorConfigProps> = ({
selectedOp,
renderParamConfig,
handleConfigChange,
}) => {
return (
<div className="w-1/4 min-w-3xs flex flex-col h-full">
<div className="px-4 pb-4 border-b border-gray-200">
<span className="font-semibold text-base flex items-center gap-2">
<Settings />
</span>
</div>
<div className="flex-1 overflow-auto p-4">
{selectedOp ? (
<div>
<div className="mb-4">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{selectedOp.name}</span>
</div>
<div className="text-sm text-gray-500">
{selectedOp.description}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{selectedOp?.tags?.map((tag: string) => (
<Tag key={tag} color="default">
{tag}
</Tag>
))}
</div>
</div>
<Divider />
<Form layout="vertical">
{Object.entries(selectedOp.configs).map(([key, param]) =>
renderParamConfig ? (
renderParamConfig(selectedOp, key, param)
) : (
<ParamConfig
key={key}
operator={selectedOp}
paramKey={key}
param={param}
onParamChange={handleConfigChange}
/>
)
)}
</Form>
</div>
) : (
<div className="text-center py-12 text-gray-400">
<Settings className="w-full w-10 h-10 mb-4 opacity-50" />
<div></div>
</div>
)}
</div>
</div>
);
};
export default OperatorConfig;

View File

@@ -0,0 +1,282 @@
import React, { useMemo, useState } from "react";
import {
Card,
Input,
Select,
Tooltip,
Collapse,
Tag,
Checkbox,
Button,
} from "antd";
import { StarFilled, StarOutlined, SearchOutlined } from "@ant-design/icons";
import { CategoryI, OperatorI } from "@/pages/OperatorMarket/operator.model";
import { Layers } from "lucide-react";
interface OperatorListProps {
operators: OperatorI[];
favorites: Set<string>;
showPoppular?: boolean;
toggleFavorite: (id: string) => void;
toggleOperator: (operator: OperatorI) => void;
selectedOperators: OperatorI[];
onDragOperator: (
e: React.DragEvent,
item: OperatorI,
source: "library"
) => void;
}
const OperatorList: React.FC<OperatorListProps> = ({
operators,
favorites,
toggleFavorite,
toggleOperator,
showPoppular,
selectedOperators,
onDragOperator,
}) => (
<div className="grid grid-cols-1 gap-2">
{operators.map((operator) => {
// 判断是否已选
const isSelected = selectedOperators.some((op) => op.id === operator.id);
return (
<Card
size="small"
key={operator.id}
draggable
hoverable
onDragStart={(e) => onDragOperator(e, operator, "library")}
onClick={() => toggleOperator(operator)}
>
<div className="flex items-center justify-between">
<div className="flex flex-1 min-w-0 items-center gap-2">
<Checkbox checked={isSelected} />
<span className="flex-1 min-w-0 font-medium text-sm overflow-hidden text-ellipsis whitespace-nowrap">
{operator.name}
</span>
</div>
{showPoppular && operator.isStar && (
<Tag color="gold" className="text-xs">
</Tag>
)}
<span
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
toggleFavorite(operator.id);
}}
>
{favorites.has(operator.id) ? (
<StarFilled style={{ color: "#FFD700" }} />
) : (
<StarOutlined />
)}
</span>
</div>
</Card>
);
})}
</div>
);
interface OperatorLibraryProps {
selectedOperators: OperatorI[];
operatorList: OperatorI[];
categoryOptions: CategoryI[];
setSelectedOperators: (operators: OperatorI[]) => void;
toggleOperator: (template: OperatorI) => void;
handleDragStart: (
e: React.DragEvent,
item: OperatorI,
source: "library"
) => void;
}
const OperatorLibrary: React.FC<OperatorLibraryProps> = ({
selectedOperators,
operatorList,
categoryOptions,
setSelectedOperators,
toggleOperator,
handleDragStart,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [showFavorites, setShowFavorites] = useState(false);
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set([])
);
// 按分类分组
const groupedOperators = useMemo(() => {
const groups: { [key: string]: OperatorI[] } = {};
categoryOptions.forEach((cat: any) => {
groups[cat.name] = {
...cat,
operators: operatorList.filter((op) => op.categories?.includes(cat.id)),
};
});
if (selectedCategory && selectedCategory !== "all") {
Object.keys(groups).forEach((key) => {
if (groups[key].id !== selectedCategory) {
delete groups[key];
}
});
}
if (searchTerm) {
Object.keys(groups).forEach((key) => {
groups[key].operators = groups[key].operators.filter((operator) =>
operator.name.toLowerCase().includes(searchTerm.toLowerCase())
);
if (groups[key].operators.length === 0) {
delete groups[key];
}
});
}
if (showFavorites) {
Object.keys(groups).forEach((key) => {
groups[key].operators = groups[key].operators.filter((operator) =>
favorites.has(operator.id)
);
if (groups[key].operators.length === 0) {
delete groups[key];
}
});
}
setExpandedCategories(new Set(Object.keys(groups)));
return groups;
}, [categoryOptions, selectedCategory, searchTerm, showFavorites]);
// 过滤算子
const filteredOperators = useMemo(() => {
const filtered = Object.values(groupedOperators).flatMap(
(category) => category.operators
);
return filtered;
}, [groupedOperators]);
// 收藏切换
const toggleFavorite = (operatorId: string) => {
const newFavorites = new Set(favorites);
if (newFavorites.has(operatorId)) {
newFavorites.delete(operatorId);
} else {
newFavorites.add(operatorId);
}
setFavorites(newFavorites);
};
// 全选分类算子
const handleSelectAll = (operators: OperatorI[]) => {
const newSelected = [...selectedOperators];
operators.forEach((operator) => {
if (!newSelected.some((op) => op.id === operator.id)) {
newSelected.push(operator);
}
});
setSelectedOperators(newSelected);
};
return (
<div className="w-1/4 h-full min-w-3xs flex flex-col">
<div className="pb-4 border-b border-gray-200">
<span className="flex items-center font-semibold text-base">
<Layers className="w-4 h-4 mr-2" />
({filteredOperators.length})
</span>
</div>
<div className="flex flex-col h-full pt-4 pr-4 overflow-hidden">
{/* 过滤器 */}
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-4">
<Input
prefix={<SearchOutlined />}
placeholder="搜索算子名称..."
value={searchTerm}
allowClear
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Select
value={selectedCategory}
options={[{ label: "全部分类", value: "all" }, ...categoryOptions]}
onChange={setSelectedCategory}
className="flex-1"
placeholder="选择分类"
></Select>
<Tooltip title="只看收藏">
<span
className="cursor-pointer"
onClick={() => setShowFavorites(!showFavorites)}
>
{showFavorites ? (
<StarFilled style={{ color: "#FFD700" }} />
) : (
<StarOutlined />
)}
</span>
</Tooltip>
</div>
{/* 算子列表 */}
<div className="flex-1 overflow-auto">
{/* 分类算子 */}
<Collapse
ghost
activeKey={Array.from(expandedCategories)}
onChange={(keys) =>
setExpandedCategories(
new Set(Array.isArray(keys) ? keys : [keys])
)
}
>
{Object.entries(groupedOperators).map(([key, category]) => (
<Collapse.Panel
key={key}
header={
<div className="flex items-center justify-between w-full">
<span className="flex items-center gap-2">
<span>{category.name}</span>
<Tag>{category.operators.length}</Tag>
</span>
<Button
type="link"
size="small"
onClick={(e) => {
e.stopPropagation();
handleSelectAll(category.operators);
}}
>
</Button>
</div>
}
>
<OperatorList
showPoppular
selectedOperators={selectedOperators}
operators={category.operators}
favorites={favorites}
toggleOperator={toggleOperator}
onDragOperator={handleDragStart}
toggleFavorite={toggleFavorite}
/>
</Collapse.Panel>
))}
</Collapse>
{filteredOperators.length === 0 && (
<div className="text-center py-8 text-gray-400">
<SearchOutlined className="text-3xl mb-2 opacity-50" />
<div></div>
</div>
)}
</div>
</div>
</div>
);
};
export default OperatorLibrary;

View File

@@ -0,0 +1,207 @@
import React, { useState } from "react";
import { Card, Input, Tag, Select, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { CleansingTemplate } from "../../cleansing.model";
import { Workflow } from "lucide-react";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
interface OperatorFlowProps {
selectedOperators: OperatorI[];
configOperator: OperatorI | null;
templates: CleansingTemplate[];
currentTemplate: CleansingTemplate | null;
setCurrentTemplate: (template: CleansingTemplate | null) => void;
removeOperator: (id: string) => void;
setSelectedOperators: (operators: OperatorI[]) => void;
setConfigOperator: (operator: OperatorI | null) => void;
handleDragStart: (
e: React.DragEvent,
operator: OperatorI,
source: "sort"
) => void;
handleItemDragOver: (e: React.DragEvent, itemId: string) => void;
handleItemDragLeave: (e: React.DragEvent) => void;
handleItemDrop: (e: React.DragEvent, index: number) => void;
handleContainerDragOver: (e: React.DragEvent) => void;
handleContainerDragLeave: (e: React.DragEvent) => void;
handleDragEnd: (e: React.DragEvent) => void;
handleDropToContainer: (e: React.DragEvent) => void;
}
const OperatorFlow: React.FC<OperatorFlowProps> = ({
selectedOperators,
configOperator,
templates,
currentTemplate,
setSelectedOperators,
setConfigOperator,
removeOperator,
setCurrentTemplate,
handleDragStart,
handleItemDragLeave,
handleItemDragOver,
handleItemDrop,
handleContainerDragLeave,
handleDropToContainer,
handleDragEnd,
}) => {
const [editingIndex, setEditingIndex] = useState<string | null>(null);
// 添加编号修改处理函数
const handleIndexChange = (operatorId: string, newIndex: string) => {
const index = Number.parseInt(newIndex);
if (isNaN(index) || index < 1 || index > selectedOperators.length) {
return; // 无效输入,不处理
}
const currentIndex = selectedOperators.findIndex(
(op) => op.id === operatorId
);
if (currentIndex === -1) return;
const targetIndex = index - 1; // 转换为0基索引
if (currentIndex === targetIndex) return; // 位置没有变化
const newOperators = [...selectedOperators];
const [movedOperator] = newOperators.splice(currentIndex, 1);
newOperators.splice(targetIndex, 0, movedOperator);
setSelectedOperators(newOperators);
setEditingIndex(null);
};
return (
<div className="w-1/2 h-full min-w-xs flex-1 flex flex-col border-x border-gray-200">
{/* 工具栏 */}
<div className="px-4 pb-2 border-b border-gray-200">
<div className="flex flex-wrap gap-2 justify-between items-start">
<span className="font-semibold text-base flex items-center gap-2">
<Workflow className="w-5 h-5" />
({selectedOperators.length}){" "}
<Button
type="link"
size="small"
onClick={() => {
setConfigOperator(null);
setSelectedOperators([]);
}}
disabled={selectedOperators.length === 0}
>
</Button>
</span>
<Select
placeholder="选择模板"
className="min-w-64"
options={templates}
value={currentTemplate?.value}
onChange={(value) =>
setCurrentTemplate(
templates.find((t) => t.value === value) || null
)
}
></Select>
</div>
</div>
{/* 编排区域 */}
<div
className="flex-1 overflow-auto p-4 flex flex-col gap-2"
onDragOver={(e) => e.preventDefault()}
onDragLeave={handleContainerDragLeave}
onDrop={handleDropToContainer}
>
{selectedOperators.map((operator, index) => (
<Card
size="small"
key={operator.id}
style={
configOperator?.id === operator.id
? { borderColor: "#1677ff" }
: {}
}
hoverable
draggable
onDragStart={(e) => handleDragStart(e, operator, "sort")}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleItemDragOver(e, operator.id)}
onDragLeave={handleItemDragLeave}
onDrop={(e) => handleItemDrop(e, index)}
onClick={() => setConfigOperator(operator)}
>
<div className="flex items-center gap-1">
{/* 可编辑编号 */}
<span></span>
{editingIndex === operator.id ? (
<Input
type="number"
min={1}
max={selectedOperators.length}
defaultValue={index + 1}
className="w-10 h-6 text-xs text-center"
autoFocus
onBlur={(e) => handleIndexChange(operator.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter")
handleIndexChange(
operator.id,
(e.target as HTMLInputElement).value
);
else if (e.key === "Escape") setEditingIndex(null);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<Tag
color="default"
onClick={(e) => {
e.stopPropagation();
setEditingIndex(operator.id);
}}
>
{index + 1}
</Tag>
)}
{/* 算子图标和名称 */}
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="font-medium text-sm truncate">
{operator.name}
</span>
</div>
{/* 分类标签 */}
<Tag color="default"></Tag>
{/* 参数状态指示 */}
{Object.values(operator.configs).some(
(param: any) =>
(param.type === "input" && !param.value) ||
(param.type === "checkbox" &&
Array.isArray(param.value) &&
param.value.length === 0)
) && <Tag color="red"></Tag>}
{/* 操作按钮 */}
<span
className="cursor-pointer text-red-500"
onClick={(e) => {
e.stopPropagation();
removeOperator(operator.id);
}}
>
<DeleteOutlined />
</span>
</div>
</Card>
))}
{selectedOperators.length === 0 && (
<div className="text-center py-16 text-gray-400 border-2 border-dashed border-gray-100 rounded-lg">
<Workflow className="w-full w-10 h-10 mb-4 opacity-50" />
<div className="text-lg font-medium mb-2"></div>
<div className="text-sm">
</div>
</div>
)}
</div>
</div>
);
};
export default OperatorFlow;

View File

@@ -0,0 +1,234 @@
import React from "react";
import {
Input,
Select,
Radio,
Checkbox,
Form,
InputNumber,
Slider,
Space,
} from "antd";
import { ConfigI, OperatorI } from "@/pages/OperatorMarket/operator.model";
interface ParamConfigProps {
operator: OperatorI;
paramKey: string;
param: ConfigI;
onParamChange?: (operatorId: string, paramKey: string, value: any) => void;
}
const ParamConfig: React.FC<ParamConfigProps> = ({
operator,
paramKey,
param,
onParamChange,
}) => {
if (!param) return null;
const [value, setValue] = React.useState(param.value || param.defaultVal);
const updateValue = (newValue: any) => {
setValue(newValue);
return onParamChange && onParamChange(operator.id, paramKey, newValue);
};
switch (param.type) {
case "input":
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<Input
value={value}
onChange={(e) => updateValue(e.target.value)}
placeholder={`请输入${param.name}`}
className="w-full"
/>
</Form.Item>
);
case "select":
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<Select
value={value}
onChange={updateValue}
options={(param.options || []).map((option: any) =>
typeof option === "string"
? { label: option, value: option }
: option
)}
placeholder={`请选择${param.name}`}
className="w-full"
/>
</Form.Item>
);
case "radio":
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<Radio.Group
value={value}
onChange={(e) => updateValue(e.target.value)}
>
{(param.options || []).map((option: any) => (
<Radio
key={typeof option === "string" ? option : option.value}
value={typeof option === "string" ? option : option.value}
>
{typeof option === "string" ? option : option.label}
</Radio>
))}
</Radio.Group>
</Form.Item>
);
case "checkbox":
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<Checkbox.Group
value={value}
onChange={updateValue}
options={param.options || []}
/>
</Form.Item>
);
case "slider":
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<div className="flex items-center gap-1">
<Slider
value={value}
onChange={updateValue}
tooltip={{ open: true }}
marks={{
[param.min || 0]: `${param.min || 0}`,
[param.min + (param.max - param.min) / 2]: `${
(param.min + param.max) / 2
}`,
[param.max || 100]: `${param.max || 100}`,
}}
min={param.min || 0}
max={param.max || 100}
step={param.step || 1}
className="flex-1"
/>
<InputNumber
min={param.min || 0}
max={param.max || 100}
step={param.step || 1}
value={value}
onChange={updateValue}
style={{ width: 80 }}
/>
</div>
</Form.Item>
);
case "range": {
const min = param.min || 0;
const max = param.max || 100;
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<Slider
value={Array.isArray(value) ? value : [value, value]}
onChange={(val) =>
updateValue(Array.isArray(val) ? val : [val, val])
}
range
min={min}
max={max}
step={param.step || 1}
className="w-full"
/>
<Space>
<InputNumber
min={min}
max={max}
value={value[0]}
onChange={(val1) => updateValue([val1, value[1]])}
changeOnWheel
/>
~
<InputNumber
min={min}
max={max}
value={value[1]}
onChange={(val2) => updateValue([value[0], val2])}
changeOnWheel
/>
</Space>
</Form.Item>
);
}
case "inputNumber":
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<InputNumber
value={value}
onChange={(val) => updateValue(val)}
placeholder={`请输入${param.name}`}
className="w-full"
min={param.min}
max={param.max}
step={param.step || 1}
/>
</Form.Item>
);
case "switch":
return (
<Form.Item
label={param.name}
tooltip={param.description}
key={paramKey}
>
<Checkbox
checked={value as boolean}
onChange={(e) => updateValue(e.target.checked)}
>
{param.name}
</Checkbox>
</Form.Item>
);
case "multiple":
return (
<div className="pl-4 border-l border-gray-300">
{param.properties.map((subParam) => (
<Config
key={subParam.key}
operator={operator}
paramKey={subParam.key}
param={subParam}
onParamChange={onParamChange}
/>
))}
</div>
);
default:
return null;
}
};
export default ParamConfig;

View File

@@ -0,0 +1,86 @@
import { useDragOperators } from "./useDragOperators";
import { useOperatorOperations } from "./useOperatorOperations";
import OperatorConfig from "../components/OperatorConfig";
import OperatorLibrary from "../components/OperatorLibrary";
import OperatorOrchestration from "../components/OperatorOrchestration";
export function useCreateStepTwo() {
const {
operators,
selectedOperators,
templates,
currentTemplate,
configOperator,
currentStep,
categoryOptions,
handlePrev,
handleNext,
setCurrentTemplate,
setConfigOperator,
setSelectedOperators,
handleConfigChange,
toggleOperator,
removeOperator,
} = useOperatorOperations();
const {
handleDragStart,
handleDragEnd,
handleContainerDragOver,
handleContainerDragLeave,
handleItemDragOver,
handleItemDragLeave,
handleItemDrop,
handleDropToContainer,
} = useDragOperators({
operators: selectedOperators,
setOperators: setSelectedOperators,
});
const renderStepTwo = (
<div className="flex w-full h-full">
{/* 左侧算子库 */}
<OperatorLibrary
categoryOptions={categoryOptions}
selectedOperators={selectedOperators}
operatorList={operators}
setSelectedOperators={setSelectedOperators}
toggleOperator={toggleOperator}
handleDragStart={handleDragStart}
/>
{/* 中间算子编排区域 */}
<OperatorOrchestration
selectedOperators={selectedOperators}
configOperator={configOperator}
templates={templates}
currentTemplate={currentTemplate}
setSelectedOperators={setSelectedOperators}
setConfigOperator={setConfigOperator}
setCurrentTemplate={setCurrentTemplate}
removeOperator={removeOperator}
handleDragStart={handleDragStart}
handleContainerDragLeave={handleContainerDragLeave}
handleContainerDragOver={handleContainerDragOver}
handleItemDragOver={handleItemDragOver}
handleItemDragLeave={handleItemDragLeave}
handleItemDrop={handleItemDrop}
handleDropToContainer={handleDropToContainer}
handleDragEnd={handleDragEnd}
/>
{/* 右侧参数配置面板 */}
<OperatorConfig
selectedOp={configOperator}
handleConfigChange={handleConfigChange}
/>
</div>
);
return {
renderStepTwo,
selectedOperators,
currentStep,
handlePrev,
handleNext,
};
}

View File

@@ -0,0 +1,158 @@
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
import React, { useState } from "react";
export function useDragOperators({
operators,
setOperators,
}: {
operators: OperatorI[];
setOperators: (operators: OperatorI[]) => void;
}) {
const [draggingItem, setDraggingItem] = useState<OperatorI | null>(null);
const [draggingSource, setDraggingSource] = useState<
"library" | "sort" | null
>(null);
const [insertPosition, setInsertPosition] = useState<
"above" | "below" | null
>(null);
// 处理拖拽开始
const handleDragStart = (
e: React.DragEvent,
item: OperatorI,
source: "library" | "sort"
) => {
setDraggingItem({
...item,
originalId: item.id,
});
setDraggingSource(source);
e.dataTransfer.effectAllowed = "move";
};
// 处理拖拽结束
const handleDragEnd = () => {
setDraggingItem(null);
setInsertPosition(null);
};
// 处理容器拖拽经过
const handleContainerDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
// 处理容器拖拽离开
const handleContainerDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setInsertPosition(null);
}
};
// 处理项目拖拽经过
const handleItemDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const mouseY = e.clientY;
const elementMiddle = rect.top + rect.height / 2;
// 判断鼠标在元素的上半部分还是下半部分
const newPosition = mouseY < elementMiddle ? "above" : "below";
setInsertPosition(newPosition);
};
// 处理项目拖拽离开
const handleItemDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setInsertPosition(null);
}
};
// 处理放置到空白区域
const handleDropToContainer = (e: React.DragEvent) => {
e.preventDefault();
if (!draggingItem) return;
// 如果是从算子库拖拽过来的
if (draggingSource === "library") {
// 检查是否已存在
const exists = operators.some((item) => item.id === draggingItem.id);
if (!exists) {
setOperators([...operators, draggingItem]);
}
}
resetDragState();
};
// 处理放置到特定位置
const handleItemDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
e.stopPropagation();
if (!draggingItem) return;
// 从左侧拖拽到右侧的精确插入
if (draggingSource === "library") {
if (targetIndex !== -1) {
const insertIndex =
insertPosition === "above" ? targetIndex : targetIndex + 1;
// 检查是否已存在
const exists = operators.some((item) => item.id === draggingItem.id);
if (!exists) {
const newRightItems = [...operators];
newRightItems.splice(insertIndex, 0, draggingItem);
setOperators(newRightItems);
}
}
}
// 右侧容器内的重新排序
else if (draggingSource === "sort") {
const draggedIndex = operators.findIndex(
(item) => item.id === draggingItem.id
);
if (
draggedIndex !== -1 &&
targetIndex !== -1 &&
draggedIndex !== targetIndex
) {
const newItems = [...operators];
const [draggedItem] = newItems.splice(draggedIndex, 1);
// 计算正确的插入位置
let insertIndex =
insertPosition === "above" ? targetIndex : targetIndex + 1;
if (draggedIndex < insertIndex) {
insertIndex--; // 调整插入位置,因为已经移除了原元素
}
newItems.splice(insertIndex, 0, draggedItem);
setOperators(newItems);
}
}
resetDragState();
};
// 重置拖拽状态
const resetDragState = () => {
setDraggingItem(null);
setInsertPosition(null);
};
return {
handleDragStart,
handleDragEnd,
handleContainerDragOver,
handleContainerDragLeave,
handleItemDragOver,
handleItemDragLeave,
handleItemDrop,
handleDropToContainer,
};
}

View File

@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
import { CleansingTemplate } from "../../cleansing.model";
import { queryCleaningTemplatesUsingGet } from "../../cleansing.api";
import {
queryCategoryTreeUsingGet,
queryOperatorsUsingPost,
} from "@/pages/OperatorMarket/operator.api";
export function useOperatorOperations() {
const [currentStep, setCurrentStep] = useState(1);
const [operators, setOperators] = useState<OperatorI[]>([]);
const [selectedOperators, setSelectedOperators] = useState<OperatorI[]>([]);
const [configOperator, setConfigOperator] = useState<OperatorI | null>(null);
const [templates, setTemplates] = useState<CleansingTemplate[]>([]);
const [currentTemplate, setCurrentTemplate] =
useState<CleansingTemplate | null>(null);
// 将后端返回的算子数据映射为前端需要的格式
const mapOperator = (op: OperatorI) => {
const configs =
op.settings && typeof op.settings === "string"
? JSON.parse(op.settings)
: {};
const defaultParams: Record<string, string> = {};
Object.keys(configs).forEach((key) => {
const { value } = configs[key];
defaultParams[key] = value;
});
return {
...op,
defaultParams,
configs,
};
};
const [categoryOptions, setCategoryOptions] = useState([]);
const initOperators = async () => {
const [categoryRes, operatorRes] = await Promise.all([
queryCategoryTreeUsingGet(),
queryOperatorsUsingPost({ page: 0, size: 1000 }),
]);
const operators = operatorRes.data.content.map(mapOperator);
setOperators(operators || []);
const options = categoryRes.data.content.reduce((acc: any[], item: any) => {
const cats = item.categories.map((cat) => ({
...cat,
type: item.name,
label: cat.name,
value: cat.id,
icon: cat.icon,
operators: operators.filter((op) => op[item.name] === cat.name),
}));
acc.push(...cats);
return acc;
}, [] as { id: string; name: string; icon: React.ReactNode }[]);
setCategoryOptions(options);
};
const initTemplates = async () => {
const { data } = await queryCleaningTemplatesUsingGet();
const newTemplates =
data.content?.map?.((item) => ({
...item,
label: item.name,
value: item.id,
})) || [];
setTemplates(newTemplates);
};
useEffect(() => {
setSelectedOperators(currentTemplate?.instance?.map(mapOperator) || []);
}, [currentTemplate]);
useEffect(() => {
initTemplates();
initOperators();
}, []);
const toggleOperator = (operator: OperatorI) => {
const exist = selectedOperators.find((op) => op.id === operator.id);
if (exist) {
setSelectedOperators(
selectedOperators.filter((op) => op.id !== operator.id)
);
} else {
setSelectedOperators([...selectedOperators, { ...operator }]);
}
};
// 删除算子
const removeOperator = (id: string) => {
setSelectedOperators(selectedOperators.filter((op) => op.id !== id));
if (configOperator?.id === id) setConfigOperator(null);
};
// 配置算子参数变化
const handleConfigChange = (
operatorId: string,
paramKey: string,
value: any
) => {
setSelectedOperators((prev) =>
prev.map((op) =>
op.id === operatorId
? {
...op,
overrides: {
...(op?.overrides || op?.defaultParams),
[paramKey]: value,
},
}
: op
)
);
};
const handleNext = () => {
if (currentStep < 2) {
setCurrentStep(currentStep + 1);
}
};
const handlePrev = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
return {
currentStep,
templates,
currentTemplate,
configOperator,
categoryOptions,
setConfigOperator,
setCurrentTemplate,
setCurrentStep,
operators,
setOperators,
selectedOperators,
setSelectedOperators,
handleConfigChange,
toggleOperator,
removeOperator,
handleNext,
handlePrev,
};
}

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from "react";
import { Card, Breadcrumb, App } from "antd";
import {
Play,
Pause,
Clock,
CheckCircle,
AlertCircle,
Database,
Trash2,
Activity,
} from "lucide-react";
import DetailHeader from "@/components/DetailHeader";
import { Link, useNavigate, useParams } from "react-router";
import {
deleteCleaningTaskByIdUsingDelete,
executeCleaningTaskUsingPost,
queryCleaningTaskByIdUsingGet,
stopCleaningTaskUsingPost,
} from "../cleansing.api";
import { TaskStatusMap } from "../cleansing.const";
import { 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";
// 任务详情页面组件
export default function CleansingTaskDetail() {
const { id = "" } = useParams(); // 获取动态路由参数
const { message } = App.useApp();
const navigate = useNavigate();
const fetchTaskDetail = async () => {
if (!id) return;
try {
const { data } = await queryCleaningTaskByIdUsingGet(id);
setTask(data);
} catch (error) {
message.error("获取任务详情失败");
navigate("/data/cleansing");
}
};
const pauseTask = async () => {
await stopCleaningTaskUsingPost(id);
message.success("任务已暂停");
fetchTaskDetail();
};
const startTask = async () => {
await executeCleaningTaskUsingPost(id);
message.success("任务已启动");
fetchTaskDetail();
};
const deleteTask = async () => {
await deleteCleaningTaskByIdUsingDelete(id);
message.success("任务已删除");
navigate("/data/cleansing");
};
useEffect(() => {
fetchTaskDetail();
}, [id]);
const [task, setTask] = useState(null);
const [activeTab, setActiveTab] = useState("basic");
const headerData = {
...task,
icon: <Database className="w-8 h-8" />,
status: TaskStatusMap[task?.status],
createdAt: task?.startTime,
lastUpdated: task?.updatedAt,
};
const statistics = [
{
icon: <Clock className="w-4 h-4 text-blue-500" />,
label: "总耗时",
value: task?.duration || "--",
},
{
icon: <CheckCircle className="w-4 h-4 text-green-500" />,
label: "成功文件",
value: task?.successFiles || "--",
},
{
icon: <AlertCircle className="w-4 h-4 text-red-500" />,
label: "失败文件",
value: task?.failedFiles || "--",
},
{
icon: <Activity className="w-4 h-4 text-purple-500" />,
label: "成功率",
value: `${task?.progress}%`,
},
];
const operations = [
...(task?.status === TaskStatus.RUNNING
? [
{
key: "pause",
label: "暂停任务",
icon: <Pause className="w-4 h-4" />,
onClick: pauseTask,
},
]
: []),
...(task?.status === TaskStatus.PENDING
? [
{
key: "start",
label: "执行任务",
icon: <Play className="w-4 h-4" />,
onClick: startTask,
},
]
: []),
{
key: "delete",
label: "删除任务",
icon: <Trash2 className="w-4 h-4" />,
danger: true,
onClick: deleteTask,
},
];
const tabList = [
{
key: "basic",
tab: "基本信息",
children: <BasicInfo task={task} />,
},
{
key: "operators",
tab: "处理算子",
children: <OperatorTable task={task} />,
},
{
key: "files",
tab: "处理文件",
children: <FileTable task={task} />,
},
{ key: "logs", tab: "运行日志", children: <LogsTable task={task} /> },
];
const breadItems = [
{
title: <Link to="/data/cleansing"></Link>,
},
{
title: "清洗任务详情",
},
];
return (
<div className="min-h-screen">
<Breadcrumb items={breadItems} />
<div className="mb-4 mt-4">
<DetailHeader
data={headerData}
statistics={statistics}
operations={operations}
/>
</div>
<Card
tabList={tabList}
activeTabKey={activeTab}
onTabChange={setActiveTab}
></Card>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import type { CleansingTask } from "@/pages/DataCleansing/cleansing.model";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
import { Button, Card, Descriptions, Progress, Tag } from "antd";
import { Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
import { useNavigate } from "react-router";
export default function BasicInfo({ task }: { task: CleansingTask }) {
const navigate = useNavigate();
const descriptionItems = [
{
key: "id",
label: "任务ID",
children: <span className="font-mono">#{task?.id}</span>,
},
{ key: "name", label: "任务名称", children: task?.name },
{
key: "dataset",
label: "源数据集",
children: (
<Button
type="link"
size="small"
onClick={() =>
navigate("/data/management/detail/" + task?.srcDatasetId)
}
>
{task?.srcDatasetName}
</Button>
),
},
{
key: "targetDataset",
label: "目标数据集",
children: (
<Button
type="link"
size="small"
onClick={() =>
navigate("/data/management/detail/" + task?.destDatasetId)
}
>
{task?.destDatasetName}
</Button>
),
},
{ key: "template", label: "使用模板", children: task?.template },
{ key: "startTime", label: "开始时间", children: task?.startedAt },
{ key: "estimatedTime", label: "预计用时", children: task?.estimatedTime },
{
key: "description",
label: "任务描述",
children: (
<span className="text-gray-600">{task?.description || "暂无描述"}</span>
),
span: 2,
},
{
key: "rules",
label: "处理算子",
children: (
<div className="flex flex-wrap gap-1">
{task?.instance?.map?.((op: OperatorI) => (
<Tag key={op.id}>{op.name}</Tag>
))}
</div>
),
span: 2,
},
];
return (
<>
{/* 执行摘要 */}
<Card className="mb-6">
<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">
{task?.duration || "--"}
</div>
<div className="text-sm text-gray-600"></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?.successFiles || "--"}
</div>
<div className="text-sm text-gray-600"></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?.failedFiles || "--"}
</div>
<div className="text-sm text-gray-600"></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 || "--"}
</div>
<div className="text-sm text-gray-600"></div>
</div>
</div>
</Card>
{/* 基本信息 */}
<Card>
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<Descriptions
column={2}
bordered={false}
size="middle"
labelStyle={{ fontWeight: 500, color: "#555" }}
contentStyle={{ fontSize: 14 }}
items={descriptionItems}
></Descriptions>
</div>
{/* 处理进度 */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<Progress percent={task?.progress} showInfo />
<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>: {task?.processedFiles || "--"}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-blue-500 rounded-full inline-block" />
<span>: {task?.processingFiles || "--"}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-gray-300 rounded-full inline-block" />
<span>
: {task?.totalFiles - task?.processedFiles || "--"}
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-red-500 rounded-full inline-block" />
<span>: {task?.failedFiles || "--"}</span>
</div>
</div>
</div>
</Card>
</>
);
}

View File

@@ -0,0 +1,503 @@
import { Button, Modal, Table, Badge, Input } from "antd";
import { Download, FileText } from "lucide-react";
import { useState } from "react";
// 模拟文件列表数据
const fileList = [
{
id: 1,
fileName: "lung_cancer_001.svs",
originalSize: "15.2MB",
processedSize: "8.5MB",
status: "已完成",
duration: "2分15秒",
processedAt: "2024-01-20 09:32:40",
},
{
id: 2,
fileName: "lung_cancer_002.svs",
originalSize: "18.7MB",
processedSize: "10.2MB",
status: "已完成",
duration: "2分38秒",
processedAt: "2024-01-20 09:35:18",
},
{
id: 3,
fileName: "lung_cancer_003.svs",
originalSize: "12.3MB",
processedSize: "6.8MB",
status: "已完成",
duration: "1分52秒",
processedAt: "2024-01-20 09:37:10",
},
{
id: 4,
fileName: "lung_cancer_004.svs",
originalSize: "20.1MB",
processedSize: "-",
status: "失败",
duration: "0分45秒",
processedAt: "2024-01-20 09:38:55",
},
{
id: 5,
fileName: "lung_cancer_005.svs",
originalSize: "16.8MB",
processedSize: "9.3MB",
status: "已完成",
duration: "2分22秒",
processedAt: "2024-01-20 09:41:17",
},
];
export default function FileTable() {
const [showFileCompareDialog, setShowFileCompareDialog] = useState(false);
const [showFileLogDialog, setShowFileLogDialog] = useState(false);
const [selectedFile, setSelectedFile] = useState<any>(null);
const [selectedFileIds, setSelectedFileIds] = useState<number[]>([]);
const handleSelectAllFiles = (checked: boolean) => {
if (checked) {
setSelectedFileIds(fileList.map((file) => file.id));
} else {
setSelectedFileIds([]);
}
};
const handleSelectFile = (fileId: number, checked: boolean) => {
if (checked) {
setSelectedFileIds([...selectedFileIds, fileId]);
} else {
setSelectedFileIds(selectedFileIds.filter((id) => id !== fileId));
}
};
const handleViewFileCompare = (file: any) => {
setSelectedFile(file);
setShowFileCompareDialog(true);
};
const handleBatchDownload = () => {
// 实际下载逻辑
};
const handleBatchDeleteFiles = () => {
// 实际删除逻辑
setSelectedFileIds([]);
};
const handleViewFileLog = (file: any) => {
setSelectedFile(file);
setShowFileLogDialog(true);
};
// 模拟单个文件的处理日志
const getFileProcessLog = (fileName: string) => [
{
time: "09:30:18",
step: "开始处理",
operator: "格式转换",
status: "INFO",
message: `开始处理文件: ${fileName}`,
},
{
time: "09:30:19",
step: "文件验证",
operator: "格式转换",
status: "INFO",
message: "验证文件格式和完整性",
},
{
time: "09:30:20",
step: "格式解析",
operator: "格式转换",
status: "INFO",
message: "解析SVS格式文件",
},
{
time: "09:30:25",
step: "格式转换",
operator: "格式转换",
status: "SUCCESS",
message: "成功转换为JPEG格式",
},
{
time: "09:30:26",
step: "噪声检测",
operator: "噪声去除",
status: "INFO",
message: "检测图像噪声水平",
},
{
time: "09:30:28",
step: "噪声去除",
operator: "噪声去除",
status: "INFO",
message: "应用高斯滤波去除噪声",
},
{
time: "09:30:31",
step: "噪声去除完成",
operator: "噪声去除",
status: "SUCCESS",
message: "噪声去除处理完成",
},
{
time: "09:30:32",
step: "尺寸检测",
operator: "尺寸标准化",
status: "INFO",
message: "检测当前图像尺寸: 2048x1536",
},
{
time: "09:30:33",
step: "尺寸调整",
operator: "尺寸标准化",
status: "INFO",
message: "调整图像尺寸至512x512",
},
{
time: "09:30:35",
step: "尺寸标准化完成",
operator: "尺寸标准化",
status: "SUCCESS",
message: "图像尺寸标准化完成",
},
{
time: "09:30:36",
step: "质量检查",
operator: "质量检查",
status: "INFO",
message: "检查图像质量指标",
},
{
time: "09:30:38",
step: "分辨率检查",
operator: "质量检查",
status: "SUCCESS",
message: "分辨率符合要求",
},
{
time: "09:30:39",
step: "清晰度检查",
operator: "质量检查",
status: "SUCCESS",
message: "图像清晰度良好",
},
{
time: "09:30:40",
step: "处理完成",
operator: "质量检查",
status: "SUCCESS",
message: `文件 ${fileName} 处理完成`,
},
];
const fileColumns = [
{
title: (
<input
type="checkbox"
checked={
selectedFileIds.length === fileList.length && fileList.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.id)}
onChange={(e) => handleSelectFile(record.id, e.target.checked)}
className="w-4 h-4"
/>
),
},
{
title: "文件名",
dataIndex: "fileName",
key: "fileName",
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
}: any) => (
<div className="p-4 w-64">
<Input
placeholder="搜索文件名"
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()}>
</Button>
<Button size="small" onClick={() => clearFilters()}>
</Button>
</div>
</div>
),
onFilter: (value: string, record: any) =>
record.fileName.toLowerCase().includes(value.toLowerCase()),
render: (text: string) => (
<span className="font-mono text-sm">{text}</span>
),
},
{
title: "清洗前大小",
dataIndex: "originalSize",
key: "originalSize",
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);
},
},
{
title: "清洗后大小",
dataIndex: "processedSize",
key: "processedSize",
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)
);
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
filters: [
{ text: "已完成", value: "已完成" },
{ text: "失败", value: "失败" },
{ text: "处理中", value: "处理中" },
],
onFilter: (value: string, record: any) => record.status === value,
render: (status: string) => (
<Badge
status={
status === "已完成"
? "success"
: status === "失败"
? "error"
: "processing"
}
text={status}
/>
),
},
{
title: "执行耗时",
dataIndex: "duration",
key: "duration",
sorter: (a: any, b: any) => {
const getTimeInSeconds = (duration: string) => {
const parts = duration.split(/[分秒]/);
const minutes = Number.parseInt(parts[0]) || 0;
const seconds = Number.parseInt(parts[1]) || 0;
return minutes * 60 + seconds;
};
return getTimeInSeconds(a.duration) - getTimeInSeconds(b.duration);
},
},
{
title: "操作",
key: "action",
render: (text: string, record: any) => (
<div className="flex">
<Button
type="link"
size="small"
onClick={() => handleViewFileLog(record)}
>
</Button>
{record.status === "已完成" && (
<Button
type="link"
size="small"
onClick={() => handleViewFileCompare(record)}
>
</Button>
)}
<Button type="link" size="small">
</Button>
</div>
),
},
];
return (
<>
{selectedFileIds.length > 0 && (
<div className="mb-4 flex justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{selectedFileIds.length}
</span>
<Button
onClick={handleBatchDownload}
size="small"
type="primary"
icon={<Download className="w-4 h-4 mr-2" />}
>
</Button>
</div>
</div>
)}
<Table
columns={fileColumns}
dataSource={fileList}
pagination={{ pageSize: 10, showSizeChanger: true }}
size="middle"
rowKey="id"
/>
{/* 文件日志弹窗 */}
<Modal
open={showFileLogDialog}
onCancel={() => setShowFileLogDialog(false)}
footer={null}
width={700}
title={
<span>
<FileText className="w-4 h-4 mr-2 inline" />
- {selectedFile?.fileName}
</span>
}
>
<div className="py-4">
<div className="bg-gray-900 rounded-lg p-4 max-h-96 overflow-y-auto">
<div className="font-mono text-sm">
{selectedFile &&
getFileProcessLog(selectedFile.fileName).map((log, index) => (
<div key={index} className="flex gap-3">
<span className="text-gray-500 min-w-20">{log.time}</span>
<span className="text-blue-400 min-w-24">
[{log.operator}]
</span>
<span
className={`min-w-20 ${
log.status === "ERROR"
? "text-red-400"
: log.status === "SUCCESS"
? "text-green-400"
: "text-yellow-400"
}`}
>
{log.step}
</span>
<span className="text-gray-100">{log.message}</span>
</div>
))}
</div>
</div>
</div>
</Modal>
{/* 文件对比弹窗 */}
<Modal
open={showFileCompareDialog}
onCancel={() => setShowFileCompareDialog(false)}
footer={null}
width={900}
title={<span> - {selectedFile?.fileName}</span>}
>
<div className="grid grid-cols-2 gap-6 py-6">
<div>
<h4 className="font-medium text-gray-900"></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"></div>
<div className="text-xs text-gray-400">
: {selectedFile?.originalSize}
</div>
</div>
</div>
<div className="text-sm text-gray-600 mt-3 space-y-1">
<div>
<span className="font-medium">:</span> SVS
</div>
<div>
<span className="font-medium">:</span> 2048x1536
</div>
<div>
<span className="font-medium">:</span> RGB
</div>
<div>
<span className="font-medium">:</span>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900"></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"></div>
<div className="text-xs text-gray-400">
: {selectedFile?.processedSize}
</div>
</div>
</div>
<div className="text-sm text-gray-600 mt-3 space-y-1">
<div>
<span className="font-medium">:</span> JPEG
</div>
<div>
<span className="font-medium">:</span> 512x512
</div>
<div>
<span className="font-medium">:</span> RGB
</div>
<div>
<span className="font-medium">:</span> JPEG压缩
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 mt-6 pt-4">
<h4 className="font-medium text-gray-900 mb-3"></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"></div>
<div className="text-green-600"> 44.1%</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="font-medium text-blue-700"></div>
<div className="text-blue-600">{selectedFile?.duration}</div>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="font-medium text-purple-700"></div>
<div className="text-purple-600"> (9.2/10)</div>
</div>
</div>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,110 @@
export default function LogsTable({ task }: { task: any }) {
// 模拟运行日志
const runLogs = [
{
time: "09:30:15",
level: "INFO",
message: "开始执行数据清洗任务: 肺癌WSI图像清洗任务",
},
{
time: "09:30:16",
level: "INFO",
message: "加载源数据集: 肺癌WSI病理图像数据集 (1250 文件)",
},
{ time: "09:30:17", level: "INFO", message: "初始化算子: 格式转换" },
{
time: "09:30:18",
level: "INFO",
message: "开始处理文件: lung_cancer_001.svs",
},
{
time: "09:30:25",
level: "SUCCESS",
message: "文件处理成功: lung_cancer_001.svs -> lung_cancer_001.jpg",
},
{
time: "09:30:26",
level: "INFO",
message: "开始处理文件: lung_cancer_002.svs",
},
{
time: "09:30:33",
level: "SUCCESS",
message: "文件处理成功: lung_cancer_002.svs -> lung_cancer_002.jpg",
},
{
time: "09:58:42",
level: "INFO",
message: "格式转换完成,成功处理 1250/1250 文件",
},
{ time: "09:58:43", level: "INFO", message: "初始化算子: 噪声去除" },
{
time: "09:58:44",
level: "INFO",
message: "开始处理文件: lung_cancer_001.jpg",
},
{
time: "09:58:51",
level: "SUCCESS",
message: "噪声去除成功: lung_cancer_001.jpg",
},
{
time: "10:15:23",
level: "WARNING",
message: "文件质量较低,跳过处理: lung_cancer_156.jpg",
},
{
time: "10:35:18",
level: "INFO",
message: "噪声去除完成,成功处理 1228/1250 文件",
},
{ time: "10:35:19", level: "INFO", message: "初始化算子: 尺寸标准化" },
{
time: "11:12:05",
level: "INFO",
message: "尺寸标准化完成,成功处理 1222/1228 文件",
},
{ time: "11:12:06", level: "INFO", message: "初始化算子: 质量检查" },
{
time: "11:25:33",
level: "ERROR",
message: "质量检查失败: lung_cancer_089.jpg - 分辨率过低",
},
{
time: "11:45:32",
level: "INFO",
message: "质量检查完成,成功处理 1198/1222 文件",
},
{
time: "11:45:33",
level: "SUCCESS",
message: "数据清洗任务完成!总成功率: 95.8%",
},
];
return (
<div className="text-gray-300 p-4 border border-gray-700 bg-gray-800 rounded-lg">
<div className="font-mono text-sm">
{runLogs?.map?.((log, index) => (
<div key={index} className="flex gap-3">
<span className="text-gray-500 min-w-20">{log.time}</span>
<span
className={`min-w-20 ${
log.level === "ERROR"
? "text-red-500"
: log.level === "WARNING"
? "text-yellow-500"
: log.level === "SUCCESS"
? "text-green-500"
: "text-blue-500"
}`}
>
[{log.level}]
</span>
<span className="text-gray-100">{log.message}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { Button, Input, Table } from "antd";
const operators = [
{
name: "格式转换",
startTime: "09:30:15",
endTime: "09:58:42",
duration: "28分27秒",
status: "成功",
processedFiles: 1250,
successRate: 100,
},
{
name: "噪声去除",
startTime: "09:58:42",
endTime: "10:35:18",
duration: "36分36秒",
status: "成功",
processedFiles: 1250,
successRate: 98.2,
},
{
name: "尺寸标准化",
startTime: "10:35:18",
endTime: "11:12:05",
duration: "36分47秒",
status: "成功",
processedFiles: 1228,
successRate: 99.5,
},
{
name: "质量检查",
startTime: "11:12:05",
endTime: "11:45:32",
duration: "33分27秒",
status: "成功",
processedFiles: 1222,
successRate: 97.8,
},
];
export default function OperatorTable({ task }: { task: any }) {
const operatorColumns = [
{
title: "算子名称",
dataIndex: "name",
key: "name",
fixed: "left",
width: 200,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
}: any) => (
<div className="p-4 w-64">
<Input
placeholder="搜索算子名称"
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()}>
</Button>
<Button size="small" onClick={() => clearFilters()}>
</Button>
</div>
</div>
),
onFilter: (value: string, record: any) =>
record.name.toLowerCase().includes(value.toLowerCase()),
},
{
title: "版本",
dataIndex: "version",
key: "version",
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
},
{
title: "更新时间",
dataIndex: "updatedAt",
key: "updatedAt",
},
];
return (
<Table
columns={operatorColumns}
dataSource={task?.instance || operators}
pagination={false}
size="middle"
/>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { Tabs, Button } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router";
import TaskList from "./components/TaskList";
import TemplateList from "./components/TemplateList";
import ProcessFlowDiagram from "./components/ProcessFlowDiagram";
import { useSearchParams } from "@/hooks/useSearchParams";
export default function DataProcessingPage() {
const navigate = useNavigate();
const urlParams = useSearchParams();
const [currentView, setCurrentView] = useState<"task" | "template">("task");
useEffect(() => {
if (urlParams.view) {
setCurrentView(urlParams.view);
}
}, [urlParams]);
return (
<div className="h-full flex flex-col gap-4">
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-xl font-bold"></h1>
<div className="flex gap-2">
<Button
icon={<PlusOutlined />}
onClick={() => navigate("/data/cleansing/create-template")}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate("/data/cleansing/create-task")}
>
</Button>
</div>
</div>
<ProcessFlowDiagram />
<Tabs
activeKey={currentView}
onChange={(key) => setCurrentView(key as any)}
items={[
{
key: "task",
label: "任务列表",
},
{
key: "template",
label: "模板管理",
},
]}
/>
{currentView === "task" && <TaskList />}
{currentView === "template" && <TemplateList />}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import {
ArrowRight,
CheckCircle,
Database,
Play,
Settings,
Workflow,
Zap,
} from "lucide-react";
// 流程图组件
export default function ProcessFlowDiagram() {
const flowSteps = [
{
id: "start",
label: "开始",
type: "start",
icon: Play,
color: "bg-green-500",
},
{
id: "select",
label: "选择数据集",
type: "process",
icon: Database,
color: "bg-blue-500",
},
{
id: "config",
label: "基本配置",
type: "process",
icon: Settings,
color: "bg-purple-500",
},
{
id: "operators",
label: "算子编排",
type: "process",
icon: Workflow,
color: "bg-orange-500",
},
{
id: "execute",
label: "执行任务",
type: "process",
icon: Zap,
color: "bg-red-500",
},
{
id: "end",
label: "完成",
type: "end",
icon: CheckCircle,
color: "bg-green-500",
},
];
return (
<div className="rounded-xl border border-gray-200 p-6 bg-white">
<div className="w-full flex items-center justify-center">
<div className="w-full flex items-center space-x-12">
{flowSteps.map((step, index) => {
const IconComponent = step.icon;
return (
<div key={step.id} className="flex-1 flex items-center">
<div className="flex flex-col items-center w-full">
<div
className={`w-12 h-12 ${step.color} rounded-full flex items-center justify-center text-white shadow-lg`}
>
<IconComponent className="w-6 h-6" />
</div>
<span className="text-xs font-medium text-gray-700 mt-2 text-center max-w-16">
{step.label}
</span>
</div>
{index < flowSteps.length - 1 && (
<ArrowRight className="w-6 h-6 text-gray-400 mx-3" />
)}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useState } from "react";
import { Table, Progress, Badge, Button, Tooltip, Card, App } from "antd";
import {
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { SearchControls } from "@/components/SearchControls";
import CardView from "@/components/CardView";
import { useNavigate } from "react-router";
import { mapTask, TaskStatusMap } from "../../cleansing.const";
import {
TaskStatus,
type CleansingTask,
} from "@/pages/DataCleansing/cleansing.model";
import useFetchData from "@/hooks/useFetchData";
import {
deleteCleaningTaskByIdUsingDelete,
executeCleaningTaskUsingPost,
queryCleaningTasksUsingGet,
stopCleaningTaskUsingPost,
} from "../../cleansing.api";
export default function TaskList() {
const navigate = useNavigate();
const { message } = App.useApp();
const [viewMode, setViewMode] = useState<"card" | "list">("list");
const filterOptions = [
{
key: "status",
label: "状态",
options: [...Object.values(TaskStatusMap)],
},
];
const {
loading,
tableData,
pagination,
searchParams,
setSearchParams,
fetchData,
handleFiltersChange,
} = useFetchData(queryCleaningTasksUsingGet, mapTask);
const handleViewTask = (task: any) => {
navigate("/data/cleansing/task-detail/" + task.id);
};
const pauseTask = async (item: CleansingTask) => {
await stopCleaningTaskUsingPost(item.id);
message.success("任务已暂停");
fetchData();
};
const startTask = async (item: CleansingTask) => {
await executeCleaningTaskUsingPost(item.id);
message.success("任务已启动");
fetchData();
};
const deleteTask = async (item: CleansingTask) => {
await deleteCleaningTaskByIdUsingDelete(item.id);
message.success("任务已删除");
fetchData();
};
const taskOperations = (record) => {
const isRunning = record.status?.value === TaskStatus.RUNNING;
const showStart = [
TaskStatus.PENDING,
TaskStatus.FAILED,
TaskStatus.STOPPED,
].includes(record.status?.value);
const pauseBtn = {
key: "pause",
label: "暂停",
icon: isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
onClick: pauseTask, // implement pause/play logic
};
const startBtn = {
key: "start",
label: "启动",
icon: isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
onClick: startTask, // implement pause/play logic
};
return [
isRunning && pauseBtn,
showStart && startBtn,
{
key: "delete",
label: "删除",
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
onClick: deleteTask, // implement delete logic
},
];
};
const taskColumns = [
{
title: "任务名称",
dataIndex: "name",
key: "name",
fixed: "left",
width: 150,
},
{
title: "源数据集",
dataIndex: "srcDatasetId",
key: "srcDatasetId",
width: 150,
render: (_, record: CleansingTask) => {
return (
<Button
type="link"
onClick={() =>
navigate("/data/management/detail/" + record.srcDatasetId)
}
>
{record.srcDatasetName}
</Button>
);
},
},
{
title: "目标数据集",
dataIndex: "destDatasetId",
key: "destDatasetId",
width: 150,
render: (_, record: CleansingTask) => {
return (
<Button
type="link"
onClick={() =>
navigate("/data/management/detail/" + record.destDatasetId)
}
>
{record.destDatasetName}
</Button>
);
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (status: any) => {
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",
width: 200,
render: (progress: number) => (
<Progress percent={progress} size="small" />
),
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
},
{
title: "执行耗时",
dataIndex: "duration",
key: "duration",
width: 180,
},
{
title: "数据量变化",
dataIndex: "dataSizeChange",
key: "dataSizeChange",
width: 180,
render: (_: any, record: CleansingTask) => {
if (record.before !== undefined && record.after !== undefined) {
return `${record.before}${record.after}`;
}
return "-";
},
},
{
title: "操作",
key: "action",
fixed: "right",
render: (text: string, record: any) => (
<div className="flex gap-2">
{taskOperations(record).map((op) =>
op ? (
<Tooltip key={op.key} title={op.label}>
<Button
type="text"
icon={op.icon}
onClick={() => op.onClick(record)}
/>
</Tooltip>
) : null
)}
</div>
),
},
];
return (
<>
{/* Search and Filters */}
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={(keyword) =>
setSearchParams({ ...searchParams, keyword })
}
searchPlaceholder="搜索任务名称、描述"
filters={filterOptions}
onFiltersChange={handleFiltersChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
/>
{/* Task List */}
{viewMode === "card" ? (
<CardView
data={tableData}
operations={taskOperations}
pagination={pagination}
/>
) : (
<Card>
<Table
columns={taskColumns}
dataSource={tableData}
rowKey="id"
loading={loading}
scroll={{ x: "max-content", y: "calc(100vh - 35rem)" }}
pagination={pagination}
/>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,46 @@
import { DeleteOutlined } from "@ant-design/icons";
import CardView from "@/components/CardView";
import {
deleteCleaningTemplateByIdUsingDelete,
queryCleaningTemplatesUsingGet,
} from "../../cleansing.api";
import useFetchData from "@/hooks/useFetchData";
import { mapTemplate } from "../../cleansing.const";
import { App } from "antd";
import { CleansingTemplate } from "../../cleansing.model";
export default function TemplateList() {
const { message } = App.useApp();
const { tableData, pagination, fetchData } = useFetchData(
queryCleaningTemplatesUsingGet,
mapTemplate
);
const deleteTemplate = async (template: CleansingTemplate) => {
if (!template.id) {
return;
}
// 实现删除逻辑
await deleteCleaningTemplateByIdUsingDelete(template.id);
fetchData();
message.success("模板删除成功");
};
const operations = [
{
key: "delete",
label: "删除模板",
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
onClick: (template: CleansingTemplate) => deleteTemplate(template), // 可实现删除逻辑
},
];
return (
<CardView
data={tableData}
operations={operations}
pagination={pagination}
/>
);
}

View File

@@ -0,0 +1,57 @@
import { get, post, put, del } from "@/utils/request";
// 清洗任务相关接口
export function queryCleaningTasksUsingGet(params?: any) {
return get("/api/cleaning/tasks", params);
}
export function createCleaningTaskUsingPost(data: any) {
return post("/api/cleaning/tasks", data);
}
export function queryCleaningTaskByIdUsingGet(taskId: string | number) {
return get(`/api/cleaning/tasks/${taskId}`);
}
export function updateCleaningTaskByIdUsingPut(taskId: string | number, data: any) {
return put(`/api/cleaning/tasks/${taskId}`, data);
}
export function deleteCleaningTaskByIdUsingDelete(taskId: string | number) {
return del(`/api/cleaning/tasks/${taskId}`);
}
export function executeCleaningTaskUsingPost(taskId: string | number, data?: any) {
return post(`/api/cleaning/tasks/${taskId}/execute`, data);
}
export function stopCleaningTaskUsingPost(taskId: string | number, data?: any) {
return post(`/api/cleaning/tasks/${taskId}/stop`, data);
}
// 清洗模板相关接口
export function queryCleaningTemplatesUsingGet(params?: any) {
return get("/api/cleaning/templates", params);
}
export function createCleaningTemplateUsingPost(data: any) {
return post("/api/cleaning/templates", data);
}
export function queryCleaningTemplateByIdUsingGet(templateId: string | number) {
return get(`/api/cleaning/templates/${templateId}`);
}
export function updateCleaningTemplateByIdUsingPut(templateId: string | number, data: any) {
return put(`/api/cleaning/templates/${templateId}`, data);
}
export function deleteCleaningTemplateByIdUsingDelete(templateId: string | number) {
return del(`/api/cleaning/templates/${templateId}`);
}

View File

@@ -0,0 +1,134 @@
import {
CleansingTask,
CleansingTemplate,
TaskStatus,
TemplateType,
} from "@/pages/DataCleansing/cleansing.model";
import {
formatBytes,
formatDateTime,
formatExecutionDuration,
} from "@/utils/unit";
import {
ClockCircleOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
AlertOutlined,
DatabaseOutlined,
AppstoreOutlined,
PauseCircleOutlined,
} from "@ant-design/icons";
export const templateTypesMap = {
[TemplateType.TEXT]: {
label: "文本",
value: TemplateType.TEXT,
icon: "📝",
description: "处理文本数据的清洗模板",
},
[TemplateType.IMAGE]: {
label: "图片",
value: TemplateType.IMAGE,
icon: "🖼️",
description: "处理图像数据的清洗模板",
},
[TemplateType.VIDEO]: {
value: TemplateType.VIDEO,
label: "视频",
icon: "🎥",
description: "处理视频数据的清洗模板",
},
[TemplateType.AUDIO]: {
value: TemplateType.AUDIO,
label: "音频",
icon: "🎵",
description: "处理音频数据的清洗模板",
},
[TemplateType.IMAGE2TEXT]: {
value: TemplateType.IMAGE2TEXT,
label: "图片转文本",
icon: "🔄",
description: "图像识别转文本的处理模板",
},
};
export const TaskStatusMap = {
[TaskStatus.PENDING]: {
label: "待处理",
value: TaskStatus.PENDING,
color: "gray",
icon: <ClockCircleOutlined />,
},
[TaskStatus.RUNNING]: {
label: "进行中",
value: TaskStatus.RUNNING,
color: "blue",
icon: <PlayCircleOutlined />,
},
[TaskStatus.COMPLETED]: {
label: "已完成",
value: TaskStatus.COMPLETED,
color: "green",
icon: <CheckCircleOutlined />,
},
[TaskStatus.FAILED]: {
label: "失败",
value: TaskStatus.FAILED,
color: "red",
icon: <AlertOutlined />,
},
[TaskStatus.STOPPED]: {
label: "已停止",
value: TaskStatus.STOPPED,
color: "orange",
icon: <PauseCircleOutlined />,
},
};
export const mapTask = (task: CleansingTask) => {
const duration = formatExecutionDuration(task.startedAt, task.finishedAt);
const before = formatBytes(task.beforeSize);
const after = formatBytes(task.afterSize);
const status = TaskStatusMap[task.status];
const finishedAt = formatDateTime(task.finishedAt);
const startedAt = formatDateTime(task.startedAt);
const createdAt = formatDateTime(task.createdAt);
return {
...task,
createdAt,
startedAt,
finishedAt,
icon: <DatabaseOutlined style={{ color: "#1677ff" }} />,
iconColor: "bg-blue-100",
status,
duration,
before,
after,
statistics: [
{ label: "进度", value: `${task.progress || 0}%` },
{
label: "执行耗时",
value: duration,
},
{
label: "处理前数据大小",
value: task.beforeSize ? formatBytes(task.beforeSize) : "--",
},
{
label: "处理后数据大小",
value: task.afterSize ? formatBytes(task.afterSize) : "--",
},
],
lastModified: formatDateTime(task.createdAt),
};
};
export const mapTemplate = (template: CleansingTemplate) => ({
...template,
createdAt: formatDateTime(template.createdAt),
updatedAt: formatDateTime(template.updatedAt),
icon: <AppstoreOutlined style={{ color: "#1677ff" }} />,
iconColor: "bg-blue-100",
statistics: [{ label: "算子数量", value: template.instance?.length ?? 0 }],
lastModified: formatDateTime(template.updatedAt),
});

View File

@@ -0,0 +1,68 @@
import { OperatorI } from "../OperatorMarket/operator.model";
export interface CleansingTask {
id: string;
name: string;
description?: string;
srcDatasetId: string;
srcDatasetName: string;
destDatasetId: string;
destDatasetName: string;
templateId: string;
templateName: string;
status: {
label: string;
value: TaskStatus;
color: string;
};
startedAt: string;
progress: number;
operators: OperatorI[];
createdAt: string;
updatedAt: string;
finishedAt: string;
beforeSize?: number;
afterSize?: number;
}
export interface CleansingTemplate {
id: string;
name: string;
description?: string;
instance: OperatorI[];
createdAt: string;
updatedAt: string;
}
export enum RuleCategory {
DATA_VALIDATION = "DATA_VALIDATION",
MISSING_VALUE_HANDLING = "MISSING_VALUE_HANDLING",
OUTLIER_DETECTION = "OUTLIER_DETECTION",
DEDUPLICATION = "DEDUPLICATION",
FORMAT_STANDARDIZATION = "FORMAT_STANDARDIZATION",
TEXT_CLEANING = "TEXT_CLEANING",
CUSTOM = "CUSTOM",
}
export enum TaskStatus {
PENDING = "PENDING",
RUNNING = "RUNNING",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
STOPPED = "STOPPED",
}
export interface RuleCondition {
field: string;
operator: string;
value: string;
logicOperator?: "AND" | "OR";
}
export enum TemplateType {
TEXT = "TEXT",
IMAGE = "IMAGE",
VIDEO = "VIDEO",
AUDIO = "AUDIO",
IMAGE2TEXT = "IMAGE2TEXT",
}