Skip to content

Commit 2383f63

Browse files
authored
feat(annotation): add bidirectional sync and flexible export for annotation tasks (#284)
- Support forward sync of auto-annotation results to Label Studio - Support backward sync of auto/manual annotation results to local database - Allow continuing to edit annotation data after task completion - Enable exporting annotation results in multiple formats (e.g. JSON, JSON Mini) - Support custom dataset selection and naming when saving annotation results
1 parent b0f44d4 commit 2383f63

16 files changed

Lines changed: 2049 additions & 105 deletions

File tree

backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/DatasetFileApplicationService.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,13 +402,23 @@ private void addFileToDataset(String datasetId, List<FileUploadResult> unpacked)
402402
for (FileUploadResult file : unpacked) {
403403
File savedFile = file.getSavedFile();
404404
LocalDateTime currentTime = LocalDateTime.now();
405+
// 统一 fileName:无论是否通过文件夹/压缩包上传,都只保留纯文件名
406+
String originalFileName = file.getFileName();
407+
String baseFileName = originalFileName;
408+
if (originalFileName != null) {
409+
String normalized = originalFileName.replace("\\", "/");
410+
int lastSlash = normalized.lastIndexOf('/');
411+
if (lastSlash >= 0 && lastSlash + 1 < normalized.length()) {
412+
baseFileName = normalized.substring(lastSlash + 1);
413+
}
414+
}
405415
DatasetFile datasetFile = DatasetFile.builder()
406416
.id(UUID.randomUUID().toString())
407417
.datasetId(datasetId)
408418
.fileSize(savedFile.length())
409419
.uploadTime(currentTime)
410420
.lastAccessTime(currentTime)
411-
.fileName(file.getFileName())
421+
.fileName(baseFileName)
412422
.filePath(savedFile.getPath())
413423
.fileType(AnalyzerUtils.getExtension(file.getFileName()))
414424
.build();

frontend/src/components/business/DatasetFileTransfer.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ interface DatasetFileTransferProps
2121
onSelectedFilesChange: (filesMap: { [key: string]: DatasetFile }) => void;
2222
onDatasetSelect?: (dataset: Dataset | null) => void;
2323
datasetTypeFilter?: DatasetType;
24+
/**
25+
* 锁定的文件ID集合:
26+
* - 在左侧文件列表中,这些文件的勾选框会变成灰色且不可交互;
27+
* - 点击整行也不会改变其选中状态;
28+
* - 主要用于“编辑任务数据集”场景下锁死任务初始文件。
29+
*/
30+
lockedFileIds?: string[];
2431
}
2532

2633
const fileCols = [
@@ -52,6 +59,7 @@ const DatasetFileTransfer: React.FC<DatasetFileTransferProps> = ({
5259
onSelectedFilesChange,
5360
onDatasetSelect,
5461
datasetTypeFilter,
62+
lockedFileIds,
5563
...props
5664
}) => {
5765
const [datasets, setDatasets] = React.useState<Dataset[]>([]);
@@ -79,6 +87,10 @@ const DatasetFileTransfer: React.FC<DatasetFileTransferProps> = ({
7987
);
8088
const [selectingAll, setSelectingAll] = React.useState<boolean>(false);
8189

90+
const lockedIdSet = React.useMemo(() => {
91+
return new Set((lockedFileIds || []).map((id) => String(id)));
92+
}, [lockedFileIds]);
93+
8294
const fetchDatasets = async () => {
8395
const { data } = await queryDatasetsUsingGet({
8496
// Ant Design Table pagination.current is 1-based; ensure backend also receives 1-based value
@@ -230,6 +242,10 @@ const DatasetFileTransfer: React.FC<DatasetFileTransferProps> = ({
230242
}, [selectedDataset, selectedFilesMap, onSelectedFilesChange]);
231243

232244
const toggleSelectFile = (record: DatasetFile) => {
245+
// 被锁定的文件不允许在此组件中被增删
246+
if (lockedIdSet.has(String(record.id))) {
247+
return;
248+
}
233249
if (!selectedFilesMap[record.id]) {
234250
onSelectedFilesChange({
235251
...selectedFilesMap,
@@ -421,6 +437,7 @@ const DatasetFileTransfer: React.FC<DatasetFileTransferProps> = ({
421437

422438
getCheckboxProps: (record: DatasetFile) => ({
423439
name: record.fileName,
440+
disabled: lockedIdSet.has(String(record.id)),
424441
}),
425442
}}
426443
/>

frontend/src/pages/DataAnnotation/AutoAnnotation/AutoAnnotation.tsx

Lines changed: 115 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { useState, useEffect } from "react";
2-
import { Card, Button, Table, message, Modal, Tag, Progress, Space, Tooltip } from "antd";
2+
import { Card, Button, Table, message, Modal, Tag, Progress, Space, Tooltip, Dropdown } from "antd";
33
import {
44
PlusOutlined,
55
DeleteOutlined,
66
DownloadOutlined,
77
ReloadOutlined,
88
EyeOutlined,
9-
SyncOutlined,
109
EditOutlined,
10+
MoreOutlined,
11+
SettingOutlined,
12+
ExportOutlined,
13+
ImportOutlined,
1114
} from "@ant-design/icons";
1215
import type { ColumnType } from "antd/es/table";
1316
import type { AutoAnnotationTask, AutoAnnotationStatus } from "../annotation.model";
@@ -19,6 +22,8 @@ import {
1922
syncAutoAnnotationTaskToLabelStudioUsingPost,
2023
} from "../annotation.api";
2124
import CreateAutoAnnotationDialog from "./components/CreateAutoAnnotationDialog";
25+
import EditAutoAnnotationDatasetDialog from "./components/EditAutoAnnotationDatasetDialog";
26+
import ImportFromLabelStudioDialog from "./components/ImportFromLabelStudioDialog";
2227

2328
const STATUS_COLORS: Record<AutoAnnotationStatus, string> = {
2429
pending: "default",
@@ -51,6 +56,10 @@ export default function AutoAnnotation() {
5156
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
5257
const [labelStudioBase, setLabelStudioBase] = useState<string | null>(null);
5358
const [datasetProjectMap, setDatasetProjectMap] = useState<Record<string, string>>({});
59+
const [editingTask, setEditingTask] = useState<AutoAnnotationTask | null>(null);
60+
const [showEditDatasetDialog, setShowEditDatasetDialog] = useState(false);
61+
const [importingTask, setImportingTask] = useState<AutoAnnotationTask | null>(null);
62+
const [showImportDialog, setShowImportDialog] = useState(false);
5463

5564
useEffect(() => {
5665
fetchTasks();
@@ -106,6 +115,16 @@ export default function AutoAnnotation() {
106115
}
107116
};
108117

118+
const handleEditTaskDataset = (task: AutoAnnotationTask) => {
119+
setEditingTask(task);
120+
setShowEditDatasetDialog(true);
121+
};
122+
123+
const handleImportFromLabelStudio = (task: AutoAnnotationTask) => {
124+
setImportingTask(task);
125+
setShowImportDialog(true);
126+
};
127+
109128
const handleDelete = (task: AutoAnnotationTask) => {
110129
Modal.confirm({
111130
title: `确认删除自动标注任务「${task.name}」吗?`,
@@ -303,55 +322,90 @@ export default function AutoAnnotation() {
303322
{
304323
title: "操作",
305324
key: "actions",
306-
width: 260,
325+
width: 320,
307326
fixed: "right",
308327
render: (_: any, record: AutoAnnotationTask) => (
309328
<Space size="small">
329+
{/* 一级功能菜单:前向同步 + 编辑(跳转 Label Studio) */}
330+
<Tooltip title="将 YOLO 预测结果前向同步到 Label Studio">
331+
<Button
332+
type="link"
333+
size="small"
334+
icon={<ExportOutlined />}
335+
onClick={() => handleSyncToLabelStudio(record)}
336+
>
337+
前向同步
338+
</Button>
339+
</Tooltip>
340+
<Tooltip title="在 Label Studio 中手动标注">
341+
<Button
342+
type="link"
343+
size="small"
344+
icon={<EditOutlined />}
345+
onClick={() => handleAnnotate(record)}
346+
>
347+
编辑
348+
</Button>
349+
</Tooltip>
350+
<Tooltip title="从 Label Studio 导回标注结果到数据集">
351+
<Button
352+
type="link"
353+
size="small"
354+
icon={<ImportOutlined />}
355+
onClick={() => handleImportFromLabelStudio(record)}
356+
>
357+
后向同步
358+
</Button>
359+
</Tooltip>
360+
361+
{/* 已完成任务的查看/下载结果仍保留 */}
310362
{record.status === "completed" && (
311363
<>
312-
<Tooltip title="查看结果">
364+
<Tooltip title="查看结果信息">
313365
<Button
314366
type="link"
315367
size="small"
316368
icon={<EyeOutlined />}
317369
onClick={() => handleViewResult(record)}
318370
/>
319371
</Tooltip>
320-
<Tooltip title="下载结果">
372+
<Tooltip title="下载标注结果 ZIP">
321373
<Button
322374
type="link"
323375
size="small"
324376
icon={<DownloadOutlined />}
325377
onClick={() => handleDownload(record)}
326378
/>
327379
</Tooltip>
328-
<Tooltip title="同步到 Label Studio">
329-
<Button
330-
type="link"
331-
size="small"
332-
icon={<SyncOutlined />}
333-
onClick={() => handleSyncToLabelStudio(record)}
334-
/>
335-
</Tooltip>
336-
<Tooltip title="在 Label Studio 中标注">
337-
<Button
338-
type="link"
339-
size="small"
340-
icon={<EditOutlined />}
341-
onClick={() => handleAnnotate(record)}
342-
/>
343-
</Tooltip>
344380
</>
345381
)}
346-
<Tooltip title="删除任务记录">
347-
<Button
348-
type="link"
349-
size="small"
350-
danger
351-
icon={<DeleteOutlined />}
352-
onClick={() => handleDelete(record)}
353-
/>
354-
</Tooltip>
382+
383+
{/* 二级功能菜单:折叠的删除任务 + 编辑任务数据集 */}
384+
<Dropdown
385+
menu={{
386+
items: [
387+
{
388+
key: "edit-dataset",
389+
label: "编辑任务数据集",
390+
icon: <SettingOutlined />,
391+
onClick: () => handleEditTaskDataset(record),
392+
},
393+
{
394+
key: "delete",
395+
label: "删除任务",
396+
icon: <DeleteOutlined />,
397+
danger: true,
398+
onClick: () => handleDelete(record),
399+
},
400+
],
401+
}}
402+
trigger={["click"]}
403+
>
404+
<Button type="link" size="small" icon={<MoreOutlined />}
405+
>
406+
更多
407+
</Button>
408+
</Dropdown>
355409
</Space>
356410
),
357411
},
@@ -402,6 +456,37 @@ export default function AutoAnnotation() {
402456
fetchTasks();
403457
}}
404458
/>
459+
460+
{editingTask && (
461+
<EditAutoAnnotationDatasetDialog
462+
visible={showEditDatasetDialog}
463+
task={editingTask}
464+
onCancel={() => {
465+
setShowEditDatasetDialog(false);
466+
setEditingTask(null);
467+
}}
468+
onSuccess={() => {
469+
setShowEditDatasetDialog(false);
470+
setEditingTask(null);
471+
fetchTasks();
472+
}}
473+
/>
474+
)}
475+
476+
{importingTask && (
477+
<ImportFromLabelStudioDialog
478+
visible={showImportDialog}
479+
task={importingTask}
480+
onCancel={() => {
481+
setShowImportDialog(false);
482+
setImportingTask(null);
483+
}}
484+
onSuccess={() => {
485+
setShowImportDialog(false);
486+
setImportingTask(null);
487+
}}
488+
/>
489+
)}
405490
</div>
406491
);
407492
}

0 commit comments

Comments
 (0)