Files
DataMate/frontend/src/pages/DataAnnotation/OperatorCreate/components/OperatorLibrary.tsx
Jerry Yan 78624915b7 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
2026-02-08 08:17:35 +08:00

331 lines
9.2 KiB
TypeScript

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;