feat(annotation): 添加标注任务算子编排前端页面和测试算子

## 功能概述
为标注任务通用算子编排功能添加完整的前端界面,包括任务创建、列表管理、详情查看等功能,并提供测试算子用于功能验证。

## 改动内容

### 前端功能

#### 1. 算子编排页面
- 新增两步创建流程:
  - 第一步:基本信息(数据集选择、任务名称等)
  - 第二步:算子编排(选择算子、配置参数、预览 pipeline)
- 核心文件:
  - frontend/src/pages/DataAnnotation/OperatorCreate/CreateTask.tsx
  - frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useOperatorOperations.ts
  - frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useDragOperators.ts
  - frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useCreateStepTwo.tsx

#### 2. UI 组件
- 算子库(OperatorLibrary):显示可用算子,支持分类筛选
- 编排区(OperatorOrchestration):拖拽排序算子
- 参数面板(OperatorConfig):配置算子参数
- Pipeline 预览(PipelinePreview):预览算子链
- 核心文件:frontend/src/pages/DataAnnotation/OperatorCreate/components/

#### 3. 任务列表管理
- 在数据标注首页同一 Tab 中添加任务列表
- 支持状态筛选(pending/running/completed/failed/stopped)
- 支持关键词搜索
- 支持轮询刷新
- 支持停止任务
- 支持下载结果
- 核心文件:frontend/src/pages/DataAnnotation/Home/components/AutoAnnotationTaskList.tsx

#### 4. 任务详情抽屉
- 点击任务名打开详情抽屉
- 显示任务基本信息(名称、状态、进度、时间等)
- 显示 pipeline 配置(算子链和参数)
- 显示错误信息(如果失败)
- 显示产物路径和下载按钮
- 核心文件:frontend/src/pages/DataAnnotation/Home/components/AutoAnnotationTaskDetailDrawer.tsx

#### 5. API 集成
- 封装自动标注任务相关接口:
  - list:获取任务列表
  - create:创建任务
  - detail:获取任务详情
  - delete:删除任务
  - stop:停止任务
  - download:下载结果
- 核心文件:frontend/src/pages/DataAnnotation/annotation.api.ts

#### 6. 路由配置
- 新增路由:/data/annotation/create-auto-task
- 集成到数据标注首页
- 核心文件:
  - frontend/src/routes/routes.ts
  - frontend/src/pages/DataAnnotation/Home/DataAnnotation.tsx

#### 7. 算子模型增强
- 新增 runtime 字段用于标注算子筛选
- 核心文件:frontend/src/pages/OperatorMarket/operator.model.ts

### 后端功能

#### 1. 测试算子(test_annotation_marker)
- 功能:在图片上绘制测试标记并输出 JSON 标注
- 用途:测试标注功能是否正常工作
- 实现文件:
  - runtime/ops/annotation/test_annotation_marker/process.py
  - runtime/ops/annotation/test_annotation_marker/metadata.yml
  - runtime/ops/annotation/test_annotation_marker/__init__.py

#### 2. 算子注册
- 将测试算子注册到 annotation ops 包
- 添加到运行时白名单
- 核心文件:
  - runtime/ops/annotation/__init__.py
  - runtime/python-executor/datamate/auto_annotation_worker.py

#### 3. 数据库初始化
- 添加测试算子到数据库
- 添加算子分类关联
- 核心文件:scripts/db/data-operator-init.sql

### 问题修复

#### 1. outputDir 默认值覆盖问题
- 问题:前端设置空字符串默认值导致 worker 无法注入真实输出目录
- 解决:过滤掉空/null 的 outputDir,确保 worker 能注入真实输出目录
- 修改位置:frontend/src/pages/DataAnnotation/OperatorCreate/hooks/useOperatorOperations.ts

#### 2. targetClasses 默认值类型问题
- 问题:YOLO 算子 metadata 中 targetClasses 默认值是字符串 '[]' 而不是列表
- 解决:改为列表 []
- 修改位置:runtime/ops/annotation/image_object_detection_bounding_box/metadata.yml

## 关键特性

### 用户体验
- 统一的算子编排界面(与数据清洗保持一致)
- 直观的拖拽操作
- 实时的 pipeline 预览
- 完整的任务管理功能

### 功能完整性
- 任务创建:两步流程,清晰明了
- 任务管理:列表展示、状态筛选、搜索
- 任务操作:停止、下载
- 任务详情:完整的信息展示

### 可测试性
- 提供测试算子用于功能验证
- 支持快速测试标注流程

## 验证结果

- ESLint 检查: 通过
- 前端构建: 通过(10.91s)
- 功能测试: 所有功能正常

## 部署说明

1. 执行数据库初始化脚本(如果是新环境)
2. 重启前端服务
3. 重启后端服务(如果修改了 worker 白名单)

## 使用说明

1. 进入数据标注页面
2. 点击创建自动标注任务
3. 选择数据集和文件
4. 从算子库拖拽算子到编排区
5. 配置算子参数
6. 预览 pipeline
7. 提交任务
8. 在任务列表中查看进度
9. 点击任务名查看详情
10. 下载标注结果

## 相关文件

- 前端页面:frontend/src/pages/DataAnnotation/OperatorCreate/
- 任务管理:frontend/src/pages/DataAnnotation/Home/components/
- API 集成:frontend/src/pages/DataAnnotation/annotation.api.ts
- 测试算子:runtime/ops/annotation/test_annotation_marker/
- 数据库脚本:scripts/db/data-operator-init.sql
This commit is contained in:
2026-02-08 08:17:35 +08:00
parent 2f49fc4199
commit 78624915b7
22 changed files with 2847 additions and 16 deletions

View File

@@ -0,0 +1,281 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Form, Input, message, Select, Steps } from "antd";
import TextArea from "antd/es/input/TextArea";
import { SaveOutlined, DatabaseOutlined } from "@ant-design/icons";
import { ArrowLeft } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { Dataset, DatasetType } from "@/pages/DataManagement/dataset.model";
import { createAnnotationOperatorTaskUsingPost } from "../annotation.api";
import { useCreateStepTwo } from "./hooks/useCreateStepTwo";
import PipelinePreview from "./components/PipelinePreview";
interface TaskConfigValues {
name: string;
description?: string;
datasetId: string;
outputDatasetName: string;
}
const buildDefaultOutputDatasetName = (dataset?: Dataset) => {
if (!dataset?.name) {
return "自动标注结果集";
}
return `${dataset.name}_auto_annotation`;
};
export default function AnnotationOperatorTaskCreate() {
const navigate = useNavigate();
const [form] = Form.useForm<TaskConfigValues>();
const [currentStep, setCurrentStep] = useState(1);
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [submitting, setSubmitting] = useState(false);
const [outputNameTouched, setOutputNameTouched] = useState(false);
const { loading: operatorLoading, selectedOperators, renderStepTwo } = useCreateStepTwo();
const selectedDatasetId = Form.useWatch("datasetId", form);
const selectedDataset = useMemo(
() => datasets.find((dataset) => dataset.id === selectedDatasetId),
[datasets, selectedDatasetId]
);
const fetchDatasets = async () => {
try {
const { data } = await queryDatasetsUsingGet({ page: 0, pageSize: 1000 });
const content = data?.content || [];
const mappedDatasets = content.map((item) => mapDataset(item));
setDatasets(mappedDatasets);
} catch (error) {
console.error("加载数据集失败", error);
message.error("加载数据集失败");
}
};
useEffect(() => {
fetchDatasets();
}, []);
useEffect(() => {
if (!selectedDataset || outputNameTouched) {
return;
}
form.setFieldValue(
"outputDatasetName",
buildDefaultOutputDatasetName(selectedDataset)
);
}, [form, outputNameTouched, selectedDataset]);
const canProceed = () => {
if (currentStep === 1) {
const values = form.getFieldsValue();
return !!values.name && !!values.datasetId && !!values.outputDatasetName;
}
if (currentStep === 2) {
return selectedOperators.length > 0;
}
return false;
};
const handleNext = async () => {
try {
if (currentStep === 1) {
await form.validateFields();
if (selectedDataset?.datasetType !== DatasetType.IMAGE) {
message.error("自动标注算子编排当前仅支持图片数据集");
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 2));
} catch {
message.error("请完善基本信息");
}
};
const handlePrev = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
const handleSave = async () => {
try {
const values = await form.validateFields();
if (selectedOperators.length === 0) {
message.error("请至少选择一个标注算子");
return;
}
if (selectedDataset?.datasetType !== DatasetType.IMAGE) {
message.error("自动标注算子编排当前仅支持图片数据集");
return;
}
const outputDatasetName = values.outputDatasetName?.trim();
const pipeline = selectedOperators.map((operator, index) => {
const overrides = {
...(operator.defaultParams || {}),
...(operator.overrides || {}),
} as Record<string, unknown>;
if (index === 0 && outputDatasetName) {
overrides.outputDatasetName = outputDatasetName;
}
return {
operatorId: operator.id,
overrides,
};
});
const payload = {
name: values.name,
datasetId: values.datasetId,
taskMode: "pipeline",
executorType: "annotation_local",
pipeline,
} as Record<string, unknown>;
if (values.description) {
payload.description = values.description;
}
setSubmitting(true);
await createAnnotationOperatorTaskUsingPost(payload);
message.success("自动标注任务创建成功");
navigate("/data/annotation");
} catch (error: unknown) {
const err = error as { message?: string; data?: { message?: string } };
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
message.error(msg);
console.error(error);
} finally {
setSubmitting(false);
}
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<Link to="/data/annotation">
<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>
<div className="flex-overflow-auto bg-white border-card">
<div className="flex-1 overflow-auto m-6">
{currentStep === 1 ? (
<Form form={form} layout="vertical">
<Form.Item
label="任务名称"
name="name"
rules={[{ required: true, message: "请输入任务名称" }]}
>
<Input placeholder="请输入自动标注任务名称" />
</Form.Item>
<Form.Item label="任务描述" name="description">
<TextArea
rows={3}
placeholder="可选:描述任务目标、执行策略或注意事项"
/>
</Form.Item>
<Form.Item
label="选择数据集"
name="datasetId"
rules={[{ required: true, message: "请选择数据集" }]}
extra="自动标注算子编排当前仅支持图片数据集"
>
<Select
placeholder="请选择图片数据集"
optionFilterProp="label"
options={datasets.map((dataset) => ({
label: (
<div className="flex items-center justify-between gap-3 py-2">
<div className="font-medium text-gray-900">
{(dataset as Dataset & { icon?: React.ReactNode }).icon || (
<DatabaseOutlined className="mr-2" />
)}
{dataset.name}
</div>
<div className="text-xs text-gray-500">
{dataset?.fileCount} {dataset.size}
</div>
</div>
),
value: dataset.id,
disabled: dataset.datasetType !== DatasetType.IMAGE,
}))}
/>
</Form.Item>
<Form.Item
label="输出数据集名称"
name="outputDatasetName"
rules={[{ required: true, message: "请输入输出数据集名称" }]}
>
<Input
placeholder="自动标注结果集名称"
onChange={() => setOutputNameTouched(true)}
/>
</Form.Item>
</Form>
) : (
<div className="flex flex-col gap-4 h-full">
<div className="flex-1 min-h-0">{renderStepTwo}</div>
<PipelinePreview operators={selectedOperators} />
</div>
)}
</div>
<div className="flex justify-end p-6 gap-3 border-top">
<Button onClick={() => navigate("/data/annotation")} disabled={submitting}>
</Button>
{currentStep > 1 && (
<Button onClick={handlePrev} disabled={submitting || operatorLoading}>
</Button>
)}
{currentStep === 2 ? (
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
disabled={!canProceed()}
loading={submitting}
>
</Button>
) : (
<Button
type="primary"
onClick={handleNext}
disabled={!canProceed()}
>
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { Divider, Form, Tag } from "antd";
import { Settings } from "lucide-react";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
import ParamConfig from "./ParamConfig";
interface OperatorConfigProps {
selectedOperator: OperatorI | null;
handleConfigChange: (
operatorId: string,
paramKey: string,
value: unknown
) => void;
}
const OperatorConfig: React.FC<OperatorConfigProps> = ({
selectedOperator,
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 className="w-5 h-5" />
</span>
</div>
<div className="flex-1 overflow-auto p-4">
{selectedOperator ? (
<div>
<div className="mb-4">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{selectedOperator.name}</span>
</div>
<div className="text-sm text-gray-500">
{selectedOperator.description}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{selectedOperator.tags?.map((tag: string) => (
<Tag key={tag} color="default">
{tag}
</Tag>
))}
</div>
</div>
<Divider />
<Form layout="vertical">
{Object.entries(selectedOperator.configs || {}).map(([key, param]) => (
<ParamConfig
key={key}
operator={selectedOperator}
paramKey={key}
param={param}
onParamChange={handleConfigChange}
/>
))}
</Form>
</div>
) : (
<div className="text-center py-12 text-gray-400">
<Settings className="w-full h-10 mb-4 opacity-50" />
<div></div>
</div>
)}
</div>
</div>
);
};
export default OperatorConfig;

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Button,
Card,
Checkbox,
Input,
Select,
Tag,
Tooltip,
} from "antd";
import { SearchOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
import { Layers } from "lucide-react";
import { CategoryI, OperatorI } from "@/pages/OperatorMarket/operator.model";
import { updateOperatorByIdUsingPut } from "@/pages/OperatorMarket/operator.api";
type GroupedCategoryOption = {
label: string;
title: string;
options: Array<Omit<CategoryI, "type">>;
};
interface OperatorListProps {
operators: OperatorI[];
favorites: Set<string>;
toggleFavorite: (id: string) => void;
toggleOperator: (operator: OperatorI) => void;
selectedOperators: OperatorI[];
onDragOperator: (
e: React.DragEvent,
item: OperatorI,
source: "library"
) => void;
}
const handleStar = async (
operator: OperatorI,
toggleFavorite: (id: string) => void
) => {
const data = {
id: operator.id,
isStar: !operator.isStar,
};
await updateOperatorByIdUsingPut(operator.id, data);
toggleFavorite(operator.id);
};
const OperatorList: React.FC<OperatorListProps> = ({
operators,
favorites,
toggleFavorite,
toggleOperator,
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={(event) => onDragOperator(event, 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>
<span
className="cursor-pointer"
onClick={(event) => {
event.stopPropagation();
handleStar(operator, toggleFavorite);
}}
>
{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: (operator: 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[]>([]);
const [operatorListFiltered, setOperatorListFiltered] = useState<OperatorI[]>(
[]
);
useMemo(() => {
const groups: Record<string, { type: string; operators: OperatorI[] }> = {};
let operatorFilteredList: OperatorI[];
categoryOptions.forEach((cat) => {
groups[cat.id] = {
type: cat.type,
operators: operatorList.filter((op) => op.categories?.includes(cat.id)),
};
});
if (selectedCategory.length) {
const groupedFiltered: Record<string, OperatorI[]> = {};
selectedCategory.forEach((cat: string) => {
const parent = groups[cat]?.type;
if (!parent) {
return;
}
if (!groupedFiltered[parent]) {
groupedFiltered[parent] = groups[cat].operators;
} else {
groupedFiltered[parent] = Array.from(
new Map(
[...groupedFiltered[parent], ...groups[cat].operators].map((item) => [
item.id,
item,
])
).values()
);
}
});
operatorFilteredList = Object.values(groupedFiltered).reduce<OperatorI[]>(
(acc, currentList) => {
if (acc.length === 0) {
return currentList;
}
const currentIds = new Set(currentList.map((item) => item.id));
return acc.filter((item) => currentIds.has(item.id));
},
[]
);
} else {
operatorFilteredList = [...operatorList];
}
if (searchTerm) {
operatorFilteredList = operatorFilteredList.filter((operator) =>
operator.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (showFavorites) {
operatorFilteredList = operatorFilteredList.filter((operator) =>
favorites.has(operator.id)
);
}
setOperatorListFiltered([...operatorFilteredList]);
return groups;
}, [
categoryOptions,
selectedCategory,
operatorList,
searchTerm,
showFavorites,
favorites,
]);
const toggleFavorite = (operatorId: string) => {
const newFavorites = new Set(favorites);
if (newFavorites.has(operatorId)) {
newFavorites.delete(operatorId);
} else {
newFavorites.add(operatorId);
}
setFavorites(newFavorites);
};
useEffect(() => {
const newFavorites = new Set<string>();
operatorList.forEach((item) => {
if (item.isStar) {
newFavorites.add(item.id);
}
});
setFavorites(newFavorites);
}, [operatorList]);
const handleSelectAll = (operators: OperatorI[]) => {
const newSelected = [...selectedOperators];
operators.forEach((operator) => {
if (!newSelected.some((op) => op.id === operator.id)) {
newSelected.push(operator);
}
});
setSelectedOperators(newSelected);
};
const handleSelectCategory = (source: CategoryI[]): GroupedCategoryOption[] => {
const groups: Record<string, GroupedCategoryOption> = {};
const tree: GroupedCategoryOption[] = [];
source.forEach((item) => {
const groupName = item.type || "未分组";
if (!groups[groupName]) {
const newGroup = {
label: groupName,
title: groupName,
options: [],
};
groups[groupName] = newGroup;
tree.push(newGroup);
}
const childItem: Omit<CategoryI, "type"> = {
id: item.id,
name: item.name,
count: item.count,
parentId: item.parentId,
value: item.value,
createdAt: item.createdAt,
};
groups[groupName].options.push(childItem);
});
return tree;
};
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" />
({operatorList.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-2">
<Input
prefix={<SearchOutlined />}
placeholder="搜索算子名称..."
value={searchTerm}
allowClear
onChange={(event) => setSearchTerm(event.target.value)}
/>
<Select
value={selectedCategory}
options={handleSelectCategory(categoryOptions)}
onChange={setSelectedCategory}
mode="multiple"
allowClear
className="flex-1"
placeholder="选择分类"
/>
<Tooltip title="只看收藏">
<span
className="cursor-pointer"
onClick={() => setShowFavorites(!showFavorites)}
>
{showFavorites ? (
<StarFilled style={{ color: "#FFD700" }} />
) : (
<StarOutlined />
)}
</span>
</Tooltip>
<div className="flex items-center justify-right w-full">
<Button
type="link"
size="small"
onClick={(event) => {
event.stopPropagation();
handleSelectAll(operatorListFiltered);
}}
>
<Tag>{operatorListFiltered.length}</Tag>
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<OperatorList
selectedOperators={selectedOperators}
operators={operatorListFiltered}
favorites={favorites}
toggleOperator={toggleOperator}
onDragOperator={handleDragStart}
toggleFavorite={toggleFavorite}
/>
{operatorListFiltered.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,202 @@
import React, { useMemo, useState } from "react";
import { Card, Input, Tag, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { Workflow } from "lucide-react";
import { CategoryI, OperatorI } from "@/pages/OperatorMarket/operator.model";
interface OperatorOrchestrationProps {
selectedOperators: OperatorI[];
configOperator: OperatorI | null;
categoryOptions: CategoryI[];
setSelectedOperators: (operators: OperatorI[]) => void;
setConfigOperator: (operator: OperatorI | null) => void;
removeOperator: (id: string) => 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 OperatorOrchestration: React.FC<OperatorOrchestrationProps> = ({
selectedOperators,
configOperator,
categoryOptions,
setSelectedOperators,
setConfigOperator,
removeOperator,
handleDragStart,
handleItemDragOver,
handleItemDragLeave,
handleItemDrop,
handleContainerDragOver,
handleContainerDragLeave,
handleDragEnd,
handleDropToContainer,
}) => {
const [editingIndex, setEditingIndex] = useState<string | null>(null);
const categoryMap = useMemo(() => {
const map: Record<string, CategoryI> = {};
categoryOptions.forEach((category) => {
map[category.id] = category;
});
return map;
}, [categoryOptions]);
const handleIndexChange = (operatorId: string, newIndex: string) => {
const index = Number.parseInt(newIndex, 10);
if (Number.isNaN(index) || index < 1 || index > selectedOperators.length) {
return;
}
const currentIndex = selectedOperators.findIndex((op) => op.id === operatorId);
if (currentIndex === -1) return;
const targetIndex = index - 1;
if (currentIndex === targetIndex) {
setEditingIndex(null);
return;
}
const newOperators = [...selectedOperators];
const [movedOperator] = newOperators.splice(currentIndex, 1);
newOperators.splice(targetIndex, 0, movedOperator);
setSelectedOperators(newOperators);
setEditingIndex(null);
};
const renderCategoryTag = (categoryId: string) => {
const category = categoryMap[categoryId];
if (!category) return null;
return (
<Tag key={`${categoryId}-tag`} color="default">
{category.name}
</Tag>
);
};
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>
</div>
</div>
<div
className="flex-overflow-auto p-4 gap-2"
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
onDrop={handleDropToContainer}
>
{selectedOperators.map((operator, index) => (
<Card
size="small"
key={operator.id}
style={
configOperator?.id === operator.id
? { borderColor: "#1677ff" }
: {}
}
hoverable
draggable
onDragStart={(event) => handleDragStart(event, operator, "sort")}
onDragEnd={handleDragEnd}
onDragOver={(event) => handleItemDragOver(event, operator.id)}
onDragLeave={handleItemDragLeave}
onDrop={(event) => handleItemDrop(event, 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"
style={{ width: 60 }}
autoFocus
onBlur={(event) =>
handleIndexChange(operator.id, event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleIndexChange(
operator.id,
(event.target as HTMLInputElement).value
);
} else if (event.key === "Escape") {
setEditingIndex(null);
}
}}
onClick={(event) => event.stopPropagation()}
/>
) : (
<Tag
color="default"
onClick={(event) => {
event.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>
{operator.categories?.map(renderCategoryTag)}
<span
className="cursor-pointer text-red-500"
onClick={(event) => {
event.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 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 OperatorOrchestration;

View File

@@ -0,0 +1,224 @@
import React from "react";
import {
Checkbox,
Form,
Input,
InputNumber,
Radio,
Select,
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: unknown) => void;
}
const ParamConfig: React.FC<ParamConfigProps> = ({
operator,
paramKey,
param,
onParamChange,
}) => {
let defaultVal: unknown = param.defaultVal;
if (param.type === "range") {
defaultVal = Array.isArray(param.defaultVal)
? param.defaultVal
: [
param?.properties?.[0]?.defaultVal,
param?.properties?.[1]?.defaultVal,
];
}
const [value, setValue] = React.useState(param.value ?? defaultVal);
const updateValue = (newValue: unknown) => {
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 as string}
onChange={(event) => updateValue(event.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) =>
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={(event) => updateValue(event.target.value)}
>
{(param.options || []).map((option) => (
<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 as string[]}
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 as number}
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 as number}
onChange={updateValue}
style={{ width: 80 }}
/>
</div>
</Form.Item>
);
case "range": {
const min = param.min || param?.properties?.[0]?.min || 0;
const max = param.max || param?.properties?.[0]?.max || 1;
const step = param.step || param?.properties?.[0]?.step || 0.1;
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<Slider
value={Array.isArray(value) ? value : [value, value]}
onChange={(val: number | [number, number]) =>
updateValue(Array.isArray(val) ? val : [val, val])
}
range
min={min}
max={max}
step={step}
className="w-full"
/>
<Space>
<InputNumber
min={min}
max={max}
value={(value as number[])?.[0]}
onChange={(val1) =>
updateValue([val1 ?? (value as number[])?.[0], (value as number[])?.[1]])
}
changeOnWheel
/>
~
<InputNumber
min={min}
max={max}
value={(value as number[])?.[1]}
onChange={(val2) =>
updateValue([(value as number[])?.[0], val2 ?? (value as number[])?.[1]])
}
changeOnWheel
/>
</Space>
</Form.Item>
);
}
case "inputNumber":
return (
<Form.Item label={param.name} tooltip={param.description} key={paramKey}>
<InputNumber
value={value as number}
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={Boolean(value)}
onChange={(event) => updateValue(event.target.checked)}
>
{param.name}
</Checkbox>
</Form.Item>
);
case "multiple":
return (
<div className="pl-4 border-l border-gray-300">
{param.properties?.map((subParam) => (
<ParamConfig
key={subParam.key}
operator={operator}
paramKey={subParam.key}
param={subParam}
onParamChange={onParamChange}
/>
))}
</div>
);
default:
return null;
}
};
export default ParamConfig;

View File

@@ -0,0 +1,45 @@
import React from "react";
import { Card, Empty, Typography } from "antd";
import { GitBranch } from "lucide-react";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
interface PipelinePreviewProps {
operators: OperatorI[];
}
const PipelinePreview: React.FC<PipelinePreviewProps> = ({ operators }) => {
const pipelinePreview = operators.map((operator) => ({
operatorId: operator.id,
overrides: {
...(operator.defaultParams || {}),
...(operator.overrides || {}),
},
}));
const previewText = JSON.stringify(pipelinePreview, null, 2);
return (
<Card
size="small"
title={
<span className="flex items-center gap-2">
<GitBranch className="w-4 h-4" />
Pipeline
</span>
}
>
{operators.length === 0 ? (
<Empty description="暂无算子,请先从左侧选择" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Typography.Paragraph
code
className="!mb-0 whitespace-pre-wrap break-all"
>
{previewText}
</Typography.Paragraph>
)}
</Card>
);
};
export default PipelinePreview;

View File

@@ -0,0 +1,88 @@
import OperatorConfig from "../components/OperatorConfig";
import OperatorLibrary from "../components/OperatorLibrary";
import OperatorOrchestration from "../components/OperatorOrchestration";
import { useDragOperators } from "./useDragOperators";
import { useOperatorOperations } from "./useOperatorOperations";
import { OperatorI } from "@/pages/OperatorMarket/operator.model";
type DragFromLibrary = (
e: React.DragEvent,
item: OperatorI,
source: "library"
) => void;
type DragForSort = (
e: React.DragEvent,
item: OperatorI,
source: "sort"
) => void;
export function useCreateStepTwo() {
const {
loading,
operators,
selectedOperators,
configOperator,
categoryOptions,
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 as DragFromLibrary}
/>
<OperatorOrchestration
selectedOperators={selectedOperators}
configOperator={configOperator}
categoryOptions={categoryOptions}
setSelectedOperators={setSelectedOperators}
setConfigOperator={setConfigOperator}
removeOperator={removeOperator}
handleDragStart={handleDragStart as DragForSort}
handleContainerDragLeave={handleContainerDragLeave}
handleContainerDragOver={handleContainerDragOver}
handleItemDragOver={handleItemDragOver}
handleItemDragLeave={handleItemDragLeave}
handleItemDrop={handleItemDrop}
handleDropToContainer={handleDropToContainer}
handleDragEnd={handleDragEnd}
/>
<OperatorConfig
selectedOperator={configOperator}
handleConfigChange={handleConfigChange}
/>
</div>
);
return {
loading,
operators,
selectedOperators,
renderStepTwo,
};
}

View File

@@ -0,0 +1,141 @@
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 [draggedOperator] = newItems.splice(draggedIndex, 1);
let insertIndex =
insertPosition === "above" ? targetIndex : targetIndex + 1;
if (draggedIndex < insertIndex) {
insertIndex--;
}
newItems.splice(insertIndex, 0, draggedOperator);
setOperators(newItems);
}
}
resetDragState();
};
const resetDragState = () => {
setDraggingItem(null);
setInsertPosition(null);
};
return {
handleDragStart,
handleDragEnd,
handleContainerDragOver,
handleContainerDragLeave,
handleItemDragOver,
handleItemDragLeave,
handleItemDrop,
handleDropToContainer,
};
}

View File

@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from "react";
import {
queryCategoryTreeUsingGet,
queryOperatorsUsingPost,
} from "@/pages/OperatorMarket/operator.api";
import { CategoryI, ConfigI, OperatorI } from "@/pages/OperatorMarket/operator.model";
type OperatorConfigMap = Record<string, ConfigI>;
type OperatorWithDefaults = OperatorI & {
defaultParams?: Record<string, unknown>;
};
type CategoryNode = CategoryI & {
label?: string;
value?: string;
count?: number;
};
type CategoryGroup = {
name: string;
categories: CategoryNode[];
};
const ANNOTATION_OPERATOR_ID_WHITELIST = new Set([
"ImageObjectDetectionBoundingBox",
"test_annotation_marker",
]);
const ensureArray = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.map((item) => String(item));
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
};
const parseSettings = (settings?: string): OperatorConfigMap => {
if (!settings) return {};
try {
const parsed = JSON.parse(settings);
if (!parsed || typeof parsed !== "object") {
return {};
}
return parsed as OperatorConfigMap;
} catch {
return {};
}
};
const mapOperator = (operator: OperatorI): OperatorWithDefaults => {
const configs = parseSettings(operator.settings);
const defaultParams: Record<string, unknown> = {};
Object.entries(configs).forEach(([key, config]) => {
if (!(config && typeof config === "object" && "defaultVal" in config)) {
return;
}
const defaultVal = config.defaultVal as unknown;
const normalizedKey = key.trim().toLowerCase().replace(/_/g, "");
if (normalizedKey === "outputdir" && (defaultVal === "" || defaultVal === null || defaultVal === undefined)) {
return;
}
defaultParams[key] = defaultVal;
});
return {
...operator,
categories: ensureArray(operator.categories),
configs,
defaultParams,
};
};
const isAnnotationOperator = (operator: OperatorWithDefaults) => {
if (ANNOTATION_OPERATOR_ID_WHITELIST.has(operator.id)) {
return true;
}
const name = operator.name?.toLowerCase() || "";
const desc = operator.description?.toLowerCase() || "";
const runtime = operator.runtime?.toLowerCase() || "";
if (runtime.includes("annotation") || runtime.includes("/annotation/")) {
return true;
}
return name.includes("标注") || desc.includes("标注") || name.includes("annotation");
};
export function useOperatorOperations() {
const [operators, setOperators] = useState<OperatorWithDefaults[]>([]);
const [selectedOperators, setSelectedOperators] = useState<OperatorWithDefaults[]>(
[]
);
const [configOperator, setConfigOperator] = useState<OperatorWithDefaults | null>(
null
);
const [categoryOptions, setCategoryOptions] = useState<CategoryI[]>([]);
const [loading, setLoading] = useState(false);
const selectedOperatorIds = useMemo(
() => new Set(selectedOperators.map((operator) => operator.id)),
[selectedOperators]
);
const selectedCategoryOptions = useMemo(
() =>
categoryOptions.filter((category) =>
operators.some((operator) => operator.categories?.includes(category.id))
),
[categoryOptions, operators]
);
const initOperators = async () => {
setLoading(true);
try {
const [categoryRes, operatorRes] = await Promise.all([
queryCategoryTreeUsingGet(),
queryOperatorsUsingPost({ page: 0, size: 1000 }),
]);
const allOperators = (operatorRes?.data?.content || []).map(mapOperator);
const annotationOperators = allOperators.filter(isAnnotationOperator);
setOperators(annotationOperators);
const options = ((categoryRes?.data?.content || []) as CategoryGroup[]).reduce(
(acc: CategoryNode[], item) => {
const children = (item.categories || []).map((category) => {
const matchedCount = annotationOperators.filter((operator) =>
operator.categories?.includes(category.id)
).length;
return {
...category,
type: item.name,
label: category.name,
value: category.id,
count: matchedCount,
};
});
acc.push(...children);
return acc;
},
[]
);
setCategoryOptions(
options.filter((item) => (item.count || 0) > 0) as CategoryI[]
);
} finally {
setLoading(false);
}
};
useEffect(() => {
initOperators();
}, []);
const toggleOperator = (operator: OperatorWithDefaults) => {
if (selectedOperatorIds.has(operator.id)) {
setSelectedOperators((prev) => prev.filter((item) => item.id !== operator.id));
if (configOperator?.id === operator.id) {
setConfigOperator(null);
}
return;
}
setSelectedOperators((prev) => [...prev, { ...operator }]);
};
const removeOperator = (id: string) => {
setSelectedOperators((prev) => prev.filter((operator) => operator.id !== id));
if (configOperator?.id === id) {
setConfigOperator(null);
}
};
const handleConfigChange = (
operatorId: string,
paramKey: string,
value: unknown
) => {
setSelectedOperators((prev) =>
prev.map((operator) =>
operator.id === operatorId
? {
...operator,
overrides: {
...(operator.overrides || operator.defaultParams || {}),
[paramKey]: value,
},
}
: operator
)
);
setConfigOperator((prev) => {
if (!prev || prev.id !== operatorId) return prev;
return {
...prev,
overrides: {
...(prev.overrides || prev.defaultParams || {}),
[paramKey]: value,
},
};
});
};
return {
loading,
operators,
selectedOperators,
configOperator,
categoryOptions: selectedCategoryOptions,
setConfigOperator,
setSelectedOperators,
handleConfigChange,
toggleOperator,
removeOperator,
};
}