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:
@@ -24,6 +24,7 @@ import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialo
|
|||||||
import ExportAnnotationDialog from "./ExportAnnotationDialog";
|
import ExportAnnotationDialog from "./ExportAnnotationDialog";
|
||||||
import { ColumnType } from "antd/es/table";
|
import { ColumnType } from "antd/es/table";
|
||||||
import { TemplateList } from "../Template";
|
import { TemplateList } from "../Template";
|
||||||
|
import AutoAnnotationTaskList from "./components/AutoAnnotationTaskList";
|
||||||
// Note: DevelopmentInProgress intentionally not used here
|
// Note: DevelopmentInProgress intentionally not used here
|
||||||
|
|
||||||
type AnnotationTaskRowKey = string | number;
|
type AnnotationTaskRowKey = string | number;
|
||||||
@@ -325,6 +326,11 @@ export default function DataAnnotation() {
|
|||||||
>
|
>
|
||||||
批量删除
|
批量删除
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/data/annotation/create-auto-task")}
|
||||||
|
>
|
||||||
|
创建自动标注任务
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
@@ -364,6 +370,8 @@ export default function DataAnnotation() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AutoAnnotationTaskList />
|
||||||
|
|
||||||
<CreateAnnotationTask
|
<CreateAnnotationTask
|
||||||
open={showCreateDialog || !!editTask}
|
open={showCreateDialog || !!editTask}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
App,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Empty,
|
||||||
|
Progress,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
DownloadOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { formatDateTime } from "@/utils/unit";
|
||||||
|
import {
|
||||||
|
downloadAnnotationOperatorTaskResultUsingGet,
|
||||||
|
queryAnnotationOperatorTaskByIdUsingGet,
|
||||||
|
stopAnnotationOperatorTaskByIdUsingPost,
|
||||||
|
} from "../../annotation.api";
|
||||||
|
|
||||||
|
type AutoAnnotationTaskStatus =
|
||||||
|
| "pending"
|
||||||
|
| "running"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "stopped";
|
||||||
|
|
||||||
|
type StatusMeta = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoAnnotationPipelineStepRaw = {
|
||||||
|
operatorId?: string;
|
||||||
|
operator_id?: string;
|
||||||
|
id?: string;
|
||||||
|
overrides?: Record<string, unknown>;
|
||||||
|
settingsOverride?: Record<string, unknown>;
|
||||||
|
settings_override?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoAnnotationTaskDetailRaw = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
datasetId?: string;
|
||||||
|
dataset_id?: string;
|
||||||
|
datasetName?: string;
|
||||||
|
dataset_name?: string;
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
|
totalImages?: number;
|
||||||
|
total_images?: number;
|
||||||
|
processedImages?: number;
|
||||||
|
processed_images?: number;
|
||||||
|
detectedObjects?: number;
|
||||||
|
detected_objects?: number;
|
||||||
|
outputPath?: string;
|
||||||
|
output_path?: string;
|
||||||
|
outputDatasetId?: string;
|
||||||
|
output_dataset_id?: string;
|
||||||
|
stopRequested?: boolean;
|
||||||
|
stop_requested?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
error_message?: string;
|
||||||
|
taskMode?: string;
|
||||||
|
task_mode?: string;
|
||||||
|
executorType?: string;
|
||||||
|
executor_type?: string;
|
||||||
|
sourceDatasets?: string[];
|
||||||
|
source_datasets?: string[];
|
||||||
|
createdBy?: string;
|
||||||
|
created_by?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
started_at?: string;
|
||||||
|
heartbeatAt?: string;
|
||||||
|
heartbeat_at?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
completed_at?: string;
|
||||||
|
pipeline?: unknown;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoAnnotationPipelineStep = {
|
||||||
|
operatorId: string;
|
||||||
|
overrides: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoAnnotationTaskDetail = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
datasetId: string;
|
||||||
|
datasetName: string;
|
||||||
|
status: StatusMeta;
|
||||||
|
progress: number;
|
||||||
|
totalImages: number;
|
||||||
|
processedImages: number;
|
||||||
|
detectedObjects: number;
|
||||||
|
outputPath?: string;
|
||||||
|
outputDatasetId?: string;
|
||||||
|
stopRequested: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
taskMode: string;
|
||||||
|
executorType: string;
|
||||||
|
sourceDatasets: string[];
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
startedAt: string;
|
||||||
|
heartbeatAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
pipeline: AutoAnnotationPipelineStep[];
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AutoAnnotationTaskDetailDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
taskId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefreshList?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_ANNOTATION_STATUS_MAP: Record<AutoAnnotationTaskStatus, StatusMeta> = {
|
||||||
|
pending: {
|
||||||
|
label: "待处理",
|
||||||
|
value: "pending",
|
||||||
|
color: "gray",
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
label: "进行中",
|
||||||
|
value: "running",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "已完成",
|
||||||
|
value: "completed",
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: "失败",
|
||||||
|
value: "failed",
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
stopped: {
|
||||||
|
label: "已停止",
|
||||||
|
value: "stopped",
|
||||||
|
color: "orange",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSafeNumber = (value: unknown): number =>
|
||||||
|
typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
|
||||||
|
const resolveStatus = (status?: string): StatusMeta => {
|
||||||
|
const normalizedStatus = (status || "").toLowerCase();
|
||||||
|
if (normalizedStatus in AUTO_ANNOTATION_STATUS_MAP) {
|
||||||
|
return AUTO_ANNOTATION_STATUS_MAP[normalizedStatus as AutoAnnotationTaskStatus];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: normalizedStatus || "未知",
|
||||||
|
value: normalizedStatus || "unknown",
|
||||||
|
color: "default",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTaskTime = (value?: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return formatDateTime(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeStringArray = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePipeline = (value: unknown): AutoAnnotationPipelineStep[] => {
|
||||||
|
let rawPipeline = value;
|
||||||
|
|
||||||
|
if (typeof rawPipeline === "string") {
|
||||||
|
try {
|
||||||
|
rawPipeline = JSON.parse(rawPipeline);
|
||||||
|
} catch {
|
||||||
|
rawPipeline = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(rawPipeline)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawPipeline.map((step, index) => {
|
||||||
|
const stepObject: AutoAnnotationPipelineStepRaw =
|
||||||
|
step && typeof step === "object"
|
||||||
|
? (step as AutoAnnotationPipelineStepRaw)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const operatorIdRaw =
|
||||||
|
stepObject.operatorId || stepObject.operator_id || stepObject.id;
|
||||||
|
const operatorId =
|
||||||
|
typeof operatorIdRaw === "string" && operatorIdRaw.trim().length > 0
|
||||||
|
? operatorIdRaw
|
||||||
|
: `step_${index + 1}`;
|
||||||
|
|
||||||
|
const overridesRaw =
|
||||||
|
stepObject.overrides ||
|
||||||
|
stepObject.settingsOverride ||
|
||||||
|
stepObject.settings_override;
|
||||||
|
const overrides =
|
||||||
|
overridesRaw && typeof overridesRaw === "object" && !Array.isArray(overridesRaw)
|
||||||
|
? (overridesRaw as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
operatorId,
|
||||||
|
overrides,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapAutoAnnotationTaskDetail = (
|
||||||
|
task: Partial<AutoAnnotationTaskDetailRaw>
|
||||||
|
): AutoAnnotationTaskDetail => {
|
||||||
|
const status = resolveStatus(task.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id || "",
|
||||||
|
name: task.name || "-",
|
||||||
|
datasetId: task.datasetId || task.dataset_id || "",
|
||||||
|
datasetName: task.datasetName || task.dataset_name || "-",
|
||||||
|
status,
|
||||||
|
progress: Math.max(0, Math.min(100, toSafeNumber(task.progress))),
|
||||||
|
totalImages: toSafeNumber(task.totalImages ?? task.total_images),
|
||||||
|
processedImages: toSafeNumber(task.processedImages ?? task.processed_images),
|
||||||
|
detectedObjects: toSafeNumber(task.detectedObjects ?? task.detected_objects),
|
||||||
|
outputPath: task.outputPath || task.output_path,
|
||||||
|
outputDatasetId: task.outputDatasetId || task.output_dataset_id,
|
||||||
|
stopRequested: task.stopRequested ?? task.stop_requested ?? false,
|
||||||
|
errorMessage: task.errorMessage || task.error_message,
|
||||||
|
taskMode: task.taskMode || task.task_mode || "-",
|
||||||
|
executorType: task.executorType || task.executor_type || "-",
|
||||||
|
sourceDatasets: normalizeStringArray(task.sourceDatasets || task.source_datasets),
|
||||||
|
createdBy: task.createdBy || task.created_by || "-",
|
||||||
|
createdAt: formatTaskTime(task.createdAt || task.created_at),
|
||||||
|
updatedAt: formatTaskTime(task.updatedAt || task.updated_at),
|
||||||
|
startedAt: formatTaskTime(task.startedAt || task.started_at),
|
||||||
|
heartbeatAt: formatTaskTime(task.heartbeatAt || task.heartbeat_at),
|
||||||
|
completedAt: formatTaskTime(task.completedAt || task.completed_at),
|
||||||
|
pipeline: parsePipeline(task.pipeline),
|
||||||
|
config:
|
||||||
|
task.config && typeof task.config === "object" && !Array.isArray(task.config)
|
||||||
|
? task.config
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveProgressStatus = (statusValue: string) => {
|
||||||
|
if (statusValue === "failed") {
|
||||||
|
return "exception" as const;
|
||||||
|
}
|
||||||
|
if (statusValue === "completed") {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (statusValue === "running") {
|
||||||
|
return "active" as const;
|
||||||
|
}
|
||||||
|
return "normal" as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AutoAnnotationTaskDetailDrawer({
|
||||||
|
open,
|
||||||
|
taskId,
|
||||||
|
onClose,
|
||||||
|
onRefreshList,
|
||||||
|
}: AutoAnnotationTaskDetailDrawerProps) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [taskDetail, setTaskDetail] = useState<AutoAnnotationTaskDetail | null>(null);
|
||||||
|
|
||||||
|
const fetchTaskDetail = useCallback(async () => {
|
||||||
|
if (!taskId) {
|
||||||
|
setTaskDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await queryAnnotationOperatorTaskByIdUsingGet(taskId);
|
||||||
|
const payload = response?.data;
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
setTaskDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTaskDetail(mapAutoAnnotationTaskDetail(payload));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error("获取自动标注任务详情失败");
|
||||||
|
setTaskDetail(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [taskId, message]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchTaskDetail();
|
||||||
|
}, [open, fetchTaskDetail]);
|
||||||
|
|
||||||
|
const canStop = useMemo(
|
||||||
|
() =>
|
||||||
|
!!taskDetail &&
|
||||||
|
["pending", "running"].includes(taskDetail.status.value) &&
|
||||||
|
!taskDetail.stopRequested,
|
||||||
|
[taskDetail]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canDownload = useMemo(
|
||||||
|
() =>
|
||||||
|
!!taskDetail &&
|
||||||
|
["completed", "stopped", "failed"].includes(taskDetail.status.value) &&
|
||||||
|
!!taskDetail.outputPath,
|
||||||
|
[taskDetail]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStopTask = async () => {
|
||||||
|
if (!taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stopAnnotationOperatorTaskByIdUsingPost(taskId);
|
||||||
|
message.success("已发送停止请求");
|
||||||
|
onRefreshList?.();
|
||||||
|
await fetchTaskDetail();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error("停止任务失败,请稍后重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!taskId || !taskDetail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadAnnotationOperatorTaskResultUsingGet(
|
||||||
|
taskId,
|
||||||
|
`${taskDetail.name || taskId}_annotations.zip`
|
||||||
|
);
|
||||||
|
message.success("结果下载已开始");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error("下载失败,请确认任务已生成结果");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title="自动标注任务详情"
|
||||||
|
width={840}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void fetchTaskDetail()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<PauseCircleOutlined />}
|
||||||
|
disabled={!canStop}
|
||||||
|
onClick={() => void handleStopTask()}
|
||||||
|
>
|
||||||
|
停止任务
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
disabled={!canDownload}
|
||||||
|
onClick={() => void handleDownload()}
|
||||||
|
>
|
||||||
|
下载结果
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-full min-h-60 flex items-center justify-center">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : !taskDetail ? (
|
||||||
|
<Empty description="暂无任务详情" />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={16} className="w-full">
|
||||||
|
<Card title="基本信息">
|
||||||
|
<Descriptions column={2} size="small" bordered>
|
||||||
|
<Descriptions.Item label="任务名称">{taskDetail.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="任务ID">{taskDetail.id || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Space>
|
||||||
|
<Badge color={taskDetail.status.color} text={taskDetail.status.label} />
|
||||||
|
{taskDetail.stopRequested && <Tag color="orange">停止中</Tag>}
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="进度">
|
||||||
|
<Progress
|
||||||
|
percent={taskDetail.progress}
|
||||||
|
size="small"
|
||||||
|
status={resolveProgressStatus(taskDetail.status.value)}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="数据集">{taskDetail.datasetName}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="数据集ID">{taskDetail.datasetId || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="任务模式">{taskDetail.taskMode}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="执行器">{taskDetail.executorType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="处理进度">
|
||||||
|
{taskDetail.processedImages}/{taskDetail.totalImages}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="检测对象数">
|
||||||
|
{taskDetail.detectedObjects}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建人">{taskDetail.createdBy}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源数据集">
|
||||||
|
{taskDetail.sourceDatasets.length > 0
|
||||||
|
? taskDetail.sourceDatasets.join("、")
|
||||||
|
: "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{taskDetail.createdAt}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="启动时间">{taskDetail.startedAt}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="更新时间">{taskDetail.updatedAt}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="完成时间">{taskDetail.completedAt}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="心跳时间" span={2}>
|
||||||
|
{taskDetail.heartbeatAt}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Pipeline 配置(算子链与参数)">
|
||||||
|
{taskDetail.pipeline.length > 0 ? (
|
||||||
|
<Space direction="vertical" size={12} className="w-full">
|
||||||
|
{taskDetail.pipeline.map((step, index) => (
|
||||||
|
<Card
|
||||||
|
key={`${step.operatorId}-${index}`}
|
||||||
|
size="small"
|
||||||
|
title={`步骤 ${index + 1} · ${step.operatorId}`}
|
||||||
|
>
|
||||||
|
{Object.keys(step.overrides).length > 0 ? (
|
||||||
|
<pre className="mb-0 rounded bg-gray-50 p-3 text-xs whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(step.overrides, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无参数覆盖</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="该任务未配置 pipeline"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(taskDetail.config).length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Typography.Title level={5}>任务配置</Typography.Title>
|
||||||
|
<pre className="mb-0 rounded bg-gray-50 p-3 text-xs whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(taskDetail.config, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{taskDetail.errorMessage && (
|
||||||
|
<Card title="错误信息">
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="任务执行异常"
|
||||||
|
description={
|
||||||
|
<span className="whitespace-pre-wrap break-all">
|
||||||
|
{taskDetail.errorMessage}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="产物信息">
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="输出路径">
|
||||||
|
{taskDetail.outputPath ? (
|
||||||
|
<Typography.Text code copyable>
|
||||||
|
{taskDetail.outputPath}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="输出数据集ID">
|
||||||
|
{taskDetail.outputDatasetId || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
disabled={!canDownload}
|
||||||
|
onClick={() => void handleDownload()}
|
||||||
|
>
|
||||||
|
下载任务结果
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { App, Badge, Button, Card, Progress, Table, Tooltip } from "antd";
|
||||||
|
import { DownloadOutlined, PauseCircleOutlined } from "@ant-design/icons";
|
||||||
|
import type { ColumnsType } from "antd/es/table";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { SearchControls } from "@/components/SearchControls";
|
||||||
|
import useFetchData from "@/hooks/useFetchData";
|
||||||
|
import { formatDateTime } from "@/utils/unit";
|
||||||
|
import {
|
||||||
|
downloadAnnotationOperatorTaskResultUsingGet,
|
||||||
|
queryAnnotationOperatorTasksUsingGet,
|
||||||
|
stopAnnotationOperatorTaskByIdUsingPost,
|
||||||
|
} from "../../annotation.api";
|
||||||
|
import AutoAnnotationTaskDetailDrawer from "./AutoAnnotationTaskDetailDrawer";
|
||||||
|
|
||||||
|
type AutoAnnotationTaskStatus =
|
||||||
|
| "pending"
|
||||||
|
| "running"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "stopped";
|
||||||
|
|
||||||
|
type AutoAnnotationTaskPayload = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
datasetId?: string;
|
||||||
|
dataset_id?: string;
|
||||||
|
datasetName?: string;
|
||||||
|
dataset_name?: string;
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
|
totalImages?: number;
|
||||||
|
total_images?: number;
|
||||||
|
processedImages?: number;
|
||||||
|
processed_images?: number;
|
||||||
|
detectedObjects?: number;
|
||||||
|
detected_objects?: number;
|
||||||
|
outputPath?: string;
|
||||||
|
output_path?: string;
|
||||||
|
stopRequested?: boolean;
|
||||||
|
stop_requested?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
completed_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusMeta = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoAnnotationTaskRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
datasetId: string;
|
||||||
|
datasetName: string;
|
||||||
|
status: StatusMeta;
|
||||||
|
progress: number;
|
||||||
|
totalImages: number;
|
||||||
|
processedImages: number;
|
||||||
|
detectedObjects: number;
|
||||||
|
outputPath?: string;
|
||||||
|
stopRequested: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchParams = Record<string, unknown>;
|
||||||
|
type FetchResult<T> = {
|
||||||
|
data: {
|
||||||
|
content: Partial<T>[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTO_ANNOTATION_STATUS_MAP: Record<AutoAnnotationTaskStatus, StatusMeta> = {
|
||||||
|
pending: {
|
||||||
|
label: "待处理",
|
||||||
|
value: "pending",
|
||||||
|
color: "gray",
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
label: "进行中",
|
||||||
|
value: "running",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "已完成",
|
||||||
|
value: "completed",
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: "失败",
|
||||||
|
value: "failed",
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
stopped: {
|
||||||
|
label: "已停止",
|
||||||
|
value: "stopped",
|
||||||
|
color: "orange",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSafeNumber = (value: unknown): number =>
|
||||||
|
typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
|
||||||
|
const resolveStatus = (status?: string): StatusMeta => {
|
||||||
|
const normalizedStatus = (status || "").toLowerCase();
|
||||||
|
if (normalizedStatus in AUTO_ANNOTATION_STATUS_MAP) {
|
||||||
|
return AUTO_ANNOTATION_STATUS_MAP[normalizedStatus as AutoAnnotationTaskStatus];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: normalizedStatus || "未知",
|
||||||
|
value: normalizedStatus || "unknown",
|
||||||
|
color: "default",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTaskTime = (value?: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return formatDateTime(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapAutoAnnotationTask = (
|
||||||
|
task: Partial<AutoAnnotationTaskPayload>
|
||||||
|
): AutoAnnotationTaskRow => {
|
||||||
|
const status = resolveStatus(task.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id || "",
|
||||||
|
name: task.name || "-",
|
||||||
|
datasetId: task.datasetId || task.dataset_id || "",
|
||||||
|
datasetName: task.datasetName || task.dataset_name || "-",
|
||||||
|
status,
|
||||||
|
progress: Math.max(0, Math.min(100, toSafeNumber(task.progress))),
|
||||||
|
totalImages: toSafeNumber(task.totalImages ?? task.total_images),
|
||||||
|
processedImages: toSafeNumber(task.processedImages ?? task.processed_images),
|
||||||
|
detectedObjects: toSafeNumber(task.detectedObjects ?? task.detected_objects),
|
||||||
|
outputPath: task.outputPath || task.output_path,
|
||||||
|
stopRequested: task.stopRequested ?? task.stop_requested ?? false,
|
||||||
|
createdAt: formatTaskTime(task.createdAt || task.created_at),
|
||||||
|
updatedAt: formatTaskTime(task.updatedAt || task.updated_at),
|
||||||
|
completedAt: formatTaskTime(task.completedAt || task.completed_at),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAutoAnnotationTasks = async (
|
||||||
|
params?: FetchParams
|
||||||
|
): Promise<FetchResult<AutoAnnotationTaskPayload>> => {
|
||||||
|
const response = await queryAnnotationOperatorTasksUsingGet();
|
||||||
|
const tasks = Array.isArray(response?.data)
|
||||||
|
? (response.data as AutoAnnotationTaskPayload[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const keyword =
|
||||||
|
typeof params?.keyword === "string" ? params.keyword.trim().toLowerCase() : "";
|
||||||
|
const status = typeof params?.status === "string" ? params.status.toLowerCase() : "";
|
||||||
|
const page =
|
||||||
|
typeof params?.page === "number" && params.page >= 0 ? params.page : 0;
|
||||||
|
const size =
|
||||||
|
typeof params?.size === "number" && params.size > 0 ? params.size : 12;
|
||||||
|
|
||||||
|
const filtered = tasks
|
||||||
|
.filter((task) => {
|
||||||
|
if (!keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [task.id, task.name, task.datasetName, task.dataset_name]
|
||||||
|
.filter((value) => typeof value === "string")
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return text.includes(keyword);
|
||||||
|
})
|
||||||
|
.filter((task) => {
|
||||||
|
if (!status) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (task.status || "").toLowerCase() === status;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const timeA = new Date(a.createdAt || a.created_at || 0).getTime();
|
||||||
|
const timeB = new Date(b.createdAt || b.created_at || 0).getTime();
|
||||||
|
return timeB - timeA;
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = page * size;
|
||||||
|
const end = start + size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
content: filtered.slice(start, end),
|
||||||
|
total: filtered.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AutoAnnotationTaskList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailTaskId, setDetailTaskId] = useState("");
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
tableData,
|
||||||
|
pagination,
|
||||||
|
searchParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handleKeywordChange,
|
||||||
|
fetchData,
|
||||||
|
} = useFetchData<AutoAnnotationTaskRow>(
|
||||||
|
fetchAutoAnnotationTasks,
|
||||||
|
mapAutoAnnotationTask,
|
||||||
|
10000,
|
||||||
|
true,
|
||||||
|
[],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
options: Object.values(AUTO_ANNOTATION_STATUS_MAP),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenDetail = (task: AutoAnnotationTaskRow) => {
|
||||||
|
if (!task.id) {
|
||||||
|
message.warning("任务ID缺失,无法查看详情");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetailTaskId(task.id);
|
||||||
|
setDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopTask = async (task: AutoAnnotationTaskRow) => {
|
||||||
|
try {
|
||||||
|
await stopAnnotationOperatorTaskByIdUsingPost(task.id);
|
||||||
|
message.success("已发送停止请求");
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error("停止任务失败,请稍后重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (task: AutoAnnotationTaskRow) => {
|
||||||
|
try {
|
||||||
|
await downloadAnnotationOperatorTaskResultUsingGet(
|
||||||
|
task.id,
|
||||||
|
`${task.name || task.id}_annotations.zip`
|
||||||
|
);
|
||||||
|
message.success("结果下载已开始");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error("下载失败,请确认任务已生成结果");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<AutoAnnotationTaskRow> = [
|
||||||
|
{
|
||||||
|
title: "任务名称",
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
width: 180,
|
||||||
|
fixed: "left",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ paddingInline: 0 }}
|
||||||
|
onClick={() => handleOpenDetail(record)}
|
||||||
|
>
|
||||||
|
{record.name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "任务ID",
|
||||||
|
dataIndex: "id",
|
||||||
|
key: "id",
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "源数据集",
|
||||||
|
dataIndex: "datasetName",
|
||||||
|
key: "datasetName",
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
if (!record.datasetId) {
|
||||||
|
return record.datasetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={() => navigate(`/data/management/detail/${record.datasetId}`)}
|
||||||
|
>
|
||||||
|
{record.datasetName}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
key: "status",
|
||||||
|
width: 120,
|
||||||
|
render: (status: StatusMeta) => (
|
||||||
|
<Badge color={status.color} text={status.label} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "进度",
|
||||||
|
dataIndex: "progress",
|
||||||
|
key: "progress",
|
||||||
|
width: 180,
|
||||||
|
render: (_progress, record) => {
|
||||||
|
const progressStatus =
|
||||||
|
record.status.value === "failed"
|
||||||
|
? "exception"
|
||||||
|
: record.status.value === "completed"
|
||||||
|
? "success"
|
||||||
|
: record.status.value === "running"
|
||||||
|
? "active"
|
||||||
|
: "normal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Progress
|
||||||
|
percent={record.progress}
|
||||||
|
size="small"
|
||||||
|
status={progressStatus}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "已处理/总数",
|
||||||
|
dataIndex: "processedImages",
|
||||||
|
key: "processedImages",
|
||||||
|
width: 120,
|
||||||
|
align: "right",
|
||||||
|
render: (_, record) => `${record.processedImages}/${record.totalImages}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "检测对象数",
|
||||||
|
dataIndex: "detectedObjects",
|
||||||
|
key: "detectedObjects",
|
||||||
|
width: 120,
|
||||||
|
align: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
dataIndex: "createdAt",
|
||||||
|
key: "createdAt",
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "更新时间",
|
||||||
|
dataIndex: "updatedAt",
|
||||||
|
key: "updatedAt",
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "完成时间",
|
||||||
|
dataIndex: "completedAt",
|
||||||
|
key: "completedAt",
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
dataIndex: "actions",
|
||||||
|
key: "actions",
|
||||||
|
width: 140,
|
||||||
|
fixed: "right",
|
||||||
|
render: (_, record) => {
|
||||||
|
const canStop =
|
||||||
|
["pending", "running"].includes(record.status.value) &&
|
||||||
|
!record.stopRequested;
|
||||||
|
const canDownload =
|
||||||
|
["completed", "stopped", "failed"].includes(record.status.value) &&
|
||||||
|
!!record.outputPath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip title={record.stopRequested ? "已请求停止" : "停止任务"}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PauseCircleOutlined />}
|
||||||
|
disabled={!canStop}
|
||||||
|
onClick={() => handleStopTask(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={canDownload ? "下载结果" : "任务完成后可下载"}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
disabled={!canDownload}
|
||||||
|
onClick={() => handleDownload(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="自动标注任务">
|
||||||
|
<div className="mb-4">
|
||||||
|
<SearchControls
|
||||||
|
searchTerm={searchParams.keyword}
|
||||||
|
onSearchChange={handleKeywordChange}
|
||||||
|
searchPlaceholder="搜索任务名称、任务ID、数据集"
|
||||||
|
filters={filterOptions}
|
||||||
|
onFiltersChange={handleFiltersChange}
|
||||||
|
showViewToggle={false}
|
||||||
|
onReload={fetchData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tableData}
|
||||||
|
pagination={pagination}
|
||||||
|
scroll={{ x: "max-content", y: "calc(100vh - 34rem)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AutoAnnotationTaskDetailDrawer
|
||||||
|
open={detailOpen}
|
||||||
|
taskId={detailTaskId}
|
||||||
|
onClose={() => setDetailOpen(false)}
|
||||||
|
onRefreshList={() => fetchData()}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
frontend/src/pages/DataAnnotation/OperatorCreate/CreateTask.tsx
Normal file
281
frontend/src/pages/DataAnnotation/OperatorCreate/CreateTask.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,30 @@ export function syncAnnotationTaskUsingPost(data: RequestPayload) {
|
|||||||
return post(`/api/annotation/task/sync`, data);
|
return post(`/api/annotation/task/sync`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function queryAnnotationOperatorTasksUsingGet(params?: RequestParams) {
|
||||||
|
return get("/api/annotation/operator-tasks", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnnotationOperatorTaskUsingPost(data: RequestPayload) {
|
||||||
|
return post("/api/annotation/operator-tasks", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryAnnotationOperatorTaskByIdUsingGet(taskId: string) {
|
||||||
|
return get(`/api/annotation/operator-tasks/${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAnnotationOperatorTaskByIdUsingDelete(taskId: string) {
|
||||||
|
return del(`/api/annotation/operator-tasks/${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAnnotationOperatorTaskByIdUsingPost(taskId: string) {
|
||||||
|
return post(`/api/annotation/operator-tasks/${taskId}/stop`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadAnnotationOperatorTaskResultUsingGet(taskId: string, filename?: string) {
|
||||||
|
return download(`/api/annotation/operator-tasks/${taskId}/download`, null, filename);
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) {
|
export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) {
|
||||||
// Backend expects mapping UUID as path parameter
|
// Backend expects mapping UUID as path parameter
|
||||||
return del(`/api/annotation/project/${mappingId}`);
|
return del(`/api/annotation/project/${mappingId}`);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface OperatorI {
|
|||||||
version: string;
|
version: string;
|
||||||
inputs: string;
|
inputs: string;
|
||||||
outputs: string;
|
outputs: string;
|
||||||
|
runtime?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
iconColor?: string; // 图标背景色,用于区分不同类型算子
|
iconColor?: string; // 图标背景色,用于区分不同类型算子
|
||||||
description: string;
|
description: string;
|
||||||
@@ -36,8 +37,8 @@ export interface OperatorI {
|
|||||||
originalId?: string; // 用于标识原始算子ID,便于去重
|
originalId?: string; // 用于标识原始算子ID,便于去重
|
||||||
categories: string[]; // 分类列表
|
categories: string[]; // 分类列表
|
||||||
settings: string;
|
settings: string;
|
||||||
overrides?: { [key: string]: any }; // 用户配置的参数
|
overrides?: Record<string, unknown>; // 用户配置的参数
|
||||||
defaultParams?: { [key: string]: any }; // 默认参数
|
defaultParams?: Record<string, unknown>; // 默认参数
|
||||||
configs: {
|
configs: {
|
||||||
[key: string]: ConfigI;
|
[key: string]: ConfigI;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import CleansingTemplateCreate from "@/pages/DataCleansing/Create/CreateTemplate
|
|||||||
import DataAnnotation from "@/pages/DataAnnotation/Home/DataAnnotation";
|
import DataAnnotation from "@/pages/DataAnnotation/Home/DataAnnotation";
|
||||||
import AnnotationTaskCreate from "@/pages/DataAnnotation/Create/CreateTask";
|
import AnnotationTaskCreate from "@/pages/DataAnnotation/Create/CreateTask";
|
||||||
import LabelStudioTextEditor from "@/pages/DataAnnotation/Annotate/LabelStudioTextEditor";
|
import LabelStudioTextEditor from "@/pages/DataAnnotation/Annotate/LabelStudioTextEditor";
|
||||||
|
import AnnotationOperatorTaskCreate from "@/pages/DataAnnotation/OperatorCreate/CreateTask";
|
||||||
|
|
||||||
import DataSynthesisPage from "@/pages/SynthesisTask/DataSynthesis";
|
import DataSynthesisPage from "@/pages/SynthesisTask/DataSynthesis";
|
||||||
import InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate";
|
import InstructionTemplateCreate from "@/pages/SynthesisTask/CreateTemplate";
|
||||||
@@ -183,6 +184,10 @@ const router = createBrowserRouter([
|
|||||||
path: "create-task",
|
path: "create-task",
|
||||||
Component: AnnotationTaskCreate,
|
Component: AnnotationTaskCreate,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "create-auto-task",
|
||||||
|
Component: AnnotationOperatorTaskCreate,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "annotate/:projectId",
|
path: "annotate/:projectId",
|
||||||
Component: LabelStudioTextEditor,
|
Component: LabelStudioTextEditor,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
"""Annotation-related operators (e.g. YOLO detection)."""
|
"""Annotation-related operators (e.g. YOLO detection)."""
|
||||||
|
|
||||||
from . import image_object_detection_bounding_box
|
from . import image_object_detection_bounding_box
|
||||||
|
from . import test_annotation_marker
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"image_object_detection_bounding_box",
|
"image_object_detection_bounding_box",
|
||||||
|
"test_annotation_marker",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ settings:
|
|||||||
name: '目标类别'
|
name: '目标类别'
|
||||||
description: 'COCO 类别 ID 列表;为空表示全部类别。'
|
description: 'COCO 类别 ID 列表;为空表示全部类别。'
|
||||||
type: 'input'
|
type: 'input'
|
||||||
defaultVal: '[]'
|
defaultVal: []
|
||||||
outputDir:
|
outputDir:
|
||||||
name: '输出目录'
|
name: '输出目录'
|
||||||
description: '算子输出目录(由运行时注入)。'
|
description: '算子输出目录(由运行时注入)。'
|
||||||
|
|||||||
12
runtime/ops/annotation/test_annotation_marker/__init__.py
Normal file
12
runtime/ops/annotation/test_annotation_marker/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Test annotation marker operator package."""
|
||||||
|
|
||||||
|
from datamate.core.base_op import OPERATORS
|
||||||
|
|
||||||
|
from .process import test_annotation_marker
|
||||||
|
|
||||||
|
OPERATORS.register_module(
|
||||||
|
module_name="test_annotation_marker",
|
||||||
|
module_path="ops.annotation.test_annotation_marker.process",
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["test_annotation_marker"]
|
||||||
37
runtime/ops/annotation/test_annotation_marker/metadata.yml
Normal file
37
runtime/ops/annotation/test_annotation_marker/metadata.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: '测试标注标记'
|
||||||
|
name_en: 'Test Annotation Marker'
|
||||||
|
description: '系统测试算子:在图片上添加测试标记并输出对应标注文件。'
|
||||||
|
description_en: 'System testing operator: draw test marker on images and emit annotation json.'
|
||||||
|
language: 'python'
|
||||||
|
vendor: 'datamate'
|
||||||
|
raw_id: 'test_annotation_marker'
|
||||||
|
version: '1.0.0'
|
||||||
|
types:
|
||||||
|
- 'annotation'
|
||||||
|
modal: 'image'
|
||||||
|
inputs: 'image'
|
||||||
|
outputs: 'image'
|
||||||
|
settings:
|
||||||
|
markerText:
|
||||||
|
name: '标记文本'
|
||||||
|
description: '绘制在图片上的测试标记文本。'
|
||||||
|
type: 'input'
|
||||||
|
defaultVal: 'TEST_ANNOTATION'
|
||||||
|
markerColor:
|
||||||
|
name: '标记颜色'
|
||||||
|
description: '标记颜色(OpenCV BGR,格式如 0,255,0)。'
|
||||||
|
type: 'input'
|
||||||
|
defaultVal: '0,255,0'
|
||||||
|
markerThickness:
|
||||||
|
name: '线宽'
|
||||||
|
description: '标记框线宽。'
|
||||||
|
type: 'inputNumber'
|
||||||
|
defaultVal: 2
|
||||||
|
min: 1
|
||||||
|
max: 10
|
||||||
|
step: 1
|
||||||
|
outputDir:
|
||||||
|
name: '输出目录'
|
||||||
|
description: '算子输出目录(由运行时注入)。'
|
||||||
|
type: 'input'
|
||||||
|
defaultVal: ''
|
||||||
122
runtime/ops/annotation/test_annotation_marker/process.py
Normal file
122
runtime/ops/annotation/test_annotation_marker/process.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from datamate.core.base_op import Mapper
|
||||||
|
|
||||||
|
|
||||||
|
class test_annotation_marker(Mapper):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(test_annotation_marker, self).__init__(*args, **kwargs)
|
||||||
|
self._marker_text = str(kwargs.get("markerText", "TEST_ANNOTATION"))
|
||||||
|
self._marker_color = self._parse_color(kwargs.get("markerColor", "0,255,0"))
|
||||||
|
self._marker_thickness = int(kwargs.get("markerThickness", 2) or 2)
|
||||||
|
self._output_dir = kwargs.get("outputDir")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_color(value: Any) -> Tuple[int, int, int]:
|
||||||
|
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
||||||
|
try:
|
||||||
|
bgr = tuple(int(max(min(float(item), 255), 0)) for item in value[:3])
|
||||||
|
return bgr # type: ignore[return-value]
|
||||||
|
except Exception:
|
||||||
|
return 0, 255, 0
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts = [part.strip() for part in value.split(",")]
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
bgr = tuple(int(max(min(float(item), 255), 0)) for item in parts[:3])
|
||||||
|
return bgr # type: ignore[return-value]
|
||||||
|
except Exception:
|
||||||
|
return 0, 255, 0
|
||||||
|
|
||||||
|
return 0, 255, 0
|
||||||
|
|
||||||
|
def execute(self, sample: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
image_path = sample.get(self.image_key) or sample.get("image")
|
||||||
|
if not image_path or not os.path.exists(image_path):
|
||||||
|
logger.warning("test_annotation_marker: image not found: {}", image_path)
|
||||||
|
return sample
|
||||||
|
|
||||||
|
image = cv2.imread(image_path)
|
||||||
|
if image is None:
|
||||||
|
logger.warning("test_annotation_marker: failed to read image: {}", image_path)
|
||||||
|
return sample
|
||||||
|
|
||||||
|
image_height, image_width = image.shape[:2]
|
||||||
|
|
||||||
|
margin = max(min(image_width, image_height) // 20, 10)
|
||||||
|
x1, y1 = margin, margin
|
||||||
|
x2, y2 = image_width - margin, image_height - margin
|
||||||
|
|
||||||
|
cv2.rectangle(
|
||||||
|
image,
|
||||||
|
(x1, y1),
|
||||||
|
(x2, y2),
|
||||||
|
self._marker_color,
|
||||||
|
self._marker_thickness,
|
||||||
|
)
|
||||||
|
|
||||||
|
cv2.putText(
|
||||||
|
image,
|
||||||
|
self._marker_text,
|
||||||
|
(x1, max(y1 - 10, 20)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.8,
|
||||||
|
self._marker_color,
|
||||||
|
max(self._marker_thickness, 1),
|
||||||
|
cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._output_dir and os.path.exists(self._output_dir):
|
||||||
|
output_dir = self._output_dir
|
||||||
|
else:
|
||||||
|
output_dir = os.path.dirname(image_path)
|
||||||
|
|
||||||
|
images_dir = os.path.join(output_dir, "images")
|
||||||
|
annotations_dir = os.path.join(output_dir, "annotations")
|
||||||
|
os.makedirs(images_dir, exist_ok=True)
|
||||||
|
os.makedirs(annotations_dir, exist_ok=True)
|
||||||
|
|
||||||
|
base_name = os.path.basename(image_path)
|
||||||
|
name_without_ext = os.path.splitext(base_name)[0]
|
||||||
|
|
||||||
|
output_image_path = os.path.join(images_dir, base_name)
|
||||||
|
output_json_path = os.path.join(annotations_dir, f"{name_without_ext}.json")
|
||||||
|
|
||||||
|
cv2.imwrite(output_image_path, image)
|
||||||
|
|
||||||
|
annotations = {
|
||||||
|
"image": base_name,
|
||||||
|
"width": image_width,
|
||||||
|
"height": image_height,
|
||||||
|
"marker": {
|
||||||
|
"text": self._marker_text,
|
||||||
|
"color_bgr": list(self._marker_color),
|
||||||
|
"thickness": self._marker_thickness,
|
||||||
|
},
|
||||||
|
"detections": [
|
||||||
|
{
|
||||||
|
"label": self._marker_text,
|
||||||
|
"class_id": -1,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"bbox_xyxy": [x1, y1, x2, y2],
|
||||||
|
"bbox_xywh": [x1, y1, x2 - x1, y2 - y1],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(output_json_path, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(annotations, file, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
sample["output_image"] = output_image_path
|
||||||
|
sample["annotations_file"] = output_json_path
|
||||||
|
sample["annotations"] = annotations
|
||||||
|
sample["detection_count"] = 1
|
||||||
|
return sample
|
||||||
@@ -122,7 +122,7 @@ DEFAULT_OUTPUT_ROOT = os.getenv(
|
|||||||
|
|
||||||
DEFAULT_OPERATOR_WHITELIST = os.getenv(
|
DEFAULT_OPERATOR_WHITELIST = os.getenv(
|
||||||
"AUTO_ANNOTATION_OPERATOR_WHITELIST",
|
"AUTO_ANNOTATION_OPERATOR_WHITELIST",
|
||||||
"ImageObjectDetectionBoundingBox",
|
"ImageObjectDetectionBoundingBox,test_annotation_marker",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ VALUES ('64465bec-b46b-11f0-8291-00155d0e4808', '模态', 'modal', 'predefined'
|
|||||||
INSERT IGNORE INTO t_operator
|
INSERT IGNORE INTO t_operator
|
||||||
(id, name, description, version, inputs, outputs, runtime, settings, file_name, is_star)
|
(id, name, description, version, inputs, outputs, runtime, settings, file_name, is_star)
|
||||||
VALUES ('MineruFormatter', 'MinerU PDF文本抽取', '基于MinerU API,抽取PDF中的文本。', '1.0.0', 'text', 'text', null, null, '', false),
|
VALUES ('MineruFormatter', 'MinerU PDF文本抽取', '基于MinerU API,抽取PDF中的文本。', '1.0.0', 'text', 'text', null, null, '', false),
|
||||||
|
('test_annotation_marker', '测试标注标记', '系统测试算子:在图片上添加测试标记并输出标注文件。', '1.0.0', 'image', 'image', 'ops.annotation.test_annotation_marker.process', '{"markerText": {"name": "标记文本", "description": "绘制在图片上的测试标记文本。", "type": "input", "defaultVal": "TEST_ANNOTATION"}, "markerColor": {"name": "标记颜色", "description": "标记颜色(OpenCV BGR,格式如 0,255,0)。", "type": "input", "defaultVal": "0,255,0"}, "markerThickness": {"name": "线宽", "description": "标记框线宽。", "type": "inputNumber", "defaultVal": 2, "min": 1, "max": 10, "step": 1}, "outputDir": {"name": "输出目录", "description": "算子输出目录(由运行时注入)。", "type": "input", "defaultVal": ""}}', '', 'false'),
|
||||||
('FileWithHighRepeatPhraseRateFilter', '文档词重复率检查', '去除重复词过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatPhraseRatio": {"name": "文档词重复率", "description": "某个词的统计数/文档总词数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}, "hitStopwords": {"name": "去除停用词", "description": "统计重复词时,选择是否要去除停用词。", "type": "switch", "defaultVal": false, "required": true, "checkedLabel": "去除", "unCheckedLabel": "不去除"}}', '', 'false'),
|
('FileWithHighRepeatPhraseRateFilter', '文档词重复率检查', '去除重复词过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatPhraseRatio": {"name": "文档词重复率", "description": "某个词的统计数/文档总词数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}, "hitStopwords": {"name": "去除停用词", "description": "统计重复词时,选择是否要去除停用词。", "type": "switch", "defaultVal": false, "required": true, "checkedLabel": "去除", "unCheckedLabel": "不去除"}}', '', 'false'),
|
||||||
('FileWithHighRepeatWordRateFilter', '文档字重复率检查', '去除重复字过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatWordRatio": {"name": "文档字重复率", "description": "某个字的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}}', '', 'false'),
|
('FileWithHighRepeatWordRateFilter', '文档字重复率检查', '去除重复字过多的文档。', '1.0.0', 'text', 'text', null, '{"repeatWordRatio": {"name": "文档字重复率", "description": "某个字的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.5, "min": 0, "max": 1, "step": 0.1}}', '', 'false'),
|
||||||
('FileWithHighSpecialCharRateFilter', '文档特殊字符率检查', '去除特殊字符过多的文档。', '1.0.0', 'text', 'text', null, '{"specialCharRatio": {"name": "文档特殊字符率", "description": "特殊字符的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.3, "min": 0, "max": 1, "step": 0.1}}', '', 'false'),
|
('FileWithHighSpecialCharRateFilter', '文档特殊字符率检查', '去除特殊字符过多的文档。', '1.0.0', 'text', 'text', null, '{"specialCharRatio": {"name": "文档特殊字符率", "description": "特殊字符的统计数/文档总字数 > 设定值,该文档被去除。", "type": "slider", "defaultVal": 0.3, "min": 0, "max": 1, "step": 0.1}}', '', 'false'),
|
||||||
@@ -359,6 +360,14 @@ WHERE c.id IN ('de36b61c-9e8a-4422-8c31-d30585c7100f', '9eda9d5d-072b-499b-916c-
|
|||||||
'image_remove_background_mapper', 'image_segment_mapper', 'image_tagging_mapper',
|
'image_remove_background_mapper', 'image_segment_mapper', 'image_tagging_mapper',
|
||||||
'imgdiff_difference_area_generator_mapper');
|
'imgdiff_difference_area_generator_mapper');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO t_operator_category_relation(category_id, operator_id)
|
||||||
|
SELECT c.id, o.id
|
||||||
|
FROM t_operator_category c
|
||||||
|
CROSS JOIN t_operator o
|
||||||
|
WHERE c.id IN ('de36b61c-9e8a-4422-8c31-d30585c7100f', '9eda9d5d-072b-499b-916c-797a0a8750e1',
|
||||||
|
'96a3b07a-3439-4557-a835-525faad60ca3', '431e7798-5426-4e1a-aae6-b9905a836b34')
|
||||||
|
AND o.id IN ('test_annotation_marker');
|
||||||
|
|
||||||
INSERT IGNORE INTO t_operator_category_relation(category_id, operator_id)
|
INSERT IGNORE INTO t_operator_category_relation(category_id, operator_id)
|
||||||
SELECT c.id, o.id
|
SELECT c.id, o.id
|
||||||
FROM t_operator_category c
|
FROM t_operator_category c
|
||||||
|
|||||||
Reference in New Issue
Block a user