refactor(annotation): 移除自动标注功能模块并简化创建对话框

- 删除 AutoAnnotation 相关的所有组件和页面文件
- 从 CreateAnnotationTaskDialog 中移除自动标注相关的表单和逻辑
- 简化 CreateAnnotationTaskDialog 为仅支持手动标注模式
- 移除 COCO_CLASSES 常量和相关依赖项
- 清理无用的导入和状态变量
- 更新对话框布局以适应单一标注模式
This commit is contained in:
2026-01-19 12:02:16 +08:00
parent 3dbd6cdd90
commit 3e04aecb34
6 changed files with 799 additions and 1894 deletions

View File

@@ -1,302 +0,0 @@
import { useState, useEffect } from "react";
import { Card, Button, Table, message, Modal, Tag, Progress, Space, Tooltip } from "antd";
import {
PlusOutlined,
DeleteOutlined,
DownloadOutlined,
ReloadOutlined,
EyeOutlined,
} from "@ant-design/icons";
import type { ColumnType } from "antd/es/table";
import type { AutoAnnotationTask, AutoAnnotationStatus } from "../annotation.model";
import {
queryAutoAnnotationTasksUsingGet,
deleteAutoAnnotationTaskByIdUsingDelete,
downloadAutoAnnotationResultUsingGet,
} from "../annotation.api";
import CreateAutoAnnotationDialog from "./components/CreateAutoAnnotationDialog";
const STATUS_COLORS: Record<AutoAnnotationStatus, string> = {
pending: "default",
running: "processing",
completed: "success",
failed: "error",
cancelled: "default",
};
const STATUS_LABELS: Record<AutoAnnotationStatus, string> = {
pending: "等待中",
running: "处理中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
const MODEL_SIZE_LABELS: Record<string, string> = {
n: "YOLOv8n (最快)",
s: "YOLOv8s",
m: "YOLOv8m",
l: "YOLOv8l (推荐)",
x: "YOLOv8x (最精确)",
};
export default function AutoAnnotation() {
const [loading, setLoading] = useState(false);
const [tasks, setTasks] = useState<AutoAnnotationTask[]>([]);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
useEffect(() => {
fetchTasks();
const interval = setInterval(() => {
fetchTasks(true);
}, 3000);
return () => clearInterval(interval);
}, []);
const fetchTasks = async (silent = false) => {
if (!silent) setLoading(true);
try {
const response = await queryAutoAnnotationTasksUsingGet();
setTasks(response.data || response || []);
} catch (error) {
console.error("Failed to fetch auto annotation tasks:", error);
if (!silent) message.error("获取任务列表失败");
} finally {
if (!silent) setLoading(false);
}
};
const handleDelete = (task: AutoAnnotationTask) => {
Modal.confirm({
title: `确认删除自动标注任务「${task.name}」吗?`,
content: "删除任务后,已生成的标注结果不会被删除。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await deleteAutoAnnotationTaskByIdUsingDelete(task.id);
message.success("任务删除成功");
fetchTasks();
setSelectedRowKeys((keys) => keys.filter((k) => k !== task.id));
} catch (error) {
console.error(error);
message.error("删除失败,请稍后重试");
}
},
});
};
const handleDownload = async (task: AutoAnnotationTask) => {
try {
message.loading("正在准备下载...", 0);
await downloadAutoAnnotationResultUsingGet(task.id);
message.destroy();
message.success("下载已开始");
} catch (error) {
console.error(error);
message.destroy();
message.error("下载失败");
}
};
const handleViewResult = (task: AutoAnnotationTask) => {
if (task.outputPath) {
Modal.info({
title: "标注结果路径",
content: (
<div>
<p>{task.outputPath}</p>
<p>{task.detectedObjects}</p>
<p>
{task.processedImages} / {task.totalImages}
</p>
</div>
),
});
}
};
const columns: ColumnType<AutoAnnotationTask>[] = [
{ title: "任务名称", dataIndex: "name", key: "name", width: 200 },
{
title: "数据集",
dataIndex: "datasetName",
key: "datasetName",
width: 220,
render: (_: any, record: AutoAnnotationTask) => {
const list =
record.sourceDatasets && record.sourceDatasets.length > 0
? record.sourceDatasets
: record.datasetName
? [record.datasetName]
: [];
if (list.length === 0) return "-";
const text = list.join(",");
return (
<Tooltip title={text}>
<span>{text}</span>
</Tooltip>
);
},
},
{
title: "模型",
dataIndex: ["config", "modelSize"],
key: "modelSize",
width: 120,
render: (size: string) => MODEL_SIZE_LABELS[size] || size,
},
{
title: "置信度",
dataIndex: ["config", "confThreshold"],
key: "confThreshold",
width: 100,
render: (threshold: number) => `${(threshold * 100).toFixed(0)}%`,
},
{
title: "目标类别",
dataIndex: ["config", "targetClasses"],
key: "targetClasses",
width: 120,
render: (classes: number[]) => (
<Tooltip
title={classes.length > 0 ? classes.join(", ") : "全部类别"}
>
<span>
{classes.length > 0
? `${classes.length} 个类别`
: "全部类别"}
</span>
</Tooltip>
),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (status: AutoAnnotationStatus) => (
<Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status]}</Tag>
),
},
{
title: "进度",
dataIndex: "progress",
key: "progress",
width: 150,
render: (progress: number, record: AutoAnnotationTask) => (
<div>
<Progress percent={progress} size="small" />
<div style={{ fontSize: "12px", color: "#999" }}>
{record.processedImages} / {record.totalImages}
</div>
</div>
),
},
{
title: "检测对象数",
dataIndex: "detectedObjects",
key: "detectedObjects",
width: 100,
render: (count: number) => count.toLocaleString(),
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 150,
render: (time: string) => new Date(time).toLocaleString(),
},
{
title: "操作",
key: "actions",
width: 180,
fixed: "right",
render: (_: any, record: AutoAnnotationTask) => (
<Space size="small">
{record.status === "completed" && (
<>
<Tooltip title="查看结果">
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewResult(record)}
/>
</Tooltip>
<Tooltip title="下载结果">
<Button
type="link"
size="small"
icon={<DownloadOutlined />}
onClick={() => handleDownload(record)}
/>
</Tooltip>
</>
)}
<Tooltip title="删除">
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
</Tooltip>
</Space>
),
},
];
return (
<div>
<Card
title="自动标注任务"
extra={
<Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
<Button
icon={<ReloadOutlined />}
loading={loading}
onClick={() => fetchTasks()}
>
</Button>
</Space>
}
>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={tasks}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
pagination={{ pageSize: 10 }}
scroll={{ x: 1000 }}
/>
</Card>
<CreateAutoAnnotationDialog
visible={showCreateDialog}
onCancel={() => setShowCreateDialog(false)}
onSuccess={() => {
setShowCreateDialog(false);
fetchTasks();
}}
/>
</div>
);
}

View File

@@ -1,286 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Form, Input, Select, Slider, message, Checkbox } from "antd";
import { createAutoAnnotationTaskUsingPost } from "../../annotation.api";
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import { DatasetType, type DatasetFile, type Dataset } from "@/pages/DataManagement/dataset.model";
import DatasetFileTransfer from "@/components/business/DatasetFileTransfer";
const { Option } = Select;
interface CreateAutoAnnotationDialogProps {
visible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
const COCO_CLASSES = [
{ id: 0, name: "person", label: "人" },
{ id: 1, name: "bicycle", label: "自行车" },
{ id: 2, name: "car", label: "汽车" },
{ id: 3, name: "motorcycle", label: "摩托车" },
{ id: 4, name: "airplane", label: "飞机" },
{ id: 5, name: "bus", label: "公交车" },
{ id: 6, name: "train", label: "火车" },
{ id: 7, name: "truck", label: "卡车" },
{ id: 8, name: "boat", label: "船" },
{ id: 9, name: "traffic light", label: "红绿灯" },
{ id: 10, name: "fire hydrant", label: "消防栓" },
{ id: 11, name: "stop sign", label: "停止标志" },
{ id: 12, name: "parking meter", label: "停车计时器" },
{ id: 13, name: "bench", label: "长椅" },
{ id: 14, name: "bird", label: "鸟" },
{ id: 15, name: "cat", label: "猫" },
{ id: 16, name: "dog", label: "狗" },
{ id: 17, name: "horse", label: "马" },
{ id: 18, name: "sheep", label: "羊" },
{ id: 19, name: "cow", label: "牛" },
{ id: 20, name: "elephant", label: "大象" },
{ id: 21, name: "bear", label: "熊" },
{ id: 22, name: "zebra", label: "斑马" },
{ id: 23, name: "giraffe", label: "长颈鹿" },
{ id: 24, name: "backpack", label: "背包" },
{ id: 25, name: "umbrella", label: "雨伞" },
{ id: 26, name: "handbag", label: "手提包" },
{ id: 27, name: "tie", label: "领带" },
{ id: 28, name: "suitcase", label: "行李箱" },
{ id: 29, name: "frisbee", label: "飞盘" },
{ id: 30, name: "skis", label: "滑雪板" },
{ id: 31, name: "snowboard", label: "滑雪板" },
{ id: 32, name: "sports ball", label: "球类" },
{ id: 33, name: "kite", label: "风筝" },
{ id: 34, name: "baseball bat", label: "棒球棒" },
{ id: 35, name: "baseball glove", label: "棒球手套" },
{ id: 36, name: "skateboard", label: "滑板" },
{ id: 37, name: "surfboard", label: "冲浪板" },
{ id: 38, name: "tennis racket", label: "网球拍" },
{ id: 39, name: "bottle", label: "瓶子" },
{ id: 40, name: "wine glass", label: "酒杯" },
{ id: 41, name: "cup", label: "杯子" },
{ id: 42, name: "fork", label: "叉子" },
{ id: 43, name: "knife", label: "刀" },
{ id: 44, name: "spoon", label: "勺子" },
{ id: 45, name: "bowl", label: "碗" },
{ id: 46, name: "banana", label: "香蕉" },
{ id: 47, name: "apple", label: "苹果" },
{ id: 48, name: "sandwich", label: "三明治" },
{ id: 49, name: "orange", label: "橙子" },
{ id: 50, name: "broccoli", label: "西兰花" },
{ id: 51, name: "carrot", label: "胡萝卜" },
{ id: 52, name: "hot dog", label: "热狗" },
{ id: 53, name: "pizza", label: "披萨" },
{ id: 54, name: "donut", label: "甜甜圈" },
{ id: 55, name: "cake", label: "蛋糕" },
{ id: 56, name: "chair", label: "椅子" },
{ id: 57, name: "couch", label: "沙发" },
{ id: 58, name: "potted plant", label: "盆栽" },
{ id: 59, name: "bed", label: "床" },
{ id: 60, name: "dining table", label: "餐桌" },
{ id: 61, name: "toilet", label: "马桶" },
{ id: 62, name: "tv", label: "电视" },
{ id: 63, name: "laptop", label: "笔记本电脑" },
{ id: 64, name: "mouse", label: "鼠标" },
{ id: 65, name: "remote", label: "遥控器" },
{ id: 66, name: "keyboard", label: "键盘" },
{ id: 67, name: "cell phone", label: "手机" },
{ id: 68, name: "microwave", label: "微波炉" },
{ id: 69, name: "oven", label: "烤箱" },
{ id: 70, name: "toaster", label: "烤面包机" },
{ id: 71, name: "sink", label: "水槽" },
{ id: 72, name: "refrigerator", label: "冰箱" },
{ id: 73, name: "book", label: "书" },
{ id: 74, name: "clock", label: "钟表" },
{ id: 75, name: "vase", label: "花瓶" },
{ id: 76, name: "scissors", label: "剪刀" },
{ id: 77, name: "teddy bear", label: "玩具熊" },
{ id: 78, name: "hair drier", label: "吹风机" },
{ id: 79, name: "toothbrush", label: "牙刷" },
];
export default function CreateAutoAnnotationDialog({
visible,
onCancel,
onSuccess,
}: CreateAutoAnnotationDialogProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [datasets, setDatasets] = useState<any[]>([]);
const [selectAllClasses, setSelectAllClasses] = useState(true);
const [selectedFilesMap, setSelectedFilesMap] = useState<Record<string, DatasetFile>>({});
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
const [imageFileCount, setImageFileCount] = useState(0);
useEffect(() => {
if (visible) {
fetchDatasets();
form.resetFields();
form.setFieldsValue({
modelSize: "l",
confThreshold: 0.7,
targetClasses: [],
});
}
}, [visible, form]);
const fetchDatasets = async () => {
try {
const { data } = await queryDatasetsUsingGet({
page: 0,
pageSize: 1000,
});
const imageDatasets = (data.content || [])
.map(mapDataset)
.filter((ds: any) => ds.datasetType === DatasetType.IMAGE);
setDatasets(imageDatasets);
} catch (error) {
console.error("Failed to fetch datasets:", error);
message.error("获取数据集列表失败");
}
};
useEffect(() => {
const imageExtensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"];
const count = Object.values(selectedFilesMap).filter((file) => {
const ext = file.fileName?.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
return imageExtensions.includes(ext);
}).length;
setImageFileCount(count);
}, [selectedFilesMap]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (imageFileCount === 0) {
message.error("请至少选择一个图像文件");
return;
}
setLoading(true);
const imageExtensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"];
const imageFileIds = Object.values(selectedFilesMap)
.filter((file) => {
const ext = file.fileName?.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
return imageExtensions.includes(ext);
})
.map((file) => file.id);
const payload = {
name: values.name,
datasetId: values.datasetId,
fileIds: imageFileIds,
config: {
modelSize: values.modelSize,
confThreshold: values.confThreshold,
targetClasses: selectAllClasses ? [] : values.targetClasses || [],
outputDatasetName: values.outputDatasetName || undefined,
},
};
await createAutoAnnotationTaskUsingPost(payload);
message.success("自动标注任务创建成功");
onSuccess();
} catch (error: any) {
if (error.errorFields) return;
console.error("Failed to create auto annotation task:", error);
message.error(error.message || "创建任务失败");
} finally {
setLoading(false);
}
};
const handleClassSelectionChange = (checked: boolean) => {
setSelectAllClasses(checked);
if (checked) {
form.setFieldsValue({ targetClasses: [] });
}
};
return (
<Modal
title="创建自动标注任务"
open={visible}
onCancel={onCancel}
onOk={handleSubmit}
confirmLoading={loading}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item
name="name"
label="任务名称"
rules={[
{ required: true, message: "请输入任务名称" },
{ max: 100, message: "任务名称不能超过100个字符" },
]}
>
<Input placeholder="请输入任务名称" />
</Form.Item>
<Form.Item label="选择数据集和图像文件" required>
<DatasetFileTransfer
open
selectedFilesMap={selectedFilesMap}
onSelectedFilesChange={setSelectedFilesMap}
onDatasetSelect={(dataset) => {
setSelectedDataset(dataset);
form.setFieldsValue({ datasetId: dataset?.id ?? "" });
}}
datasetTypeFilter={DatasetType.IMAGE}
/>
{selectedDataset && (
<div className="mt-2 p-2 bg-blue-50 rounded border border-blue-200 text-xs">
<span className="font-medium">{selectedDataset.name}</span> -
<span className="font-medium text-blue-600"> {imageFileCount} </span>
</div>
)}
</Form.Item>
<Form.Item hidden name="datasetId" rules={[{ required: true, message: "请选择数据集" }]}>
<Input type="hidden" />
</Form.Item>
<Form.Item name="modelSize" label="模型规模" rules={[{ required: true, message: "请选择模型规模" }]}>
<Select>
<Option value="n">YOLOv8n ()</Option>
<Option value="s">YOLOv8s</Option>
<Option value="m">YOLOv8m</Option>
<Option value="l">YOLOv8l ()</Option>
<Option value="x">YOLOv8x ()</Option>
</Select>
</Form.Item>
<Form.Item
name="confThreshold"
label="置信度阈值"
rules={[{ required: true, message: "请选择置信度阈值" }]}
>
<Slider min={0.1} max={0.9} step={0.05} tooltip={{ formatter: (v) => `${(v || 0) * 100}%` }} />
</Form.Item>
<Form.Item label="目标类别">
<Checkbox checked={selectAllClasses} onChange={(e) => handleClassSelectionChange(e.target.checked)}>
</Checkbox>
{!selectAllClasses && (
<Form.Item name="targetClasses" noStyle>
<Select mode="multiple" placeholder="选择目标类别" style={{ marginTop: 8 }}>
{COCO_CLASSES.map((cls) => (
<Option key={cls.id} value={cls.id}>
{cls.label} ({cls.name})
</Option>
))}
</Select>
</Form.Item>
)}
</Form.Item>
<Form.Item name="outputDatasetName" label="输出数据集名称 (可选)">
<Input placeholder="留空则将结果写入原数据集的标签中" />
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -1 +0,0 @@
export { default } from "./AutoAnnotation";

View File

@@ -1,501 +1,290 @@
import { useState, useEffect } from "react";
import { Card, Button, Table, message, Modal, Tabs, Tag, Progress, Tooltip } from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
DownloadOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router";
import { SearchControls } from "@/components/SearchControls";
import CardView from "@/components/CardView";
import type { AnnotationTask } from "../annotation.model";
import useFetchData from "@/hooks/useFetchData";
import {
deleteAnnotationTaskByIdUsingDelete,
queryAnnotationTasksUsingGet,
queryAutoAnnotationTasksUsingGet,
deleteAutoAnnotationTaskByIdUsingDelete,
} from "../annotation.api";
import { mapAnnotationTask } from "../annotation.const";
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
import ExportAnnotationDialog from "./ExportAnnotationDialog";
import { ColumnType } from "antd/es/table";
import { TemplateList } from "../Template";
// Note: DevelopmentInProgress intentionally not used here
const AUTO_STATUS_LABELS: Record<string, string> = {
pending: "等待中",
running: "处理中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
const AUTO_MODEL_SIZE_LABELS: Record<string, string> = {
n: "YOLOv8n (最快)",
s: "YOLOv8s",
m: "YOLOv8m",
l: "YOLOv8l (推荐)",
x: "YOLOv8x (最精确)",
};
export default function DataAnnotation() {
// return <DevelopmentInProgress showTime="2025.10.30" />;
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("tasks");
const [viewMode, setViewMode] = useState<"list" | "card">("list");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [exportTask, setExportTask] = useState<AnnotationTask | null>(null);
const [autoTasks, setAutoTasks] = useState<any[]>([]);
const {
loading,
tableData,
pagination,
searchParams,
fetchData,
handleFiltersChange,
handleKeywordChange,
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
// 拉取自动标注任务(供轮询和创建成功后立即刷新复用)
const refreshAutoTasks = async (silent = false) => {
try {
const response = await queryAutoAnnotationTasksUsingGet();
const tasks = (response as any)?.data || response || [];
if (Array.isArray(tasks)) {
setAutoTasks(tasks);
}
} catch (error) {
console.error("Failed to fetch auto annotation tasks:", error);
if (!silent) {
message.error("获取自动标注任务失败");
}
}
};
// 自动标注任务轮询(用于在同一表格中展示处理进度)
useEffect(() => {
refreshAutoTasks();
const timer = setInterval(() => refreshAutoTasks(true), 3000);
return () => {
clearInterval(timer);
};
}, []);
const handleAnnotate = (task: AnnotationTask) => {
const projectId = (task as any)?.id;
if (!projectId) {
message.error("无法进入标注:缺少标注项目ID");
return;
}
navigate(`/data/annotation/annotate/${projectId}`);
};
const handleExport = (task: AnnotationTask) => {
setExportTask(task);
};
const handleDelete = (task: AnnotationTask) => {
Modal.confirm({
title: `确认删除标注任务「${task.name}」吗?`,
content: "删除标注任务不会删除对应数据集,但会删除该任务的所有标注结果。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await deleteAnnotationTaskByIdUsingDelete(task.id);
message.success("映射删除成功");
fetchData();
// clear selection if deleted item was selected
setSelectedRowKeys((keys) => keys.filter((k) => k !== task.id));
setSelectedRows((rows) => rows.filter((r) => r.id !== task.id));
} catch (e) {
console.error(e);
message.error("删除失败,请稍后重试");
}
},
});
};
const handleDeleteAuto = (task: any) => {
Modal.confirm({
title: `确认删除自动标注任务「${task.name}」吗?`,
content: <div></div>,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await deleteAutoAnnotationTaskByIdUsingDelete(task.id);
message.success("自动标注任务删除成功");
// 重新拉取自动标注任务
setAutoTasks((prev) => prev.filter((t: any) => t.id !== task.id));
// 清理选中
setSelectedRowKeys((keys) => keys.filter((k) => k !== task.id));
setSelectedRows((rows) => rows.filter((r) => r.id !== task.id));
} catch (e) {
console.error(e);
message.error("删除失败,请稍后重试");
}
},
});
};
const handleBatchDelete = () => {
if (!selectedRows || selectedRows.length === 0) return;
const manualRows = selectedRows.filter((r) => r._kind !== "auto");
const autoRows = selectedRows.filter((r) => r._kind === "auto");
Modal.confirm({
title: `确认删除所选 ${selectedRows.length} 个标注任务吗?`,
content: "删除标注任务不会删除对应数据集,但会删除这些任务的所有标注结果。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await Promise.all(
[
...manualRows.map((r) => deleteAnnotationTaskByIdUsingDelete(r.id)),
...autoRows.map((r) => deleteAutoAnnotationTaskByIdUsingDelete(r.id)),
]
);
message.success("批量删除已完成");
fetchData();
setSelectedRowKeys([]);
setSelectedRows([]);
} catch (e) {
console.error(e);
message.error("批量删除失败,请稍后重试");
}
},
});
};
const operations = [
{
key: "annotate",
label: "标注",
icon: (
<EditOutlined
className="w-4 h-4 text-green-400"
style={{ color: "#52c41a" }}
/>
),
onClick: handleAnnotate,
},
{
key: "export",
label: "导出",
icon: <DownloadOutlined className="w-4 h-4" style={{ color: "#1890ff" }} />,
onClick: handleExport,
},
{
key: "delete",
label: "删除",
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
onClick: handleDelete,
},
];
// 合并手动标注任务与自动标注任务
const mergedTableData = [
// 手动标注任务
...tableData.map((task) => ({
...task,
_kind: "manual" as const,
})),
// 自动标注任务
...autoTasks.map((task: any) => {
const sourceList = Array.isArray(task.sourceDatasets)
? task.sourceDatasets
: task.datasetName
? [task.datasetName]
: [];
const datasetName = sourceList.length > 0 ? sourceList.join(",") : "-";
return {
id: task.id,
name: task.name,
datasetName,
createdAt: task.createdAt || "-",
updatedAt: task.updatedAt || "-",
_kind: "auto" as const,
autoStatus: task.status,
autoProgress: task.progress,
autoProcessedImages: task.processedImages,
autoTotalImages: task.totalImages,
autoDetectedObjects: task.detectedObjects,
autoConfig: task.config || {},
};
}),
];
const columns: ColumnType<any>[] = [
{
title: "任务名称",
dataIndex: "name",
key: "name",
fixed: "left" as const,
},
{
title: "类型",
key: "kind",
width: 100,
render: (_: any, record: any) =>
record._kind === "auto" ? "自动标注" : "手动标注",
},
{
title: "任务ID",
dataIndex: "id",
key: "id",
},
{
title: "数据集",
dataIndex: "datasetName",
key: "datasetName",
width: 180,
},
{
title: "模型",
key: "modelSize",
width: 160,
render: (_: any, record: any) => {
if (record._kind !== "auto") return "-";
const size = record.autoConfig?.modelSize;
return AUTO_MODEL_SIZE_LABELS[size] || size || "-";
},
},
{
title: "置信度",
key: "confThreshold",
width: 120,
render: (_: any, record: any) => {
if (record._kind !== "auto") return "-";
const threshold = record.autoConfig?.confThreshold;
if (typeof threshold !== "number") return "-";
return `${(threshold * 100).toFixed(0)}%`;
},
},
{
title: "目标类别",
key: "targetClasses",
width: 160,
render: (_: any, record: any) => {
if (record._kind !== "auto") return "-";
const classes: number[] = record.autoConfig?.targetClasses || [];
if (!classes.length) return "全部类别";
const text = classes.join(", ");
return (
<Tooltip title={text}>
<span>{`${classes.length} 个类别`}</span>
</Tooltip>
);
},
},
{
title: "自动标注状态",
key: "autoStatus",
width: 130,
render: (_: any, record: any) => {
if (record._kind !== "auto") return "-";
const status = record.autoStatus as string;
const label = AUTO_STATUS_LABELS[status] || status || "-";
return <Tag>{label}</Tag>;
},
},
{
title: "自动标注进度",
key: "autoProgress",
width: 200,
render: (_: any, record: any) => {
if (record._kind !== "auto") return "-";
const progress = typeof record.autoProgress === "number" ? record.autoProgress : 0;
const processed = record.autoProcessedImages ?? 0;
const total = record.autoTotalImages ?? 0;
return (
<div>
<Progress percent={progress} size="small" />
<div style={{ fontSize: 12, color: "#999" }}>
{processed} / {total}
</div>
</div>
);
},
},
{
title: "检测对象数",
key: "detectedObjects",
width: 120,
render: (_: any, record: any) => {
if (record._kind !== "auto") return "-";
const count = record.autoDetectedObjects;
if (typeof count !== "number") return "-";
try {
return count.toLocaleString();
} catch {
return String(count);
}
},
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
},
{
title: "更新时间",
dataIndex: "updatedAt",
key: "updatedAt",
width: 180,
},
{
title: "操作",
key: "actions",
fixed: "right" as const,
width: 150,
dataIndex: "actions",
render: (_: any, task: any) => (
<div className="flex items-center justify-center space-x-1">
{task._kind === "manual" &&
operations.map((operation) => (
<Button
key={operation.key}
type="text"
icon={operation.icon}
onClick={() => (operation?.onClick as any)?.(task)}
title={operation.label}
/>
))}
{task._kind === "auto" && (
<Button
type="text"
icon={<DeleteOutlined style={{ color: "#f5222d" }} />}
onClick={() => handleDeleteAuto(task)}
title="删除自动标注任务"
/>
)}
</div>
),
},
];
return (
<div className="flex flex-col h-full gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
</div>
{/* Tabs */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: "tasks",
label: "标注任务",
children: (
<div className="flex flex-col gap-4">
{/* Search, Filters and Buttons in one row */}
<div className="flex items-center justify-between gap-2">
{/* Left side: Search and view controls */}
<div className="flex items-center gap-2">
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称、描述"
onFiltersChange={handleFiltersChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
/>
</div>
{/* Right side: All action buttons */}
<div className="flex items-center gap-2">
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
</div>
{/* Task List/Card */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={mergedTableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as (string | number)[]);
setSelectedRows(rows as any[]);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
/>
</Card>
) : (
<CardView
data={tableData}
operations={operations as any}
pagination={pagination}
loading={loading}
/>
)}
<CreateAnnotationTask
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onRefresh={(mode?: any) => {
// 手动标注创建成功后刷新标注任务列表
fetchData();
// 自动标注创建成功后立即刷新自动标注任务列表
if (mode === "auto") {
refreshAutoTasks(true);
}
}}
/>
<ExportAnnotationDialog
open={!!exportTask}
projectId={exportTask?.id || ""}
projectName={exportTask?.name || ""}
onClose={() => setExportTask(null)}
/>
</div>
),
},
{
key: "templates",
label: "标注模板",
children: <TemplateList />,
},
]}
/>
</div>
);
}
import { useState } from "react";
import { Card, Button, Table, message, Modal, Tabs } from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
DownloadOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router";
import { SearchControls } from "@/components/SearchControls";
import CardView from "@/components/CardView";
import type { AnnotationTask } from "../annotation.model";
import useFetchData from "@/hooks/useFetchData";
import {
deleteAnnotationTaskByIdUsingDelete,
queryAnnotationTasksUsingGet,
} from "../annotation.api";
import { mapAnnotationTask } from "../annotation.const";
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
import ExportAnnotationDialog from "./ExportAnnotationDialog";
import { ColumnType } from "antd/es/table";
import { TemplateList } from "../Template";
// Note: DevelopmentInProgress intentionally not used here
export default function DataAnnotation() {
// return <DevelopmentInProgress showTime="2025.10.30" />;
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("tasks");
const [viewMode, setViewMode] = useState<"list" | "card">("list");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [exportTask, setExportTask] = useState<AnnotationTask | null>(null);
const {
loading,
tableData,
pagination,
searchParams,
fetchData,
handleFiltersChange,
handleKeywordChange,
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const handleAnnotate = (task: AnnotationTask) => {
const projectId = (task as any)?.id;
if (!projectId) {
message.error("无法进入标注:缺少标注项目ID");
return;
}
navigate(`/data/annotation/annotate/${projectId}`);
};
const handleExport = (task: AnnotationTask) => {
setExportTask(task);
};
const handleDelete = (task: AnnotationTask) => {
Modal.confirm({
title: `确认删除标注任务「${task.name}」吗?`,
content: "删除标注任务不会删除对应数据集,但会删除该任务的所有标注结果。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await deleteAnnotationTaskByIdUsingDelete(task.id);
message.success("删除成功");
fetchData();
// clear selection if deleted item was selected
setSelectedRowKeys((keys) => keys.filter((k) => k !== task.id));
setSelectedRows((rows) => rows.filter((r) => r.id !== task.id));
} catch (e) {
console.error(e);
message.error("删除失败,请稍后重试");
}
},
});
};
const handleBatchDelete = () => {
if (!selectedRows || selectedRows.length === 0) return;
Modal.confirm({
title: `确认删除所选 ${selectedRows.length} 个标注任务吗?`,
content: "删除标注任务不会删除对应数据集,但会删除这些任务的所有标注结果。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await Promise.all(
selectedRows.map((r) => deleteAnnotationTaskByIdUsingDelete(r.id))
);
message.success("批量删除已完成");
fetchData();
setSelectedRowKeys([]);
setSelectedRows([]);
} catch (e) {
console.error(e);
message.error("批量删除失败,请稍后重试");
}
},
});
};
const operations = [
{
key: "annotate",
label: "标注",
icon: (
<EditOutlined
className="w-4 h-4 text-green-400"
style={{ color: "#52c41a" }}
/>
),
onClick: handleAnnotate,
},
{
key: "export",
label: "导出",
icon: <DownloadOutlined className="w-4 h-4" style={{ color: "#1890ff" }} />,
onClick: handleExport,
},
{
key: "delete",
label: "删除",
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
onClick: handleDelete,
},
];
const columns: ColumnType<any>[] = [
{
title: "任务名称",
dataIndex: "name",
key: "name",
fixed: "left" as const,
},
{
title: "任务ID",
dataIndex: "id",
key: "id",
},
{
title: "数据集",
dataIndex: "datasetName",
key: "datasetName",
width: 180,
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
},
{
title: "更新时间",
dataIndex: "updatedAt",
key: "updatedAt",
width: 180,
},
{
title: "操作",
key: "actions",
fixed: "right" as const,
width: 150,
dataIndex: "actions",
render: (_: any, task: any) => (
<div className="flex items-center justify-center space-x-1">
{operations.map((operation) => (
<Button
key={operation.key}
type="text"
icon={operation.icon}
onClick={() => (operation?.onClick as any)?.(task)}
title={operation.label}
/>
))}
</div>
),
},
];
return (
<div className="flex flex-col h-full gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
</div>
{/* Tabs */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: "tasks",
label: "标注任务",
children: (
<div className="flex flex-col gap-4">
{/* Search, Filters and Buttons in one row */}
<div className="flex items-center justify-between gap-2">
{/* Left side: Search and view controls */}
<div className="flex items-center gap-2">
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称、描述"
onFiltersChange={handleFiltersChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
/>
</div>
{/* Right side: All action buttons */}
<div className="flex items-center gap-2">
<Button
danger
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
</div>
{/* Task List/Card */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as (string | number)[]);
setSelectedRows(rows as any[]);
},
}}
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
/>
</Card>
) : (
<CardView
data={tableData}
operations={operations as any}
pagination={pagination}
loading={loading}
/>
)}
<CreateAnnotationTask
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onRefresh={() => fetchData()}
/>
<ExportAnnotationDialog
open={!!exportTask}
projectId={exportTask?.id || ""}
projectName={exportTask?.name || ""}
onClose={() => setExportTask(null)}
/>
</div>
),
},
{
key: "templates",
label: "标注模板",
children: <TemplateList />,
},
]}
/>
</div>
);
}

View File

@@ -48,26 +48,6 @@ export function deleteAnnotationTemplateByIdUsingDelete(
return del(`/api/annotation/template/${templateId}`);
}
// 自动标注任务管理
export function queryAutoAnnotationTasksUsingGet(params?: any) {
return get("/api/annotation/auto", params);
}
export function createAutoAnnotationTaskUsingPost(data: any) {
return post("/api/annotation/auto", data);
}
export function deleteAutoAnnotationTaskByIdUsingDelete(taskId: string) {
return del(`/api/annotation/auto/${taskId}`);
}
export function getAutoAnnotationTaskStatusUsingGet(taskId: string) {
return get(`/api/annotation/auto/${taskId}/status`);
}
export function downloadAutoAnnotationResultUsingGet(taskId: string) {
return download(`/api/annotation/auto/${taskId}/download`);
}
// =====================
// Label Studio Editor(内嵌版)