You've already forked DataMate
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:
@@ -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;
|
||||
Reference in New Issue
Block a user