You've already forked DataMate
feat(annotation): 添加标注数据导出功能
- 新增导出对话框组件,支持多种格式选择 - 实现 JSON、JSONL、CSV、COCO、YOLO 五种导出格式 - 添加导出统计信息显示,包括总文件数和已标注数 - 集成前端导出按钮和后端 API 接口 - 支持仅导出已标注数据和包含原始数据选项 - 实现文件下载和命名功能
This commit is contained in:
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user