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,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,
};
}