feat(annotation): 添加标注数据导出功能

- 新增导出对话框组件,支持多种格式选择
- 实现 JSON、JSONL、CSV、COCO、YOLO 五种导出格式
- 添加导出统计信息显示,包括总文件数和已标注数
- 集成前端导出按钮和后端 API 接口
- 支持仅导出已标注数据和包含原始数据选项
- 实现文件下载和命名功能
This commit is contained in:
2026-01-18 16:54:02 +08:00
parent 6fbf7cc84d
commit c48d2fdeb8
7 changed files with 911 additions and 69 deletions

View File

@@ -1,16 +1,17 @@
import { useState, useEffect } from "react";
import { Card, Button, Table, message, Modal, Tabs, Tag, Progress, Tooltip } from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SyncOutlined,
} 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 { useState, useEffect } from "react";
import { Card, Button, Table, message, Modal, Tabs, Tag, Progress, Tooltip } from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SyncOutlined,
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,
@@ -20,6 +21,7 @@ import {
} 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
@@ -40,13 +42,14 @@ const AUTO_MODEL_SIZE_LABELS: Record<string, string> = {
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 [autoTasks, setAutoTasks] = useState<any[]>([]);
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,
@@ -58,8 +61,8 @@ export default function DataAnnotation() {
handleKeywordChange,
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
// 拉取自动标注任务(供轮询和创建成功后立即刷新复用)
const refreshAutoTasks = async (silent = false) => {
@@ -77,24 +80,28 @@ export default function DataAnnotation() {
}
};
// 自动标注任务轮询(用于在同一表格中展示处理进度)
useEffect(() => {
refreshAutoTasks();
const timer = setInterval(() => refreshAutoTasks(true), 3000);
// 自动标注任务轮询(用于在同一表格中展示处理进度)
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 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({
@@ -257,6 +264,12 @@ export default function DataAnnotation() {
),
onClick: handleAnnotate,
},
{
key: "export",
label: "导出",
icon: <DownloadOutlined className="w-4 h-4" style={{ color: "#1890ff" }} />,
onClick: handleExport,
},
{
key: "sync",
label: "同步",
@@ -552,6 +565,13 @@ export default function DataAnnotation() {
}
}}
/>
<ExportAnnotationDialog
open={!!exportTask}
projectId={exportTask?.id || ""}
projectName={exportTask?.name || ""}
onClose={() => setExportTask(null)}
/>
</div>
),
},

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from "react";
import {
Modal,
Form,
Select,
Checkbox,
Spin,
Statistic,
Row,
Col,
message,
Alert,
} from "antd";
import { FileTextOutlined, CheckCircleOutlined } from "@ant-design/icons";
import {
getExportStatsUsingGet,
downloadAnnotationsUsingGet,
ExportFormat,
} from "../annotation.api";
interface ExportAnnotationDialogProps {
open: boolean;
projectId: string;
projectName: string;
onClose: () => void;
}
const FORMAT_OPTIONS: { label: string; value: ExportFormat; description: string }[] = [
{
label: "JSON",
value: "json",
description: "Label Studio 原生格式,包含完整标注结构",
},
{
label: "JSON Lines",
value: "jsonl",
description: "每行一条记录,适合大数据处理",
},
{
label: "CSV",
value: "csv",
description: "表格格式,可用 Excel 打开",
},
{
label: "COCO",
value: "coco",
description: "目标检测通用格式,适用于图像标注",
},
{
label: "YOLO",
value: "yolo",
description: "YOLO 格式(ZIP),适用于目标检测训练",
},
];
export default function ExportAnnotationDialog({
open,
projectId,
projectName,
onClose,
}: ExportAnnotationDialogProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [stats, setStats] = useState<{
totalFiles: number;
annotatedFiles: number;
} | null>(null);
// 加载导出统计信息
useEffect(() => {
if (open && projectId) {
setLoading(true);
getExportStatsUsingGet(projectId)
.then((res: any) => {
const data = res?.data || res;
setStats({
totalFiles: data?.totalFiles || 0,
annotatedFiles: data?.annotatedFiles || 0,
});
})
.catch((err) => {
console.error("Failed to get export stats:", err);
message.error("获取导出统计失败");
})
.finally(() => {
setLoading(false);
});
}
}, [open, projectId]);
// 重置表单
useEffect(() => {
if (open) {
form.setFieldsValue({
format: "json",
onlyAnnotated: true,
includeData: false,
});
}
}, [open, form]);
const handleExport = async () => {
try {
const values = await form.validateFields();
setExporting(true);
const blob = await downloadAnnotationsUsingGet(
projectId,
values.format,
values.onlyAnnotated,
values.includeData
);
// 获取文件名
const formatExt: Record<ExportFormat, string> = {
json: "json",
jsonl: "jsonl",
csv: "csv",
coco: "json",
yolo: "zip",
};
const ext = formatExt[values.format as ExportFormat] || "json";
const filename = `${projectName}_annotations.${ext}`;
// 下载文件
const url = window.URL.createObjectURL(blob as Blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
message.success("导出成功");
onClose();
} catch (err: any) {
console.error("Export failed:", err);
message.error(err?.message || "导出失败,请稍后重试");
} finally {
setExporting(false);
}
};
return (
<Modal
title="导出标注数据"
open={open}
onCancel={onClose}
onOk={handleExport}
okText="导出"
cancelText="取消"
confirmLoading={exporting}
width={520}
>
<Spin spinning={loading}>
{/* 统计信息 */}
<div className="mb-4 p-4 bg-gray-50 rounded-lg">
<Row gutter={16}>
<Col span={12}>
<Statistic
title="总文件数"
value={stats?.totalFiles || 0}
prefix={<FileTextOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="已标注文件数"
value={stats?.annotatedFiles || 0}
prefix={<CheckCircleOutlined style={{ color: "#52c41a" }} />}
/>
</Col>
</Row>
</div>
{/* 导出选项 */}
<Form form={form} layout="vertical">
<Form.Item
name="format"
label="导出格式"
rules={[{ required: true, message: "请选择导出格式" }]}
>
<Select
options={FORMAT_OPTIONS.map((opt) => ({
label: (
<div>
<div className="font-medium">{opt.label}</div>
<div className="text-xs text-gray-400">{opt.description}</div>
</div>
),
value: opt.value,
}))}
optionLabelProp="label"
/>
</Form.Item>
<Form.Item name="onlyAnnotated" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="includeData" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
</Form>
{stats && stats.annotatedFiles === 0 && (
<Alert
type="warning"
message="当前项目暂无已标注数据"
description="请先完成标注后再导出"
showIcon
/>
)}
</Spin>
</Modal>
);
}

View File

@@ -1,5 +1,8 @@
import { get, post, put, del, download } from "@/utils/request";
// 导出格式类型
export type ExportFormat = "json" | "jsonl" | "csv" | "coco" | "yolo";
// 标注任务管理相关接口
export function queryAnnotationTasksUsingGet(params?: any) {
return get("/api/annotation/project", params);
@@ -62,30 +65,60 @@ 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(内嵌版)
// =====================
export function getEditorProjectInfoUsingGet(projectId: string) {
return get(`/api/annotation/editor/projects/${projectId}`);
}
export function listEditorTasksUsingGet(projectId: string, params?: any) {
return get(`/api/annotation/editor/projects/${projectId}/tasks`, params);
}
export function getEditorTaskUsingGet(projectId: string, fileId: string) {
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}`);
}
export function upsertEditorAnnotationUsingPut(
projectId: string,
fileId: string,
data: any
) {
return put(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}/annotation`, data);
}
export function downloadAutoAnnotationResultUsingGet(taskId: string) {
return download(`/api/annotation/auto/${taskId}/download`);
}
// =====================
// Label Studio Editor(内嵌版)
// =====================
export function getEditorProjectInfoUsingGet(projectId: string) {
return get(`/api/annotation/editor/projects/${projectId}`);
}
export function listEditorTasksUsingGet(projectId: string, params?: any) {
return get(`/api/annotation/editor/projects/${projectId}/tasks`, params);
}
export function getEditorTaskUsingGet(projectId: string, fileId: string) {
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}`);
}
export function upsertEditorAnnotationUsingPut(
projectId: string,
fileId: string,
data: any
) {
return put(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}/annotation`, data);
}
// =====================
// 标注数据导出
// =====================
export interface ExportStatsResponse {
projectId: string;
projectName: string;
totalFiles: number;
annotatedFiles: number;
exportFormat: string;
}
export function getExportStatsUsingGet(projectId: string) {
return get(`/api/annotation/export/projects/${projectId}/stats`);
}
export function downloadAnnotationsUsingGet(
projectId: string,
format: ExportFormat = "json",
onlyAnnotated: boolean = true,
includeData: boolean = false
) {
const params = new URLSearchParams({
format,
only_annotated: String(onlyAnnotated),
include_data: String(includeData),
});
return download(`/api/annotation/export/projects/${projectId}/download?${params.toString()}`);
}