You've already forked DataMate
feat: fix the problem in the Operator Market frontend pages
This commit is contained in:
@@ -1,229 +1,229 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, message } from "antd";
|
||||
import { Button, Badge, Progress, Checkbox } from "antd";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Save,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { mockTasks } from "@/mock/annotation";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
|
||||
export default function AnnotationWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const [task, setTask] = useState(mockTasks[0]);
|
||||
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [annotationProgress, setAnnotationProgress] = useState({
|
||||
completed: task.completedCount,
|
||||
skipped: task.skippedCount,
|
||||
total: task.totalCount,
|
||||
});
|
||||
|
||||
const handleSaveAndNext = () => {
|
||||
setAnnotationProgress((prev) => ({
|
||||
...prev,
|
||||
completed: prev.completed + 1,
|
||||
}));
|
||||
|
||||
if (currentFileIndex < task.totalCount - 1) {
|
||||
setCurrentFileIndex(currentFileIndex + 1);
|
||||
}
|
||||
|
||||
message({
|
||||
title: "标注已保存",
|
||||
description: "标注结果已保存,自动跳转到下一个",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkipAndNext = () => {
|
||||
setAnnotationProgress((prev) => ({
|
||||
...prev,
|
||||
skipped: prev.skipped + 1,
|
||||
}));
|
||||
|
||||
if (currentFileIndex < task.totalCount - 1) {
|
||||
setCurrentFileIndex(currentFileIndex + 1);
|
||||
}
|
||||
|
||||
message({
|
||||
title: "已跳过",
|
||||
description: "已跳过当前项目,自动跳转到下一个",
|
||||
});
|
||||
};
|
||||
|
||||
const getDatasetTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "text":
|
||||
return <FileText className="w-4 h-4 text-blue-500" />;
|
||||
case "image":
|
||||
return <ImageIcon className="w-4 h-4 text-green-500" />;
|
||||
case "video":
|
||||
return <Video className="w-4 h-4 text-purple-500" />;
|
||||
case "audio":
|
||||
return <Music className="w-4 h-4 text-orange-500" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const currentProgress = Math.round(
|
||||
((annotationProgress.completed + annotationProgress.skipped) /
|
||||
annotationProgress.total) *
|
||||
100
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate("/data/annotation")}
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
></Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getDatasetTypeIcon(task.datasetType)}
|
||||
<span className="text-xl font-bold">{task.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{currentFileIndex + 1} / {task.totalCount}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 min-w-56">
|
||||
<span className="text-sm text-gray-600">进度:</span>
|
||||
<Progress
|
||||
percent={currentProgress}
|
||||
showInfo={false}
|
||||
className="w-24 h-2"
|
||||
/>
|
||||
<span className="text-sm font-medium">{currentProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex-1 flex">
|
||||
{/* Annotation Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Only show for text and image types */}
|
||||
{(task.datasetType === "text" || task.datasetType === "image") && (
|
||||
<div className="w-80 border-l border-gray-200 p-4 space-y-4">
|
||||
{/* Progress Stats */}
|
||||
<Card>
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">已完成</span>
|
||||
<span className="font-medium text-green-500">
|
||||
{annotationProgress.completed}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">已跳过</span>
|
||||
<span className="font-medium text-red-500">
|
||||
{annotationProgress.skipped}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">剩余</span>
|
||||
<span className="font-medium text-gray-600">
|
||||
{annotationProgress.total -
|
||||
annotationProgress.completed -
|
||||
annotationProgress.skipped}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 my-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">总进度</span>
|
||||
<span className="font-medium">{currentProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-2">
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={handleSaveAndNext}
|
||||
className="bg-green-500 border-green-500 hover:bg-green-600 hover:border-green-600"
|
||||
icon={<CheckCircle className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
保存并下一个
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
onClick={handleSkipAndNext}
|
||||
icon={<SkipForward className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
跳过并下一个
|
||||
</Button>
|
||||
<Button block icon={<Save className="w-4 h-4 mr-2" />}>
|
||||
仅保存
|
||||
</Button>
|
||||
<Button block icon={<Eye className="w-4 h-4 mr-2" />}>
|
||||
预览结果
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
block
|
||||
disabled={currentFileIndex === 0}
|
||||
onClick={() => setCurrentFileIndex(currentFileIndex - 1)}
|
||||
>
|
||||
上一个
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
disabled={currentFileIndex === task.totalCount - 1}
|
||||
onClick={() => setCurrentFileIndex(currentFileIndex + 1)}
|
||||
>
|
||||
下一个
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
当前: {currentFileIndex + 1} / {task.totalCount}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Settings */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">自动保存</span>
|
||||
<Checkbox defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">快捷键提示</span>
|
||||
<Checkbox defaultChecked />
|
||||
</div>
|
||||
<Button block icon={<Settings className="w-4 h-4 mr-2" />}>
|
||||
更多设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, message } from "antd";
|
||||
import { Button, Badge, Progress, Checkbox } from "antd";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Save,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { mockTasks } from "@/mock/annotation";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
|
||||
export default function AnnotationWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const [task, setTask] = useState(mockTasks[0]);
|
||||
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
const [annotationProgress, setAnnotationProgress] = useState({
|
||||
completed: task.completedCount,
|
||||
skipped: task.skippedCount,
|
||||
total: task.totalCount,
|
||||
});
|
||||
|
||||
const handleSaveAndNext = () => {
|
||||
setAnnotationProgress((prev) => ({
|
||||
...prev,
|
||||
completed: prev.completed + 1,
|
||||
}));
|
||||
|
||||
if (currentFileIndex < task.totalCount - 1) {
|
||||
setCurrentFileIndex(currentFileIndex + 1);
|
||||
}
|
||||
|
||||
message({
|
||||
title: "标注已保存",
|
||||
description: "标注结果已保存,自动跳转到下一个",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkipAndNext = () => {
|
||||
setAnnotationProgress((prev) => ({
|
||||
...prev,
|
||||
skipped: prev.skipped + 1,
|
||||
}));
|
||||
|
||||
if (currentFileIndex < task.totalCount - 1) {
|
||||
setCurrentFileIndex(currentFileIndex + 1);
|
||||
}
|
||||
|
||||
message({
|
||||
title: "已跳过",
|
||||
description: "已跳过当前项目,自动跳转到下一个",
|
||||
});
|
||||
};
|
||||
|
||||
const getDatasetTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "text":
|
||||
return <FileText className="w-4 h-4 text-blue-500" />;
|
||||
case "image":
|
||||
return <ImageIcon className="w-4 h-4 text-green-500" />;
|
||||
case "video":
|
||||
return <Video className="w-4 h-4 text-purple-500" />;
|
||||
case "audio":
|
||||
return <Music className="w-4 h-4 text-orange-500" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const currentProgress = Math.round(
|
||||
((annotationProgress.completed + annotationProgress.skipped) /
|
||||
annotationProgress.total) *
|
||||
100
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate("/data/annotation")}
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
></Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getDatasetTypeIcon(task.datasetType)}
|
||||
<span className="text-xl font-bold">{task.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{currentFileIndex + 1} / {task.totalCount}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 min-w-56">
|
||||
<span className="text-sm text-gray-600">进度:</span>
|
||||
<Progress
|
||||
percent={currentProgress}
|
||||
showInfo={false}
|
||||
className="w-24 h-2"
|
||||
/>
|
||||
<span className="text-sm font-medium">{currentProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex-1 flex">
|
||||
{/* Annotation Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Only show for text and image types */}
|
||||
{(task.datasetType === "text" || task.datasetType === "image") && (
|
||||
<div className="w-80 border-l border-gray-200 p-4 space-y-4">
|
||||
{/* Progress Stats */}
|
||||
<Card>
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">已完成</span>
|
||||
<span className="font-medium text-green-500">
|
||||
{annotationProgress.completed}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">已跳过</span>
|
||||
<span className="font-medium text-red-500">
|
||||
{annotationProgress.skipped}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">剩余</span>
|
||||
<span className="font-medium text-gray-600">
|
||||
{annotationProgress.total -
|
||||
annotationProgress.completed -
|
||||
annotationProgress.skipped}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 my-3" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">总进度</span>
|
||||
<span className="font-medium">{currentProgress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-2">
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={handleSaveAndNext}
|
||||
className="bg-green-500 border-green-500 hover:bg-green-600 hover:border-green-600"
|
||||
icon={<CheckCircle className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
保存并下一个
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
onClick={handleSkipAndNext}
|
||||
icon={<SkipForward className="w-4 h-4 mr-2" />}
|
||||
>
|
||||
跳过并下一个
|
||||
</Button>
|
||||
<Button block icon={<Save className="w-4 h-4 mr-2" />}>
|
||||
仅保存
|
||||
</Button>
|
||||
<Button block icon={<Eye className="w-4 h-4 mr-2" />}>
|
||||
预览结果
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
block
|
||||
disabled={currentFileIndex === 0}
|
||||
onClick={() => setCurrentFileIndex(currentFileIndex - 1)}
|
||||
>
|
||||
上一个
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
disabled={currentFileIndex === task.totalCount - 1}
|
||||
onClick={() => setCurrentFileIndex(currentFileIndex + 1)}
|
||||
>
|
||||
下一个
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
当前: {currentFileIndex + 1} / {task.totalCount}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Settings */}
|
||||
<Card>
|
||||
<div className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">自动保存</span>
|
||||
<Checkbox defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">快捷键提示</span>
|
||||
<Checkbox defaultChecked />
|
||||
</div>
|
||||
<Button block icon={<Settings className="w-4 h-4 mr-2" />}>
|
||||
更多设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,457 +1,457 @@
|
||||
import { useState } from "react";
|
||||
import { Card, Button, Badge, Input, Checkbox } from "antd";
|
||||
|
||||
import {
|
||||
File,
|
||||
Search,
|
||||
CheckCircle,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
MessageSquare,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface QAPair {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface FileData {
|
||||
id: string;
|
||||
name: string;
|
||||
qaPairs: QAPair[];
|
||||
}
|
||||
|
||||
interface TextAnnotationWorkspaceProps {
|
||||
task: any;
|
||||
currentFileIndex: number;
|
||||
onSaveAndNext: () => void;
|
||||
onSkipAndNext: () => void;
|
||||
}
|
||||
|
||||
// 模拟文件数据
|
||||
const mockFiles: FileData[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "document_001.txt",
|
||||
qaPairs: [
|
||||
{
|
||||
id: "1",
|
||||
question: "什么是人工智能?",
|
||||
answer:
|
||||
"人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统。",
|
||||
status: "pending",
|
||||
confidence: 0.85,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
question: "机器学习和深度学习有什么区别?",
|
||||
answer:
|
||||
"机器学习是人工智能的一个子集,而深度学习是机器学习的一个子集。深度学习使用神经网络来模拟人脑的工作方式。",
|
||||
status: "pending",
|
||||
confidence: 0.92,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
question: "什么是神经网络?",
|
||||
answer:
|
||||
"神经网络是一种受生物神经网络启发的计算模型,由相互连接的节点(神经元)组成,能够学习和识别模式。",
|
||||
status: "pending",
|
||||
confidence: 0.78,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "document_002.txt",
|
||||
qaPairs: [
|
||||
{
|
||||
id: "4",
|
||||
question: "什么是自然语言处理?",
|
||||
answer:
|
||||
"自然语言处理(NLP)是人工智能的一个分支,专注于使计算机能够理解、解释和生成人类语言。",
|
||||
status: "pending",
|
||||
confidence: 0.88,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
question: "计算机视觉的应用有哪些?",
|
||||
answer:
|
||||
"计算机视觉广泛应用于图像识别、人脸识别、自动驾驶、医学影像分析、安防监控等领域。",
|
||||
status: "pending",
|
||||
confidence: 0.91,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TextAnnotationWorkspace({
|
||||
onSaveAndNext,
|
||||
onSkipAndNext,
|
||||
}: TextAnnotationWorkspaceProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<FileData | null>(
|
||||
mockFiles[0]
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [selectedQAs, setSelectedQAs] = useState<string[]>([]);
|
||||
|
||||
const handleFileSelect = (file: FileData) => {
|
||||
setSelectedFile(file);
|
||||
setSelectedQAs([]);
|
||||
};
|
||||
|
||||
const handleQAStatusChange = (
|
||||
qaId: string,
|
||||
status: "approved" | "rejected"
|
||||
) => {
|
||||
if (selectedFile) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
qa.id === qaId ? { ...qa, status } : qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
|
||||
message({
|
||||
title: status === "approved" ? "已标记为留用" : "已标记为不留用",
|
||||
description: `QA对 "${qaId}" 状态已更新`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchApprove = () => {
|
||||
if (selectedFile && selectedQAs.length > 0) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
selectedQAs.includes(qa.id)
|
||||
? { ...qa, status: "approved" as const }
|
||||
: qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
setSelectedQAs([]);
|
||||
|
||||
message({
|
||||
title: "批量操作完成",
|
||||
description: `已将 ${selectedQAs.length} 个QA对标记为留用`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchReject = () => {
|
||||
if (selectedFile && selectedQAs.length > 0) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
selectedQAs.includes(qa.id)
|
||||
? { ...qa, status: "rejected" as const }
|
||||
: qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
setSelectedQAs([]);
|
||||
|
||||
message({
|
||||
title: "批量操作完成",
|
||||
description: `已将 ${selectedQAs.length} 个QA对标记为不留用`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleQASelect = (qaId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedQAs([...selectedQAs, qaId]);
|
||||
} else {
|
||||
setSelectedQAs(selectedQAs.filter((id) => id !== qaId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked && selectedFile) {
|
||||
setSelectedQAs(selectedFile.qaPairs.map((qa) => qa.id));
|
||||
} else {
|
||||
setSelectedQAs([]);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return <Badge className="bg-green-100 text-green-800">留用</Badge>;
|
||||
case "rejected":
|
||||
return <Badge className="bg-red-100 text-red-800">不留用</Badge>;
|
||||
default:
|
||||
return <Badge>待标注</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence?: number) => {
|
||||
if (!confidence) return "text-gray-500";
|
||||
if (confidence >= 0.8) return "text-green-600";
|
||||
if (confidence >= 0.6) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const filteredQAs =
|
||||
selectedFile?.qaPairs.filter((qa) => {
|
||||
const matchesSearch =
|
||||
qa.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
qa.answer.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || qa.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex">
|
||||
{/* File List */}
|
||||
<div className="w-80 border-r bg-gray-50 p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">文件列表</h3>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input placeholder="搜索文件..." className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-96">
|
||||
<div className="space-y-2">
|
||||
{mockFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedFile?.id === file.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleFileSelect(file)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<File className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{file.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{file.qaPairs.length} 个QA对
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QA Annotation Area */}
|
||||
<div className="flex-1 p-6">
|
||||
{selectedFile ? (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{selectedFile.name}</h2>
|
||||
<p className="text-gray-500">
|
||||
共 {selectedFile.qaPairs.length} 个QA对
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={onSaveAndNext}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
保存并下一个
|
||||
</Button>
|
||||
<Button onClick={onSkipAndNext}>跳过</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Batch Actions */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="搜索QA对..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">待标注</option>
|
||||
<option value="approved">已留用</option>
|
||||
<option value="rejected">不留用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedQAs.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
已选择 {selectedQAs.length} 个
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleBatchApprove}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
批量留用
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchReject}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
批量不留用
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* QA List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedQAs.length === filteredQAs.length &&
|
||||
filteredQAs.length > 0
|
||||
}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm font-medium">全选</span>
|
||||
</div>
|
||||
|
||||
<div className="h-500">
|
||||
<div className="space-y-4">
|
||||
{filteredQAs.map((qa) => (
|
||||
<Card
|
||||
key={qa.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedQAs.includes(qa.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleQASelect(qa.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<MessageSquare className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
QA-{qa.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{qa.confidence && (
|
||||
<span
|
||||
className={`text-xs ${getConfidenceColor(
|
||||
qa.confidence
|
||||
)}`}
|
||||
>
|
||||
置信度: {(qa.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{getStatusBadge(qa.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<HelpCircle className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
问题
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm bg-blue-50 p-3 rounded">
|
||||
{qa.question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<MessageSquare className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
答案
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm bg-green-50 p-3 rounded">
|
||||
{qa.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleQAStatusChange(qa.id, "approved")
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
qa.status === "approved" ? "default" : "outline"
|
||||
}
|
||||
className={
|
||||
qa.status === "approved"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
留用
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleQAStatusChange(qa.id, "rejected")
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
qa.status === "rejected"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
不留用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<File className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
选择文件开始标注
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
从左侧文件列表中选择一个文件开始标注工作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Card, Button, Badge, Input, Checkbox } from "antd";
|
||||
|
||||
import {
|
||||
File,
|
||||
Search,
|
||||
CheckCircle,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
MessageSquare,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface QAPair {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface FileData {
|
||||
id: string;
|
||||
name: string;
|
||||
qaPairs: QAPair[];
|
||||
}
|
||||
|
||||
interface TextAnnotationWorkspaceProps {
|
||||
task: any;
|
||||
currentFileIndex: number;
|
||||
onSaveAndNext: () => void;
|
||||
onSkipAndNext: () => void;
|
||||
}
|
||||
|
||||
// 模拟文件数据
|
||||
const mockFiles: FileData[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "document_001.txt",
|
||||
qaPairs: [
|
||||
{
|
||||
id: "1",
|
||||
question: "什么是人工智能?",
|
||||
answer:
|
||||
"人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统。",
|
||||
status: "pending",
|
||||
confidence: 0.85,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
question: "机器学习和深度学习有什么区别?",
|
||||
answer:
|
||||
"机器学习是人工智能的一个子集,而深度学习是机器学习的一个子集。深度学习使用神经网络来模拟人脑的工作方式。",
|
||||
status: "pending",
|
||||
confidence: 0.92,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
question: "什么是神经网络?",
|
||||
answer:
|
||||
"神经网络是一种受生物神经网络启发的计算模型,由相互连接的节点(神经元)组成,能够学习和识别模式。",
|
||||
status: "pending",
|
||||
confidence: 0.78,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "document_002.txt",
|
||||
qaPairs: [
|
||||
{
|
||||
id: "4",
|
||||
question: "什么是自然语言处理?",
|
||||
answer:
|
||||
"自然语言处理(NLP)是人工智能的一个分支,专注于使计算机能够理解、解释和生成人类语言。",
|
||||
status: "pending",
|
||||
confidence: 0.88,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
question: "计算机视觉的应用有哪些?",
|
||||
answer:
|
||||
"计算机视觉广泛应用于图像识别、人脸识别、自动驾驶、医学影像分析、安防监控等领域。",
|
||||
status: "pending",
|
||||
confidence: 0.91,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TextAnnotationWorkspace({
|
||||
onSaveAndNext,
|
||||
onSkipAndNext,
|
||||
}: TextAnnotationWorkspaceProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<FileData | null>(
|
||||
mockFiles[0]
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [selectedQAs, setSelectedQAs] = useState<string[]>([]);
|
||||
|
||||
const handleFileSelect = (file: FileData) => {
|
||||
setSelectedFile(file);
|
||||
setSelectedQAs([]);
|
||||
};
|
||||
|
||||
const handleQAStatusChange = (
|
||||
qaId: string,
|
||||
status: "approved" | "rejected"
|
||||
) => {
|
||||
if (selectedFile) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
qa.id === qaId ? { ...qa, status } : qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
|
||||
message({
|
||||
title: status === "approved" ? "已标记为留用" : "已标记为不留用",
|
||||
description: `QA对 "${qaId}" 状态已更新`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchApprove = () => {
|
||||
if (selectedFile && selectedQAs.length > 0) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
selectedQAs.includes(qa.id)
|
||||
? { ...qa, status: "approved" as const }
|
||||
: qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
setSelectedQAs([]);
|
||||
|
||||
message({
|
||||
title: "批量操作完成",
|
||||
description: `已将 ${selectedQAs.length} 个QA对标记为留用`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchReject = () => {
|
||||
if (selectedFile && selectedQAs.length > 0) {
|
||||
const updatedFile = {
|
||||
...selectedFile,
|
||||
qaPairs: selectedFile.qaPairs.map((qa) =>
|
||||
selectedQAs.includes(qa.id)
|
||||
? { ...qa, status: "rejected" as const }
|
||||
: qa
|
||||
),
|
||||
};
|
||||
setSelectedFile(updatedFile);
|
||||
setSelectedQAs([]);
|
||||
|
||||
message({
|
||||
title: "批量操作完成",
|
||||
description: `已将 ${selectedQAs.length} 个QA对标记为不留用`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleQASelect = (qaId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedQAs([...selectedQAs, qaId]);
|
||||
} else {
|
||||
setSelectedQAs(selectedQAs.filter((id) => id !== qaId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked && selectedFile) {
|
||||
setSelectedQAs(selectedFile.qaPairs.map((qa) => qa.id));
|
||||
} else {
|
||||
setSelectedQAs([]);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return <Badge className="bg-green-100 text-green-800">留用</Badge>;
|
||||
case "rejected":
|
||||
return <Badge className="bg-red-100 text-red-800">不留用</Badge>;
|
||||
default:
|
||||
return <Badge>待标注</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence?: number) => {
|
||||
if (!confidence) return "text-gray-500";
|
||||
if (confidence >= 0.8) return "text-green-600";
|
||||
if (confidence >= 0.6) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const filteredQAs =
|
||||
selectedFile?.qaPairs.filter((qa) => {
|
||||
const matchesSearch =
|
||||
qa.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
qa.answer.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || qa.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex">
|
||||
{/* File List */}
|
||||
<div className="w-80 border-r bg-gray-50 p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">文件列表</h3>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input placeholder="搜索文件..." className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-96">
|
||||
<div className="space-y-2">
|
||||
{mockFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedFile?.id === file.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleFileSelect(file)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<File className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{file.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{file.qaPairs.length} 个QA对
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QA Annotation Area */}
|
||||
<div className="flex-1 p-6">
|
||||
{selectedFile ? (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{selectedFile.name}</h2>
|
||||
<p className="text-gray-500">
|
||||
共 {selectedFile.qaPairs.length} 个QA对
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={onSaveAndNext}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
保存并下一个
|
||||
</Button>
|
||||
<Button onClick={onSkipAndNext}>跳过</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Batch Actions */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="搜索QA对..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">待标注</option>
|
||||
<option value="approved">已留用</option>
|
||||
<option value="rejected">不留用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedQAs.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
已选择 {selectedQAs.length} 个
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleBatchApprove}
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
批量留用
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBatchReject}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
批量不留用
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* QA List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedQAs.length === filteredQAs.length &&
|
||||
filteredQAs.length > 0
|
||||
}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm font-medium">全选</span>
|
||||
</div>
|
||||
|
||||
<div className="h-500">
|
||||
<div className="space-y-4">
|
||||
{filteredQAs.map((qa) => (
|
||||
<Card
|
||||
key={qa.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedQAs.includes(qa.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleQASelect(qa.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<MessageSquare className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
QA-{qa.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{qa.confidence && (
|
||||
<span
|
||||
className={`text-xs ${getConfidenceColor(
|
||||
qa.confidence
|
||||
)}`}
|
||||
>
|
||||
置信度: {(qa.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{getStatusBadge(qa.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<HelpCircle className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
问题
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm bg-blue-50 p-3 rounded">
|
||||
{qa.question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<MessageSquare className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
答案
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm bg-green-50 p-3 rounded">
|
||||
{qa.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleQAStatusChange(qa.id, "approved")
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
qa.status === "approved" ? "default" : "outline"
|
||||
}
|
||||
className={
|
||||
qa.status === "approved"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-1" />
|
||||
留用
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleQAStatusChange(qa.id, "rejected")
|
||||
}
|
||||
size="sm"
|
||||
variant={
|
||||
qa.status === "rejected"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-1" />
|
||||
不留用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<File className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
选择文件开始标注
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
从左侧文件列表中选择一个文件开始标注工作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,346 +1,346 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Button, Input, Select, Divider, Form, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
CheckOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { mockTemplates } from "@/mock/annotation";
|
||||
import CustomTemplateDialog from "./components/CustomTemplateDialog";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
|
||||
import {
|
||||
DatasetType,
|
||||
type Dataset,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
type: "text" | "image";
|
||||
preview?: string;
|
||||
icon: React.ReactNode;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
const templateCategories = ["Computer Vision", "Natural Language Processing"];
|
||||
|
||||
export default function AnnotationTaskCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
|
||||
useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [datasetFilter, setDatasetFilter] = useState("all");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null
|
||||
);
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
|
||||
|
||||
// 用于Form的受控数据
|
||||
const [formValues, setFormValues] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
datasetId: "",
|
||||
templateId: "",
|
||||
});
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
const { data } = await queryDatasetsUsingGet();
|
||||
setDatasets(data.results || []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = mockTemplates.filter(
|
||||
(template) => template.category === selectedCategory
|
||||
);
|
||||
|
||||
const handleDatasetSelect = (datasetId: string) => {
|
||||
const dataset = datasets.find((ds) => ds.id === datasetId) || null;
|
||||
setSelectedDataset(dataset);
|
||||
setFormValues((prev) => ({ ...prev, datasetId }));
|
||||
if (dataset?.type === DatasetType.PRETRAIN_IMAGE) {
|
||||
setSelectedCategory("Computer Vision");
|
||||
} else if (dataset?.type === DatasetType.PRETRAIN_TEXT) {
|
||||
setSelectedCategory("Natural Language Processing");
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setFormValues((prev) => ({ ...prev, templateId: "" }));
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setFormValues((prev) => ({ ...prev, templateId: template.id }));
|
||||
};
|
||||
|
||||
const handleValuesChange = (_, allValues) => {
|
||||
setFormValues({ ...formValues, ...allValues });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const dataset = datasets.find((ds) => ds.id === values.datasetId);
|
||||
const template = mockTemplates.find(
|
||||
(tpl) => tpl.id === values.templateId
|
||||
);
|
||||
if (!dataset) {
|
||||
message.error("请选择数据集");
|
||||
return;
|
||||
}
|
||||
if (!template) {
|
||||
message.error("请选择标注模板");
|
||||
return;
|
||||
}
|
||||
const taskData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
dataset,
|
||||
template,
|
||||
};
|
||||
// onCreateTask(taskData); // 实际创建逻辑
|
||||
message.success("标注任务创建成功");
|
||||
navigate("/data/annotation");
|
||||
} catch (e) {
|
||||
// 校验失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomTemplate = (templateData: any) => {
|
||||
setSelectedTemplate(templateData);
|
||||
setFormValues((prev) => ({ ...prev, templateId: templateData.id }));
|
||||
message.success(`自定义模板 "${templateData.name}" 已创建`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Link to="/data/annotation">
|
||||
<Button type="text">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold bg-clip-text">创建标注任务</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-overflow-auto bg-white rounded-lg shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={formValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2">基本信息</h2>
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input placeholder="输入任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="任务描述"
|
||||
name="description"
|
||||
rules={[{ required: true, message: "请输入任务描述" }]}
|
||||
>
|
||||
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="选择数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
optionFilterProp="children"
|
||||
value={formValues.datasetId}
|
||||
onChange={handleDatasetSelect}
|
||||
placeholder="请选择数据集"
|
||||
size="large"
|
||||
options={datasets.map((dataset) => ({
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="font-medium text-gray-900">
|
||||
{dataset?.icon || <DatabaseOutlined className="mr-2" />}
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{dataset?.fileCount} 文件 • {dataset.size}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板选择 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mt-6 mb-2 flex items-center gap-2">
|
||||
模板选择
|
||||
</h2>
|
||||
<Form.Item
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Category Sidebar */}
|
||||
<div className="w-64 pr-6 border-r border-gray-200">
|
||||
<div className="space-y-2">
|
||||
{templateCategories.map((category) => {
|
||||
const isAvailable =
|
||||
selectedDataset?.type === "image"
|
||||
? category === "Computer Vision"
|
||||
: category === "Natural Language Processing";
|
||||
return (
|
||||
<Button
|
||||
key={category}
|
||||
type={
|
||||
selectedCategory === category && isAvailable
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
block
|
||||
disabled={!isAvailable}
|
||||
onClick={() =>
|
||||
isAvailable && setSelectedCategory(category)
|
||||
}
|
||||
style={{ textAlign: "left", marginBottom: 8 }}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
自定义模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Template Grid */}
|
||||
<div className="flex-1 pl-6">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`border rounded-lg cursor-pointer transition-all hover:shadow-md ${
|
||||
formValues.templateId === template.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template.preview && (
|
||||
<div className="aspect-video bg-gray-100 rounded-t-lg overflow-hidden">
|
||||
<img
|
||||
src={template.preview || "/placeholder.svg"}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{template.icon}
|
||||
<span className="font-medium text-sm">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Custom Template Option */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg cursor-pointer transition-all hover:border-gray-400 ${
|
||||
selectedTemplate?.isCustom
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
<div className="aspect-video bg-gray-50 rounded-t-lg flex items-center justify-center">
|
||||
<PlusOutlined
|
||||
style={{ fontSize: 32, color: "#bbb" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<PlusOutlined />
|
||||
<span className="font-medium text-sm">
|
||||
自定义模板
|
||||
</span>
|
||||
</div>
|
||||
{selectedTemplate?.isCustom && (
|
||||
<CheckOutlined style={{ color: "#1677ff" }} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
创建符合特定需求的标注模板
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplate && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
已选择模板
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: "#1677ff", marginTop: 4 }}
|
||||
>
|
||||
{selectedTemplate.name} - {selectedTemplate.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end border-t border-gray-200 p-6">
|
||||
<Button onClick={() => navigate("/data/annotation")}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Template Dialog */}
|
||||
<CustomTemplateDialog
|
||||
open={showCustomTemplateDialog}
|
||||
onOpenChange={setShowCustomTemplateDialog}
|
||||
onSaveTemplate={handleSaveCustomTemplate}
|
||||
datasetType={selectedDataset?.type || "image"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Button, Input, Select, Divider, Form, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
CheckOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { mockTemplates } from "@/mock/annotation";
|
||||
import CustomTemplateDialog from "./components/CustomTemplateDialog";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
|
||||
import {
|
||||
DatasetType,
|
||||
type Dataset,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
type: "text" | "image";
|
||||
preview?: string;
|
||||
icon: React.ReactNode;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
const templateCategories = ["Computer Vision", "Natural Language Processing"];
|
||||
|
||||
export default function AnnotationTaskCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
|
||||
useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [datasetFilter, setDatasetFilter] = useState("all");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null
|
||||
);
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
|
||||
|
||||
// 用于Form的受控数据
|
||||
const [formValues, setFormValues] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
datasetId: "",
|
||||
templateId: "",
|
||||
});
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
const { data } = await queryDatasetsUsingGet();
|
||||
setDatasets(data.results || []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDatasets();
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = mockTemplates.filter(
|
||||
(template) => template.category === selectedCategory
|
||||
);
|
||||
|
||||
const handleDatasetSelect = (datasetId: string) => {
|
||||
const dataset = datasets.find((ds) => ds.id === datasetId) || null;
|
||||
setSelectedDataset(dataset);
|
||||
setFormValues((prev) => ({ ...prev, datasetId }));
|
||||
if (dataset?.type === DatasetType.PRETRAIN_IMAGE) {
|
||||
setSelectedCategory("Computer Vision");
|
||||
} else if (dataset?.type === DatasetType.PRETRAIN_TEXT) {
|
||||
setSelectedCategory("Natural Language Processing");
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setFormValues((prev) => ({ ...prev, templateId: "" }));
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setFormValues((prev) => ({ ...prev, templateId: template.id }));
|
||||
};
|
||||
|
||||
const handleValuesChange = (_, allValues) => {
|
||||
setFormValues({ ...formValues, ...allValues });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const dataset = datasets.find((ds) => ds.id === values.datasetId);
|
||||
const template = mockTemplates.find(
|
||||
(tpl) => tpl.id === values.templateId
|
||||
);
|
||||
if (!dataset) {
|
||||
message.error("请选择数据集");
|
||||
return;
|
||||
}
|
||||
if (!template) {
|
||||
message.error("请选择标注模板");
|
||||
return;
|
||||
}
|
||||
const taskData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
dataset,
|
||||
template,
|
||||
};
|
||||
// onCreateTask(taskData); // 实际创建逻辑
|
||||
message.success("标注任务创建成功");
|
||||
navigate("/data/annotation");
|
||||
} catch (e) {
|
||||
// 校验失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustomTemplate = (templateData: any) => {
|
||||
setSelectedTemplate(templateData);
|
||||
setFormValues((prev) => ({ ...prev, templateId: templateData.id }));
|
||||
message.success(`自定义模板 "${templateData.name}" 已创建`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Link to="/data/annotation">
|
||||
<Button type="text">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold bg-clip-text">创建标注任务</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-overflow-auto bg-white rounded-lg shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={formValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mb-2">基本信息</h2>
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input placeholder="输入任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="任务描述"
|
||||
name="description"
|
||||
rules={[{ required: true, message: "请输入任务描述" }]}
|
||||
>
|
||||
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="选择数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
optionFilterProp="children"
|
||||
value={formValues.datasetId}
|
||||
onChange={handleDatasetSelect}
|
||||
placeholder="请选择数据集"
|
||||
size="large"
|
||||
options={datasets.map((dataset) => ({
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="font-medium text-gray-900">
|
||||
{dataset?.icon || <DatabaseOutlined className="mr-2" />}
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{dataset?.fileCount} 文件 • {dataset.size}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 模板选择 */}
|
||||
<h2 className="font-medium text-gray-900 text-lg mt-6 mb-2 flex items-center gap-2">
|
||||
模板选择
|
||||
</h2>
|
||||
<Form.Item
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Category Sidebar */}
|
||||
<div className="w-64 pr-6 border-r border-gray-200">
|
||||
<div className="space-y-2">
|
||||
{templateCategories.map((category) => {
|
||||
const isAvailable =
|
||||
selectedDataset?.type === "image"
|
||||
? category === "Computer Vision"
|
||||
: category === "Natural Language Processing";
|
||||
return (
|
||||
<Button
|
||||
key={category}
|
||||
type={
|
||||
selectedCategory === category && isAvailable
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
block
|
||||
disabled={!isAvailable}
|
||||
onClick={() =>
|
||||
isAvailable && setSelectedCategory(category)
|
||||
}
|
||||
style={{ textAlign: "left", marginBottom: 8 }}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
自定义模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Template Grid */}
|
||||
<div className="flex-1 pl-6">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`border rounded-lg cursor-pointer transition-all hover:shadow-md ${
|
||||
formValues.templateId === template.id
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template.preview && (
|
||||
<div className="aspect-video bg-gray-100 rounded-t-lg overflow-hidden">
|
||||
<img
|
||||
src={template.preview || "/placeholder.svg"}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{template.icon}
|
||||
<span className="font-medium text-sm">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Custom Template Option */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg cursor-pointer transition-all hover:border-gray-400 ${
|
||||
selectedTemplate?.isCustom
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
onClick={() => setShowCustomTemplateDialog(true)}
|
||||
>
|
||||
<div className="aspect-video bg-gray-50 rounded-t-lg flex items-center justify-center">
|
||||
<PlusOutlined
|
||||
style={{ fontSize: 32, color: "#bbb" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<PlusOutlined />
|
||||
<span className="font-medium text-sm">
|
||||
自定义模板
|
||||
</span>
|
||||
</div>
|
||||
{selectedTemplate?.isCustom && (
|
||||
<CheckOutlined style={{ color: "#1677ff" }} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">
|
||||
创建符合特定需求的标注模板
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplate && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
已选择模板
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: "#1677ff", marginTop: 4 }}
|
||||
>
|
||||
{selectedTemplate.name} - {selectedTemplate.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end border-t border-gray-200 p-6">
|
||||
<Button onClick={() => navigate("/data/annotation")}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Template Dialog */}
|
||||
<CustomTemplateDialog
|
||||
open={showCustomTemplateDialog}
|
||||
onOpenChange={setShowCustomTemplateDialog}
|
||||
onSaveTemplate={handleSaveCustomTemplate}
|
||||
datasetType={selectedDataset?.type || "image"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,192 +1,192 @@
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,195 +1,195 @@
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import { Button, Form, Input, Modal, Select, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createAnnotationTaskUsingPost, queryAnnotationTemplatesUsingGet } from "../../annotation.api";
|
||||
import { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import type { AnnotationTemplate } from "../../annotation.model";
|
||||
|
||||
export default function CreateAnnotationTask({
|
||||
open,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch datasets
|
||||
const { data: datasetData } = await queryDatasetsUsingGet({
|
||||
page: 0,
|
||||
pageSize: 1000, // Use camelCase for HTTP params
|
||||
});
|
||||
setDatasets(datasetData.content.map(mapDataset) || []);
|
||||
|
||||
// Fetch templates
|
||||
const templateResponse = await queryAnnotationTemplatesUsingGet({
|
||||
page: 1,
|
||||
size: 100, // Backend max is 100 (template API uses 'size' not 'pageSize')
|
||||
});
|
||||
|
||||
// The API returns: {code, message, data: {content, total, page, ...}}
|
||||
if (templateResponse.code === 200 && templateResponse.data) {
|
||||
const fetchedTemplates = templateResponse.data.content || [];
|
||||
console.log("Fetched templates:", fetchedTemplates);
|
||||
setTemplates(fetchedTemplates);
|
||||
} else {
|
||||
console.error("Failed to fetch templates:", templateResponse);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setTemplates([]);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Reset form and manual-edit flag when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
setNameManuallyEdited(false);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// Send templateId instead of labelingConfig
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
datasetId: values.datasetId,
|
||||
templateId: values.templateId,
|
||||
};
|
||||
|
||||
await createAnnotationTaskUsingPost(requestData);
|
||||
message?.success?.("创建标注任务成功");
|
||||
onClose();
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error("Create annotation task failed", err);
|
||||
const msg = err?.message || err?.data?.message || "创建失败,请稍后重试";
|
||||
(message as any)?.error?.(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="创建标注任务"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 数据集 与 标注工程名称 并排显示(数据集在左) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="数据集"
|
||||
name="datasetId"
|
||||
rules={[{ required: true, message: "请选择数据集" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择数据集"
|
||||
options={datasets.map((dataset) => {
|
||||
return {
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex items-center font-sm text-gray-900">
|
||||
<span className="mr-2">{(dataset as any).icon}</span>
|
||||
<span>{dataset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{dataset.size}</div>
|
||||
</div>
|
||||
),
|
||||
value: dataset.id,
|
||||
};
|
||||
})}
|
||||
onChange={(value) => {
|
||||
// 如果用户未手动修改名称,则用数据集名称作为默认任务名
|
||||
if (!nameManuallyEdited) {
|
||||
const ds = datasets.find((d) => d.id === value);
|
||||
if (ds) {
|
||||
form.setFieldsValue({ name: ds.name });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注工程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入任务名称" }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入标注工程名称"
|
||||
onChange={() => setNameManuallyEdited(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 描述变为可选 */}
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea placeholder="(可选)详细描述标注任务的要求和目标" rows={3} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 标注模板选择 */}
|
||||
<Form.Item
|
||||
label="标注模板"
|
||||
name="templateId"
|
||||
rules={[{ required: true, message: "请选择标注模板" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={templates.length === 0 ? "暂无可用模板,请先创建模板" : "请选择标注模板"}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={templates.length === 0 ? "暂无模板,请前往「标注模板」页面创建" : "未找到匹配的模板"}
|
||||
options={templates.map((template) => ({
|
||||
label: template.name,
|
||||
value: template.id,
|
||||
// Add description as subtitle
|
||||
title: template.description,
|
||||
}))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{option.label}</div>
|
||||
{option.data.title && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 2 }}>
|
||||
{option.data.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,225 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Input,
|
||||
Card,
|
||||
message,
|
||||
Divider,
|
||||
Radio,
|
||||
Form,
|
||||
} from "antd";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BorderOutlined,
|
||||
DotChartOutlined,
|
||||
EditOutlined,
|
||||
CheckSquareOutlined,
|
||||
BarsOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
interface CustomTemplateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaveTemplate: (templateData: any) => void;
|
||||
datasetType: "text" | "image";
|
||||
}
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const defaultImageTemplate = `<View style="display: flex; flex-direction: column; height: 100vh; overflow: auto;">
|
||||
<View style="display: flex; height: 100%; gap: 10px;">
|
||||
<View style="height: 100%; width: 85%; display: flex; flex-direction: column; gap: 5px;">
|
||||
<Header value="WSI图像预览" />
|
||||
<View style="min-height: 100%;">
|
||||
<Image name="image" value="$image" zoom="true" />
|
||||
</View>
|
||||
</View>
|
||||
<View style="height: 100%; width: auto;">
|
||||
<View style="width: auto; display: flex;">
|
||||
<Text name="case_id_title" toName="image" value="病例号: $case_id" />
|
||||
</View>
|
||||
<Text name="part_title" toName="image" value="取材部位: $part" />
|
||||
<Header value="标注" />
|
||||
<View style="display: flex; gap: 5px;">
|
||||
<View>
|
||||
<Text name="cancer_or_not_title" value="是否有肿瘤" />
|
||||
<Choices name="cancer_or_not" toName="image">
|
||||
<Choice value="是" alias="1" />
|
||||
<Choice value="否" alias="0" />
|
||||
</Choices>
|
||||
<Text name="remark_title" value="备注" />
|
||||
<TextArea name="remark" toName="image" editable="true"/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>`;
|
||||
|
||||
const defaultTextTemplate = `<View style="display: flex; flex-direction: column; height: 100vh;">
|
||||
<Header value="文本标注界面" />
|
||||
<View style="display: flex; height: 100%; gap: 10px;">
|
||||
<View style="flex: 1; padding: 10px;">
|
||||
<Text name="content" value="$text" />
|
||||
<Labels name="label" toName="content">
|
||||
<Label value="正面" background="green" />
|
||||
<Label value="负面" background="red" />
|
||||
<Label value="中性" background="gray" />
|
||||
</Labels>
|
||||
</View>
|
||||
<View style="width: 300px; padding: 10px; border-left: 1px solid #ccc;">
|
||||
<Header value="标注选项" />
|
||||
<Text name="sentiment_title" value="情感分类" />
|
||||
<Choices name="sentiment" toName="content">
|
||||
<Choice value="正面" />
|
||||
<Choice value="负面" />
|
||||
<Choice value="中性" />
|
||||
</Choices>
|
||||
<Text name="confidence_title" value="置信度" />
|
||||
<Rating name="confidence" toName="content" maxRating="5" />
|
||||
<Text name="comment_title" value="备注" />
|
||||
<TextArea name="comment" toName="content" placeholder="添加备注..." />
|
||||
</View>
|
||||
</View>
|
||||
</View>`;
|
||||
|
||||
const annotationTools = [
|
||||
{ id: "rectangle", label: "矩形框", icon: <BorderOutlined />, type: "image" },
|
||||
{
|
||||
id: "polygon",
|
||||
label: "多边形",
|
||||
icon: <DeploymentUnitOutlined />,
|
||||
type: "image",
|
||||
},
|
||||
{ id: "circle", label: "圆形", icon: <DotChartOutlined />, type: "image" },
|
||||
{ id: "point", label: "关键点", icon: <AppstoreOutlined />, type: "image" },
|
||||
{ id: "text", label: "文本", icon: <EditOutlined />, type: "both" },
|
||||
{ id: "choices", label: "选择题", icon: <BarsOutlined />, type: "both" },
|
||||
{
|
||||
id: "checkbox",
|
||||
label: "多选框",
|
||||
icon: <CheckSquareOutlined />,
|
||||
type: "both",
|
||||
},
|
||||
{ id: "textarea", label: "文本域", icon: <BarsOutlined />, type: "both" },
|
||||
];
|
||||
|
||||
export default function CustomTemplateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaveTemplate,
|
||||
datasetType,
|
||||
}: CustomTemplateDialogProps) {
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
const [templateDescription, setTemplateDescription] = useState("");
|
||||
const [templateCode, setTemplateCode] = useState(
|
||||
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!templateName.trim()) {
|
||||
message.error("请输入模板名称");
|
||||
return;
|
||||
}
|
||||
if (!templateCode.trim()) {
|
||||
message.error("请输入模板代码");
|
||||
return;
|
||||
}
|
||||
const templateData = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: templateName,
|
||||
description: templateDescription,
|
||||
code: templateCode,
|
||||
type: datasetType,
|
||||
isCustom: true,
|
||||
};
|
||||
onSaveTemplate(templateData);
|
||||
onOpenChange(false);
|
||||
message.success("自定义模板已保存");
|
||||
setTemplateName("");
|
||||
setTemplateDescription("");
|
||||
setTemplateCode(
|
||||
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
okText={"保存模板"}
|
||||
onOk={handleSave}
|
||||
width={1200}
|
||||
className="max-h-[80vh] overflow-auto"
|
||||
title="自定义标注模板"
|
||||
>
|
||||
<div className="flex min-h-[500px]">
|
||||
<div className="flex-1 pl-6">
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="模板名称 *" required>
|
||||
<Input
|
||||
placeholder="输入模板名称"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="模板描述">
|
||||
<Input
|
||||
placeholder="输入模板描述"
|
||||
value={templateDescription}
|
||||
onChange={(e) => setTemplateDescription(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 font-medium">代码</div>
|
||||
<Card>
|
||||
<TextArea
|
||||
rows={20}
|
||||
value={templateCode}
|
||||
onChange={(e) => setTemplateCode(e.target.value)}
|
||||
placeholder="输入模板代码"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-96 border-l border-gray-100 pl-6">
|
||||
<div className="mb-2 font-medium">预览</div>
|
||||
<Card
|
||||
cover={
|
||||
<img
|
||||
alt="预览图像"
|
||||
src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_9b855efe-ce37-4387-a845-d8ef9aaa1a8g.jpg-GhkhlenJlzOQLSDqyBm2iaC6jbv7VA.jpeg"
|
||||
className="object-cover h-48"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500">病例号:</span>
|
||||
<span>undefined</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500">取材部位:</span>
|
||||
<span>undefined</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<div className="font-medium mb-2">标注</div>
|
||||
<div className="mb-2 text-gray-500">是否有肿瘤</div>
|
||||
<Radio.Group>
|
||||
<Radio value="1">是[1]</Radio>
|
||||
<Radio value="0">否[2]</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4">
|
||||
<div className="text-gray-500 mb-1">备注</div>
|
||||
<TextArea rows={3} placeholder="添加备注..." />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Input,
|
||||
Card,
|
||||
message,
|
||||
Divider,
|
||||
Radio,
|
||||
Form,
|
||||
} from "antd";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BorderOutlined,
|
||||
DotChartOutlined,
|
||||
EditOutlined,
|
||||
CheckSquareOutlined,
|
||||
BarsOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
interface CustomTemplateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaveTemplate: (templateData: any) => void;
|
||||
datasetType: "text" | "image";
|
||||
}
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const defaultImageTemplate = `<View style="display: flex; flex-direction: column; height: 100vh; overflow: auto;">
|
||||
<View style="display: flex; height: 100%; gap: 10px;">
|
||||
<View style="height: 100%; width: 85%; display: flex; flex-direction: column; gap: 5px;">
|
||||
<Header value="WSI图像预览" />
|
||||
<View style="min-height: 100%;">
|
||||
<Image name="image" value="$image" zoom="true" />
|
||||
</View>
|
||||
</View>
|
||||
<View style="height: 100%; width: auto;">
|
||||
<View style="width: auto; display: flex;">
|
||||
<Text name="case_id_title" toName="image" value="病例号: $case_id" />
|
||||
</View>
|
||||
<Text name="part_title" toName="image" value="取材部位: $part" />
|
||||
<Header value="标注" />
|
||||
<View style="display: flex; gap: 5px;">
|
||||
<View>
|
||||
<Text name="cancer_or_not_title" value="是否有肿瘤" />
|
||||
<Choices name="cancer_or_not" toName="image">
|
||||
<Choice value="是" alias="1" />
|
||||
<Choice value="否" alias="0" />
|
||||
</Choices>
|
||||
<Text name="remark_title" value="备注" />
|
||||
<TextArea name="remark" toName="image" editable="true"/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>`;
|
||||
|
||||
const defaultTextTemplate = `<View style="display: flex; flex-direction: column; height: 100vh;">
|
||||
<Header value="文本标注界面" />
|
||||
<View style="display: flex; height: 100%; gap: 10px;">
|
||||
<View style="flex: 1; padding: 10px;">
|
||||
<Text name="content" value="$text" />
|
||||
<Labels name="label" toName="content">
|
||||
<Label value="正面" background="green" />
|
||||
<Label value="负面" background="red" />
|
||||
<Label value="中性" background="gray" />
|
||||
</Labels>
|
||||
</View>
|
||||
<View style="width: 300px; padding: 10px; border-left: 1px solid #ccc;">
|
||||
<Header value="标注选项" />
|
||||
<Text name="sentiment_title" value="情感分类" />
|
||||
<Choices name="sentiment" toName="content">
|
||||
<Choice value="正面" />
|
||||
<Choice value="负面" />
|
||||
<Choice value="中性" />
|
||||
</Choices>
|
||||
<Text name="confidence_title" value="置信度" />
|
||||
<Rating name="confidence" toName="content" maxRating="5" />
|
||||
<Text name="comment_title" value="备注" />
|
||||
<TextArea name="comment" toName="content" placeholder="添加备注..." />
|
||||
</View>
|
||||
</View>
|
||||
</View>`;
|
||||
|
||||
const annotationTools = [
|
||||
{ id: "rectangle", label: "矩形框", icon: <BorderOutlined />, type: "image" },
|
||||
{
|
||||
id: "polygon",
|
||||
label: "多边形",
|
||||
icon: <DeploymentUnitOutlined />,
|
||||
type: "image",
|
||||
},
|
||||
{ id: "circle", label: "圆形", icon: <DotChartOutlined />, type: "image" },
|
||||
{ id: "point", label: "关键点", icon: <AppstoreOutlined />, type: "image" },
|
||||
{ id: "text", label: "文本", icon: <EditOutlined />, type: "both" },
|
||||
{ id: "choices", label: "选择题", icon: <BarsOutlined />, type: "both" },
|
||||
{
|
||||
id: "checkbox",
|
||||
label: "多选框",
|
||||
icon: <CheckSquareOutlined />,
|
||||
type: "both",
|
||||
},
|
||||
{ id: "textarea", label: "文本域", icon: <BarsOutlined />, type: "both" },
|
||||
];
|
||||
|
||||
export default function CustomTemplateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaveTemplate,
|
||||
datasetType,
|
||||
}: CustomTemplateDialogProps) {
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
const [templateDescription, setTemplateDescription] = useState("");
|
||||
const [templateCode, setTemplateCode] = useState(
|
||||
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!templateName.trim()) {
|
||||
message.error("请输入模板名称");
|
||||
return;
|
||||
}
|
||||
if (!templateCode.trim()) {
|
||||
message.error("请输入模板代码");
|
||||
return;
|
||||
}
|
||||
const templateData = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: templateName,
|
||||
description: templateDescription,
|
||||
code: templateCode,
|
||||
type: datasetType,
|
||||
isCustom: true,
|
||||
};
|
||||
onSaveTemplate(templateData);
|
||||
onOpenChange(false);
|
||||
message.success("自定义模板已保存");
|
||||
setTemplateName("");
|
||||
setTemplateDescription("");
|
||||
setTemplateCode(
|
||||
datasetType === "image" ? defaultImageTemplate : defaultTextTemplate
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
okText={"保存模板"}
|
||||
onOk={handleSave}
|
||||
width={1200}
|
||||
className="max-h-[80vh] overflow-auto"
|
||||
title="自定义标注模板"
|
||||
>
|
||||
<div className="flex min-h-[500px]">
|
||||
<div className="flex-1 pl-6">
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="模板名称 *" required>
|
||||
<Input
|
||||
placeholder="输入模板名称"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="模板描述">
|
||||
<Input
|
||||
placeholder="输入模板描述"
|
||||
value={templateDescription}
|
||||
onChange={(e) => setTemplateDescription(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 font-medium">代码</div>
|
||||
<Card>
|
||||
<TextArea
|
||||
rows={20}
|
||||
value={templateCode}
|
||||
onChange={(e) => setTemplateCode(e.target.value)}
|
||||
placeholder="输入模板代码"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-96 border-l border-gray-100 pl-6">
|
||||
<div className="mb-2 font-medium">预览</div>
|
||||
<Card
|
||||
cover={
|
||||
<img
|
||||
alt="预览图像"
|
||||
src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_9b855efe-ce37-4387-a845-d8ef9aaa1a8g.jpg-GhkhlenJlzOQLSDqyBm2iaC6jbv7VA.jpeg"
|
||||
className="object-cover h-48"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500">病例号:</span>
|
||||
<span>undefined</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-gray-500">取材部位:</span>
|
||||
<span>undefined</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div>
|
||||
<div className="font-medium mb-2">标注</div>
|
||||
<div className="mb-2 text-gray-500">是否有肿瘤</div>
|
||||
<Radio.Group>
|
||||
<Radio value="1">是[1]</Radio>
|
||||
<Radio value="0">否[2]</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4">
|
||||
<div className="text-gray-500 mb-1">备注</div>
|
||||
<TextArea rows={3} placeholder="添加备注..." />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
import { Button, Card, Input, InputNumber, Popconfirm, Select, Switch, Tooltip } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { useState, useImperativeHandle, forwardRef } from "react";
|
||||
|
||||
type LabelType = "string" | "number" | "enum";
|
||||
|
||||
type LabelItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: LabelType;
|
||||
required?: boolean;
|
||||
// for enum: values; for number: min/max
|
||||
values?: string[];
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
step?: number | null;
|
||||
};
|
||||
|
||||
type LabelingConfigEditorProps = {
|
||||
initial?: any;
|
||||
onGenerate: (config: any) => void;
|
||||
hideFooter?: boolean;
|
||||
};
|
||||
|
||||
export default forwardRef<any, LabelingConfigEditorProps>(function LabelingConfigEditor(
|
||||
{ initial, onGenerate, hideFooter }: LabelingConfigEditorProps,
|
||||
ref: any
|
||||
) {
|
||||
const [labels, setLabels] = useState<LabelItem[]>(() => {
|
||||
if (initial && initial.labels && Array.isArray(initial.labels)) {
|
||||
return initial.labels.map((l: any, idx: number) => ({
|
||||
id: `${Date.now()}-${idx}`,
|
||||
name: l.name || "",
|
||||
type: l.type || "string",
|
||||
required: !!l.required,
|
||||
values: l.values || (l.type === "enum" ? [] : undefined),
|
||||
min: l.min ?? null,
|
||||
max: l.max ?? null,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const addLabel = () => {
|
||||
setLabels((s) => [
|
||||
...s,
|
||||
{ id: `${Date.now()}-${Math.random()}`, name: "", type: "string", required: false, step: null },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateLabel = (id: string, patch: Partial<LabelItem>) => {
|
||||
setLabels((s) => s.map((l) => (l.id === id ? { ...l, ...patch } : l)));
|
||||
};
|
||||
|
||||
const removeLabel = (id: string) => {
|
||||
setLabels((s) => s.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
// basic validation: label name non-empty
|
||||
for (const l of labels) {
|
||||
if (!l.name || l.name.trim() === "") {
|
||||
// focus not available here, just abort
|
||||
// Could show a more friendly UI; keep simple for now
|
||||
// eslint-disable-next-line no-alert
|
||||
alert("请为所有标签填写名称");
|
||||
return;
|
||||
}
|
||||
if (l.type === "enum") {
|
||||
if (!l.values || l.values.length === 0) {
|
||||
alert(`枚举标签 ${l.name} 需要至少一个取值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (l.type === "number") {
|
||||
// validate min/max
|
||||
if (l.min != null && l.max != null && l.min > l.max) {
|
||||
alert(`数值标签 ${l.name} 的最小值不能大于最大值`);
|
||||
return;
|
||||
}
|
||||
// validate step
|
||||
if (l.step != null && (!(typeof l.step === "number") || l.step <= 0)) {
|
||||
alert(`数值标签 ${l.name} 的步长必须为大于 0 的数值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
labels: labels.map((l) => {
|
||||
const item: any = { name: l.name, type: l.type, required: !!l.required };
|
||||
if (l.type === "enum") item.values = l.values || [];
|
||||
if (l.type === "number") {
|
||||
if (l.min != null) item.min = l.min;
|
||||
if (l.max != null) item.max = l.max;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
|
||||
onGenerate(config);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addLabel,
|
||||
generate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{labels.map((label) => (
|
||||
<Card size="small" key={label.id} styles={{ body: { padding: 10 } }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||
<Input
|
||||
placeholder="标签名称"
|
||||
value={label.name}
|
||||
onChange={(e) => updateLabel(label.id, { name: e.target.value })}
|
||||
style={{ flex: "1 1 160px", minWidth: 120 }}
|
||||
/>
|
||||
<Select
|
||||
value={label.type}
|
||||
onChange={(v) => updateLabel(label.id, { type: v as LabelType })}
|
||||
options={[{ label: "文本", value: "string" }, { label: "数值", value: "number" }, { label: "枚举", value: "enum" }]}
|
||||
style={{ width: 120, flex: "0 0 120px" }}
|
||||
/>
|
||||
|
||||
{label.type === "enum" && (
|
||||
<Input.TextArea
|
||||
placeholder="每行一个枚举值,按回车换行"
|
||||
value={(label.values || []).join("\n")}
|
||||
onChange={(e) => updateLabel(label.id, { values: e.target.value.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) })}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent parent handlers (like Form submit or modal shortcuts) from intercepting Enter
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rows={3}
|
||||
style={{ flex: "1 1 220px", minWidth: 160, width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{label.type === "number" && (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flex: "0 0 auto" }}>
|
||||
<Tooltip title="最小值">
|
||||
<InputNumber value={label.min ?? null} onChange={(v) => updateLabel(label.id, { min: v ?? null })} placeholder="min" />
|
||||
</Tooltip>
|
||||
<Tooltip title="最大值">
|
||||
<InputNumber value={label.max ?? null} onChange={(v) => updateLabel(label.id, { max: v ?? null })} placeholder="max" />
|
||||
</Tooltip>
|
||||
<Tooltip title="步长 (step)">
|
||||
<InputNumber value={label.step ?? null} onChange={(v) => updateLabel(label.id, { step: v ?? null })} placeholder="step" min={0} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginLeft: "auto" }}>
|
||||
<span style={{ fontSize: 12, color: "rgba(0,0,0,0.65)" }}>必填</span>
|
||||
<Switch checked={!!label.required} onChange={(v) => updateLabel(label.id, { required: v })} />
|
||||
<Popconfirm title="确认删除该标签?" onConfirm={() => removeLabel(label.id)}>
|
||||
<Button type="text" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: "rgba(0,0,0,0.45)", fontSize: 12 }}>
|
||||
{label.type === "string" && <span>类型:文本</span>}
|
||||
{label.type === "number" && <span>类型:数值,支持 min / max / step</span>}
|
||||
{label.type === "enum" && <span>类型:枚举,每行一个取值(示例:一行写一个值)</span>}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!hideFooter && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button icon={<PlusOutlined />} onClick={addLabel}>
|
||||
添加标签
|
||||
</Button>
|
||||
<Button type="primary" onClick={generate}>
|
||||
生成 JSON 配置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
);
|
||||
import { Button, Card, Input, InputNumber, Popconfirm, Select, Switch, Tooltip } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { useState, useImperativeHandle, forwardRef } from "react";
|
||||
|
||||
type LabelType = "string" | "number" | "enum";
|
||||
|
||||
type LabelItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: LabelType;
|
||||
required?: boolean;
|
||||
// for enum: values; for number: min/max
|
||||
values?: string[];
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
step?: number | null;
|
||||
};
|
||||
|
||||
type LabelingConfigEditorProps = {
|
||||
initial?: any;
|
||||
onGenerate: (config: any) => void;
|
||||
hideFooter?: boolean;
|
||||
};
|
||||
|
||||
export default forwardRef<any, LabelingConfigEditorProps>(function LabelingConfigEditor(
|
||||
{ initial, onGenerate, hideFooter }: LabelingConfigEditorProps,
|
||||
ref: any
|
||||
) {
|
||||
const [labels, setLabels] = useState<LabelItem[]>(() => {
|
||||
if (initial && initial.labels && Array.isArray(initial.labels)) {
|
||||
return initial.labels.map((l: any, idx: number) => ({
|
||||
id: `${Date.now()}-${idx}`,
|
||||
name: l.name || "",
|
||||
type: l.type || "string",
|
||||
required: !!l.required,
|
||||
values: l.values || (l.type === "enum" ? [] : undefined),
|
||||
min: l.min ?? null,
|
||||
max: l.max ?? null,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const addLabel = () => {
|
||||
setLabels((s) => [
|
||||
...s,
|
||||
{ id: `${Date.now()}-${Math.random()}`, name: "", type: "string", required: false, step: null },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateLabel = (id: string, patch: Partial<LabelItem>) => {
|
||||
setLabels((s) => s.map((l) => (l.id === id ? { ...l, ...patch } : l)));
|
||||
};
|
||||
|
||||
const removeLabel = (id: string) => {
|
||||
setLabels((s) => s.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
// basic validation: label name non-empty
|
||||
for (const l of labels) {
|
||||
if (!l.name || l.name.trim() === "") {
|
||||
// focus not available here, just abort
|
||||
// Could show a more friendly UI; keep simple for now
|
||||
// eslint-disable-next-line no-alert
|
||||
alert("请为所有标签填写名称");
|
||||
return;
|
||||
}
|
||||
if (l.type === "enum") {
|
||||
if (!l.values || l.values.length === 0) {
|
||||
alert(`枚举标签 ${l.name} 需要至少一个取值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (l.type === "number") {
|
||||
// validate min/max
|
||||
if (l.min != null && l.max != null && l.min > l.max) {
|
||||
alert(`数值标签 ${l.name} 的最小值不能大于最大值`);
|
||||
return;
|
||||
}
|
||||
// validate step
|
||||
if (l.step != null && (!(typeof l.step === "number") || l.step <= 0)) {
|
||||
alert(`数值标签 ${l.name} 的步长必须为大于 0 的数值`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
labels: labels.map((l) => {
|
||||
const item: any = { name: l.name, type: l.type, required: !!l.required };
|
||||
if (l.type === "enum") item.values = l.values || [];
|
||||
if (l.type === "number") {
|
||||
if (l.min != null) item.min = l.min;
|
||||
if (l.max != null) item.max = l.max;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
|
||||
onGenerate(config);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addLabel,
|
||||
generate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{labels.map((label) => (
|
||||
<Card size="small" key={label.id} styles={{ body: { padding: 10 } }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||
<Input
|
||||
placeholder="标签名称"
|
||||
value={label.name}
|
||||
onChange={(e) => updateLabel(label.id, { name: e.target.value })}
|
||||
style={{ flex: "1 1 160px", minWidth: 120 }}
|
||||
/>
|
||||
<Select
|
||||
value={label.type}
|
||||
onChange={(v) => updateLabel(label.id, { type: v as LabelType })}
|
||||
options={[{ label: "文本", value: "string" }, { label: "数值", value: "number" }, { label: "枚举", value: "enum" }]}
|
||||
style={{ width: 120, flex: "0 0 120px" }}
|
||||
/>
|
||||
|
||||
{label.type === "enum" && (
|
||||
<Input.TextArea
|
||||
placeholder="每行一个枚举值,按回车换行"
|
||||
value={(label.values || []).join("\n")}
|
||||
onChange={(e) => updateLabel(label.id, { values: e.target.value.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) })}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent parent handlers (like Form submit or modal shortcuts) from intercepting Enter
|
||||
e.stopPropagation();
|
||||
}}
|
||||
rows={3}
|
||||
style={{ flex: "1 1 220px", minWidth: 160, width: "100%", resize: "vertical" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{label.type === "number" && (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flex: "0 0 auto" }}>
|
||||
<Tooltip title="最小值">
|
||||
<InputNumber value={label.min ?? null} onChange={(v) => updateLabel(label.id, { min: v ?? null })} placeholder="min" />
|
||||
</Tooltip>
|
||||
<Tooltip title="最大值">
|
||||
<InputNumber value={label.max ?? null} onChange={(v) => updateLabel(label.id, { max: v ?? null })} placeholder="max" />
|
||||
</Tooltip>
|
||||
<Tooltip title="步长 (step)">
|
||||
<InputNumber value={label.step ?? null} onChange={(v) => updateLabel(label.id, { step: v ?? null })} placeholder="step" min={0} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginLeft: "auto" }}>
|
||||
<span style={{ fontSize: 12, color: "rgba(0,0,0,0.65)" }}>必填</span>
|
||||
<Switch checked={!!label.required} onChange={(v) => updateLabel(label.id, { required: v })} />
|
||||
<Popconfirm title="确认删除该标签?" onConfirm={() => removeLabel(label.id)}>
|
||||
<Button type="text" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: "rgba(0,0,0,0.45)", fontSize: 12 }}>
|
||||
{label.type === "string" && <span>类型:文本</span>}
|
||||
{label.type === "number" && <span>类型:数值,支持 min / max / step</span>}
|
||||
{label.type === "enum" && <span>类型:枚举,每行一个取值(示例:一行写一个值)</span>}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!hideFooter && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button icon={<PlusOutlined />} onClick={addLabel}>
|
||||
添加标签
|
||||
</Button>
|
||||
<Button type="primary" onClick={generate}>
|
||||
生成 JSON 配置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
@@ -1,398 +1,398 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Table, message, Modal, Tabs } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import type { AnnotationTask } from "../annotation.model";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import {
|
||||
deleteAnnotationTaskByIdUsingDelete, loginAnnotationUsingGet,
|
||||
queryAnnotationTasksUsingGet,
|
||||
syncAnnotationTaskUsingPost,
|
||||
} from "../annotation.api";
|
||||
import { mapAnnotationTask } from "../annotation.const";
|
||||
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
|
||||
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 [activeTab, setActiveTab] = useState("tasks");
|
||||
const [viewMode, setViewMode] = useState<"list" | "card">("list");
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
handleKeywordChange,
|
||||
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
|
||||
|
||||
const [labelStudioBase, setLabelStudioBase] = useState<string | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
|
||||
// prefetch config on mount so clicking annotate is fast and we know whether base URL exists
|
||||
// useEffect ensures this runs once
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const baseUrl = `http://${window.location.hostname}:${parseInt(window.location.port) + 1}`;
|
||||
if (mounted) setLabelStudioBase(baseUrl);
|
||||
} catch (e) {
|
||||
if (mounted) setLabelStudioBase(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAnnotate = (task: AnnotationTask) => {
|
||||
// Open Label Studio project page in a new tab
|
||||
(async () => {
|
||||
try {
|
||||
// prefer using labeling project id already present on the task
|
||||
// `mapAnnotationTask` normalizes upstream fields into `labelingProjId`/`projId`,
|
||||
// so prefer those and fall back to the task id if necessary.
|
||||
let labelingProjId = (task as any).labelingProjId || (task as any).projId || undefined;
|
||||
|
||||
// no fallback external mapping lookup; rely on normalized fields from mapAnnotationTask
|
||||
|
||||
// use prefetched base if available
|
||||
const base = labelStudioBase;
|
||||
|
||||
// no debug logging in production
|
||||
|
||||
if (labelingProjId) {
|
||||
// only open external Label Studio when we have a configured base url
|
||||
await loginAnnotationUsingGet(labelingProjId)
|
||||
if (base) {
|
||||
const target = `${base}/projects/${labelingProjId}/data`;
|
||||
window.open(target, "_blank");
|
||||
} else {
|
||||
// no external Label Studio URL configured — do not perform internal redirect in this version
|
||||
message.error("无法跳转到 Label Studio:未配置 Label Studio 基础 URL");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// no labeling project id available — do not attempt internal redirect in this version
|
||||
message.error("无法跳转到 Label Studio:该映射未绑定标注项目");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// on error, surface a user-friendly message instead of redirecting
|
||||
message.error("无法跳转到 Label Studio:发生错误,请检查配置或控制台日志");
|
||||
return;
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleDelete = (task: AnnotationTask) => {
|
||||
Modal.confirm({
|
||||
title: `确认删除标注任务「${task.name}」吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>删除标注任务不会删除对应数据集。</div>
|
||||
<div>如需保留当前标注结果,请在同步后再删除。</div>
|
||||
</div>
|
||||
),
|
||||
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 handleSync = (task: AnnotationTask, batchSize: number = 50) => {
|
||||
Modal.confirm({
|
||||
title: `确认同步标注任务「${task.name}」吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>标注工程中文件列表将与数据集保持一致,差异项将会被修正。</div>
|
||||
<div>标注工程中的标签与数据集中标签将进行合并,冲突项将以最新一次内容为准。</div>
|
||||
</div>
|
||||
),
|
||||
okText: "同步",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await syncAnnotationTaskUsingPost({ id: task.id, batchSize });
|
||||
message.success("任务同步请求已发送");
|
||||
// optional: refresh list/status
|
||||
fetchData();
|
||||
// clear selection for the task
|
||||
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 handleBatchSync = (batchSize: number = 50) => {
|
||||
if (!selectedRows || selectedRows.length === 0) return;
|
||||
Modal.confirm({
|
||||
title: `确认同步所选 ${selectedRows.length} 个标注任务吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>标注工程中文件列表将与数据集保持一致,差异项将会被修正。</div>
|
||||
<div>标注工程中的标签与数据集中标签将进行合并,冲突项将以最新一次内容为准。</div>
|
||||
</div>
|
||||
),
|
||||
okText: "同步",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedRows.map((r) => syncAnnotationTaskUsingPost({ id: r.id, batchSize }))
|
||||
);
|
||||
message.success("批量同步请求已发送");
|
||||
fetchData();
|
||||
setSelectedRowKeys([]);
|
||||
setSelectedRows([]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error("批量同步失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (!selectedRows || selectedRows.length === 0) return;
|
||||
Modal.confirm({
|
||||
title: `确认删除所选 ${selectedRows.length} 个标注任务吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>删除标注任务不会删除对应数据集。</div>
|
||||
<div>如需保留当前标注结果,请在同步后再删除。</div>
|
||||
</div>
|
||||
),
|
||||
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: "sync",
|
||||
label: "同步",
|
||||
icon: <SyncOutlined className="w-4 h-4" style={{ color: "#722ed1" }} />,
|
||||
onClick: handleSync,
|
||||
},
|
||||
{
|
||||
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
|
||||
onClick={() => handleBatchSync(50)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量同步
|
||||
</Button>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "标注模板",
|
||||
children: <TemplateList />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Table, message, Modal, Tabs } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { SearchControls } from "@/components/SearchControls";
|
||||
import CardView from "@/components/CardView";
|
||||
import type { AnnotationTask } from "../annotation.model";
|
||||
import useFetchData from "@/hooks/useFetchData";
|
||||
import {
|
||||
deleteAnnotationTaskByIdUsingDelete, loginAnnotationUsingGet,
|
||||
queryAnnotationTasksUsingGet,
|
||||
syncAnnotationTaskUsingPost,
|
||||
} from "../annotation.api";
|
||||
import { mapAnnotationTask } from "../annotation.const";
|
||||
import CreateAnnotationTask from "../Create/components/CreateAnnotationTaskDialog";
|
||||
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 [activeTab, setActiveTab] = useState("tasks");
|
||||
const [viewMode, setViewMode] = useState<"list" | "card">("list");
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
handleKeywordChange,
|
||||
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask, 30000, true, [], 0);
|
||||
|
||||
const [labelStudioBase, setLabelStudioBase] = useState<string | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
|
||||
// prefetch config on mount so clicking annotate is fast and we know whether base URL exists
|
||||
// useEffect ensures this runs once
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const baseUrl = `http://${window.location.hostname}:${parseInt(window.location.port) + 1}`;
|
||||
if (mounted) setLabelStudioBase(baseUrl);
|
||||
} catch (e) {
|
||||
if (mounted) setLabelStudioBase(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAnnotate = (task: AnnotationTask) => {
|
||||
// Open Label Studio project page in a new tab
|
||||
(async () => {
|
||||
try {
|
||||
// prefer using labeling project id already present on the task
|
||||
// `mapAnnotationTask` normalizes upstream fields into `labelingProjId`/`projId`,
|
||||
// so prefer those and fall back to the task id if necessary.
|
||||
let labelingProjId = (task as any).labelingProjId || (task as any).projId || undefined;
|
||||
|
||||
// no fallback external mapping lookup; rely on normalized fields from mapAnnotationTask
|
||||
|
||||
// use prefetched base if available
|
||||
const base = labelStudioBase;
|
||||
|
||||
// no debug logging in production
|
||||
|
||||
if (labelingProjId) {
|
||||
// only open external Label Studio when we have a configured base url
|
||||
await loginAnnotationUsingGet(labelingProjId)
|
||||
if (base) {
|
||||
const target = `${base}/projects/${labelingProjId}/data`;
|
||||
window.open(target, "_blank");
|
||||
} else {
|
||||
// no external Label Studio URL configured — do not perform internal redirect in this version
|
||||
message.error("无法跳转到 Label Studio:未配置 Label Studio 基础 URL");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// no labeling project id available — do not attempt internal redirect in this version
|
||||
message.error("无法跳转到 Label Studio:该映射未绑定标注项目");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// on error, surface a user-friendly message instead of redirecting
|
||||
message.error("无法跳转到 Label Studio:发生错误,请检查配置或控制台日志");
|
||||
return;
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleDelete = (task: AnnotationTask) => {
|
||||
Modal.confirm({
|
||||
title: `确认删除标注任务「${task.name}」吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>删除标注任务不会删除对应数据集。</div>
|
||||
<div>如需保留当前标注结果,请在同步后再删除。</div>
|
||||
</div>
|
||||
),
|
||||
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 handleSync = (task: AnnotationTask, batchSize: number = 50) => {
|
||||
Modal.confirm({
|
||||
title: `确认同步标注任务「${task.name}」吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>标注工程中文件列表将与数据集保持一致,差异项将会被修正。</div>
|
||||
<div>标注工程中的标签与数据集中标签将进行合并,冲突项将以最新一次内容为准。</div>
|
||||
</div>
|
||||
),
|
||||
okText: "同步",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await syncAnnotationTaskUsingPost({ id: task.id, batchSize });
|
||||
message.success("任务同步请求已发送");
|
||||
// optional: refresh list/status
|
||||
fetchData();
|
||||
// clear selection for the task
|
||||
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 handleBatchSync = (batchSize: number = 50) => {
|
||||
if (!selectedRows || selectedRows.length === 0) return;
|
||||
Modal.confirm({
|
||||
title: `确认同步所选 ${selectedRows.length} 个标注任务吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>标注工程中文件列表将与数据集保持一致,差异项将会被修正。</div>
|
||||
<div>标注工程中的标签与数据集中标签将进行合并,冲突项将以最新一次内容为准。</div>
|
||||
</div>
|
||||
),
|
||||
okText: "同步",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedRows.map((r) => syncAnnotationTaskUsingPost({ id: r.id, batchSize }))
|
||||
);
|
||||
message.success("批量同步请求已发送");
|
||||
fetchData();
|
||||
setSelectedRowKeys([]);
|
||||
setSelectedRows([]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error("批量同步失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (!selectedRows || selectedRows.length === 0) return;
|
||||
Modal.confirm({
|
||||
title: `确认删除所选 ${selectedRows.length} 个标注任务吗?`,
|
||||
content: (
|
||||
<div>
|
||||
<div>删除标注任务不会删除对应数据集。</div>
|
||||
<div>如需保留当前标注结果,请在同步后再删除。</div>
|
||||
</div>
|
||||
),
|
||||
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: "sync",
|
||||
label: "同步",
|
||||
icon: <SyncOutlined className="w-4 h-4" style={{ color: "#722ed1" }} />,
|
||||
onClick: handleSync,
|
||||
},
|
||||
{
|
||||
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
|
||||
onClick={() => handleBatchSync(50)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量同步
|
||||
</Button>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "标注模板",
|
||||
children: <TemplateList />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,155 +1,155 @@
|
||||
import React from "react";
|
||||
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface TemplateDetailProps {
|
||||
visible: boolean;
|
||||
template?: AnnotationTemplate;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TemplateDetail: React.FC<TemplateDetailProps> = ({
|
||||
visible,
|
||||
template,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!template) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="模板详情"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="名称" span={2}>
|
||||
{template.name}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>
|
||||
{template.description || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="数据类型">
|
||||
<Tag color="cyan">{template.dataType}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标注类型">
|
||||
<Tag color="geekblue">{template.labelingType}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">
|
||||
<Tag color="blue">{template.category}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="样式">
|
||||
{template.style}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
<Tag color={template.builtIn ? "gold" : "default"}>
|
||||
{template.builtIn ? "系统内置" : "自定义"}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">
|
||||
{template.version}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间" span={2}>
|
||||
{new Date(template.createdAt).toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
{template.updatedAt && (
|
||||
<Descriptions.Item label="更新时间" span={2}>
|
||||
{new Date(template.updatedAt).toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Divider>配置详情</Divider>
|
||||
|
||||
<Card title="数据对象" size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{template.configuration.objects.map((obj, index) => (
|
||||
<Card key={index} size="small" type="inner">
|
||||
<Space>
|
||||
<Text strong>名称:</Text>
|
||||
<Tag>{obj.name}</Tag>
|
||||
<Text strong>类型:</Text>
|
||||
<Tag color="blue">{obj.type}</Tag>
|
||||
<Text strong>值:</Text>
|
||||
<Tag color="green">{obj.value}</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="标注控件" size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
{template.configuration.labels.map((label, index) => (
|
||||
<Card key={index} size="small" type="inner" title={`控件 ${index + 1}`}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Text strong>来源名称:</Text>
|
||||
<Tag>{label.fromName}</Tag>
|
||||
|
||||
<Text strong style={{ marginLeft: 16 }}>目标名称:</Text>
|
||||
<Tag>{label.toName}</Tag>
|
||||
|
||||
<Text strong style={{ marginLeft: 16 }}>类型:</Text>
|
||||
<Tag color="purple">{label.type}</Tag>
|
||||
|
||||
{label.required && <Tag color="red">必填</Tag>}
|
||||
</div>
|
||||
|
||||
{label.description && (
|
||||
<div>
|
||||
<Text strong>描述:</Text>
|
||||
<Text type="secondary">{label.description}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label.options && label.options.length > 0 && (
|
||||
<div>
|
||||
<Text strong>选项:</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{label.options.map((opt, i) => (
|
||||
<Tag key={i} color="cyan">{opt}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label.labels && label.labels.length > 0 && (
|
||||
<div>
|
||||
<Text strong>标签:</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{label.labels.map((lbl, i) => (
|
||||
<Tag key={i} color="geekblue">{lbl}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{template.labelConfig && (
|
||||
<Card title="Label Studio XML 配置" size="small">
|
||||
<Paragraph>
|
||||
<pre style={{
|
||||
background: "#f5f5f5",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
overflow: "auto",
|
||||
maxHeight: 300
|
||||
}}>
|
||||
{template.labelConfig}
|
||||
</pre>
|
||||
</Paragraph>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDetail;
|
||||
import React from "react";
|
||||
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface TemplateDetailProps {
|
||||
visible: boolean;
|
||||
template?: AnnotationTemplate;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TemplateDetail: React.FC<TemplateDetailProps> = ({
|
||||
visible,
|
||||
template,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!template) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="模板详情"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="名称" span={2}>
|
||||
{template.name}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>
|
||||
{template.description || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="数据类型">
|
||||
<Tag color="cyan">{template.dataType}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标注类型">
|
||||
<Tag color="geekblue">{template.labelingType}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">
|
||||
<Tag color="blue">{template.category}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="样式">
|
||||
{template.style}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
<Tag color={template.builtIn ? "gold" : "default"}>
|
||||
{template.builtIn ? "系统内置" : "自定义"}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">
|
||||
{template.version}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间" span={2}>
|
||||
{new Date(template.createdAt).toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
{template.updatedAt && (
|
||||
<Descriptions.Item label="更新时间" span={2}>
|
||||
{new Date(template.updatedAt).toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Divider>配置详情</Divider>
|
||||
|
||||
<Card title="数据对象" size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{template.configuration.objects.map((obj, index) => (
|
||||
<Card key={index} size="small" type="inner">
|
||||
<Space>
|
||||
<Text strong>名称:</Text>
|
||||
<Tag>{obj.name}</Tag>
|
||||
<Text strong>类型:</Text>
|
||||
<Tag color="blue">{obj.type}</Tag>
|
||||
<Text strong>值:</Text>
|
||||
<Tag color="green">{obj.value}</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="标注控件" size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
{template.configuration.labels.map((label, index) => (
|
||||
<Card key={index} size="small" type="inner" title={`控件 ${index + 1}`}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Text strong>来源名称:</Text>
|
||||
<Tag>{label.fromName}</Tag>
|
||||
|
||||
<Text strong style={{ marginLeft: 16 }}>目标名称:</Text>
|
||||
<Tag>{label.toName}</Tag>
|
||||
|
||||
<Text strong style={{ marginLeft: 16 }}>类型:</Text>
|
||||
<Tag color="purple">{label.type}</Tag>
|
||||
|
||||
{label.required && <Tag color="red">必填</Tag>}
|
||||
</div>
|
||||
|
||||
{label.description && (
|
||||
<div>
|
||||
<Text strong>描述:</Text>
|
||||
<Text type="secondary">{label.description}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label.options && label.options.length > 0 && (
|
||||
<div>
|
||||
<Text strong>选项:</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{label.options.map((opt, i) => (
|
||||
<Tag key={i} color="cyan">{opt}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label.labels && label.labels.length > 0 && (
|
||||
<div>
|
||||
<Text strong>标签:</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{label.labels.map((lbl, i) => (
|
||||
<Tag key={i} color="geekblue">{lbl}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{template.labelConfig && (
|
||||
<Card title="Label Studio XML 配置" size="small">
|
||||
<Paragraph>
|
||||
<pre style={{
|
||||
background: "#f5f5f5",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
overflow: "auto",
|
||||
maxHeight: 300
|
||||
}}>
|
||||
{template.labelConfig}
|
||||
</pre>
|
||||
</Paragraph>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDetail;
|
||||
|
||||
@@ -1,427 +1,427 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Divider,
|
||||
Card,
|
||||
Checkbox,
|
||||
} from "antd";
|
||||
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
createAnnotationTemplateUsingPost,
|
||||
updateAnnotationTemplateByIdUsingPut,
|
||||
} from "../annotation.api";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
import TagSelector from "./components/TagSelector";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface TemplateFormProps {
|
||||
visible: boolean;
|
||||
mode: "create" | "edit";
|
||||
template?: AnnotationTemplate;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
visible,
|
||||
mode,
|
||||
template,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && template && mode === "edit") {
|
||||
form.setFieldsValue({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
dataType: template.dataType,
|
||||
labelingType: template.labelingType,
|
||||
style: template.style,
|
||||
category: template.category,
|
||||
labels: template.configuration.labels,
|
||||
objects: template.configuration.objects,
|
||||
});
|
||||
} else if (visible && mode === "create") {
|
||||
form.resetFields();
|
||||
// Set default values
|
||||
form.setFieldsValue({
|
||||
style: "horizontal",
|
||||
category: "custom",
|
||||
labels: [],
|
||||
objects: [{ name: "image", type: "Image", value: "$image" }],
|
||||
});
|
||||
}
|
||||
}, [visible, template, mode, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
console.log("Form values:", values);
|
||||
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
dataType: values.dataType,
|
||||
labelingType: values.labelingType,
|
||||
style: values.style,
|
||||
category: values.category,
|
||||
configuration: {
|
||||
labels: values.labels,
|
||||
objects: values.objects,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Request data:", requestData);
|
||||
|
||||
let response;
|
||||
if (mode === "create") {
|
||||
response = await createAnnotationTemplateUsingPost(requestData);
|
||||
} else {
|
||||
response = await updateAnnotationTemplateByIdUsingPut(template!.id, requestData);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
message.success(`模板${mode === "create" ? "创建" : "更新"}成功`);
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
} else {
|
||||
message.error(response.message || `模板${mode === "create" ? "创建" : "更新"}失败`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
message.error("请填写所有必填字段");
|
||||
} else {
|
||||
message.error(`模板${mode === "create" ? "创建" : "更新"}失败`);
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const needsOptions = (type: string) => {
|
||||
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(type);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={mode === "create" ? "创建模板" : "编辑模板"}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={900}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ maxHeight: "70vh", overflowY: "auto", paddingRight: 8 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="模板名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入模板名称" }]}
|
||||
>
|
||||
<Input placeholder="例如:产品质量分类" maxLength={100} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea
|
||||
placeholder="描述此模板的用途"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: "100%" }} size="large">
|
||||
<Form.Item
|
||||
label="数据类型"
|
||||
name="dataType"
|
||||
rules={[{ required: true, message: "请选择数据类型" }]}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
<Select placeholder="选择数据类型">
|
||||
<Option value="image">图像</Option>
|
||||
<Option value="text">文本</Option>
|
||||
<Option value="audio">音频</Option>
|
||||
<Option value="video">视频</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注类型"
|
||||
name="labelingType"
|
||||
rules={[{ required: true, message: "请选择标注类型" }]}
|
||||
style={{ width: 220 }}
|
||||
>
|
||||
<Select placeholder="选择标注类型">
|
||||
<Option value="classification">分类</Option>
|
||||
<Option value="object-detection">目标检测</Option>
|
||||
<Option value="segmentation">分割</Option>
|
||||
<Option value="ner">命名实体识别</Option>
|
||||
<Option value="multi-stage">多阶段</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="样式"
|
||||
name="style"
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
<Select>
|
||||
<Option value="horizontal">水平</Option>
|
||||
<Option value="vertical">垂直</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="分类"
|
||||
name="category"
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<Select>
|
||||
<Option value="computer-vision">计算机视觉</Option>
|
||||
<Option value="nlp">自然语言处理</Option>
|
||||
<Option value="audio">音频</Option>
|
||||
<Option value="quality-control">质量控制</Option>
|
||||
<Option value="custom">自定义</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Divider>数据对象</Divider>
|
||||
|
||||
<Form.List name="objects">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Card key={field.key} size="small" style={{ marginBottom: 8 }}>
|
||||
<Space align="start" style={{ width: "100%" }}>
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="名称"
|
||||
name={[field.name, "name"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0, width: 150 }}
|
||||
>
|
||||
<Input placeholder="例如:image" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="类型"
|
||||
name={[field.name, "type"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0, width: 150 }}
|
||||
>
|
||||
<TagSelector type="object" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="值"
|
||||
name={[field.name, "value"]}
|
||||
rules={[
|
||||
{ required: true, message: "必填" },
|
||||
{ pattern: /^\$/, message: "必须以 $ 开头" },
|
||||
]}
|
||||
style={{ marginBottom: 0, width: 150 }}
|
||||
>
|
||||
<Input placeholder="$image" />
|
||||
</Form.Item>
|
||||
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined
|
||||
style={{ marginTop: 30, color: "red" }}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加对象
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Divider>标签控件</Divider>
|
||||
|
||||
<Form.List name="labels">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Card
|
||||
key={field.key}
|
||||
size="small"
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<Space>
|
||||
<span>控件 {fields.indexOf(field) + 1}</span>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const controlType = form.getFieldValue(["labels", field.name, "type"]);
|
||||
const fromName = form.getFieldValue(["labels", field.name, "fromName"]);
|
||||
if (controlType || fromName) {
|
||||
return (
|
||||
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#999' }}>
|
||||
({fromName || '未命名'} - {controlType || '未设置类型'})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<MinusCircleOutlined
|
||||
style={{ color: "red" }}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
{/* Row 1: 控件名称, 标注目标对象, 控件类型 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 220px 1fr auto', gap: 12, alignItems: 'flex-end' }}>
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="来源名称"
|
||||
name={[field.name, "fromName"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
tooltip="此控件的唯一标识符"
|
||||
>
|
||||
<Input placeholder="例如:choice" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="标注目标对象"
|
||||
name={[field.name, "toName"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
tooltip="选择此控件将标注哪个数据对象"
|
||||
dependencies={['objects']}
|
||||
>
|
||||
<Select placeholder="选择数据对象">
|
||||
{(form.getFieldValue("objects") || []).map((obj: any, idx: number) => (
|
||||
<Option key={idx} value={obj?.name || ''}>
|
||||
{obj?.name || `对象 ${idx + 1}`} ({obj?.type || '未知类型'})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="控件类型"
|
||||
name={[field.name, "type"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<TagSelector type="control" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label=" "
|
||||
name={[field.name, "required"]}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Checkbox>必填</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 取值范围定义(添加选项) - Conditionally rendered based on type */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => {
|
||||
const prevType = prevValues.labels?.[field.name]?.type;
|
||||
const currType = currentValues.labels?.[field.name]?.type;
|
||||
return prevType !== currType;
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const controlType = getFieldValue(["labels", field.name, "type"]);
|
||||
const fieldName = controlType === "Choices" ? "options" : "labels";
|
||||
|
||||
if (needsOptions(controlType)) {
|
||||
return (
|
||||
<Form.Item
|
||||
{...field}
|
||||
label={controlType === "Choices" ? "选项" : "标签"}
|
||||
name={[field.name, fieldName]}
|
||||
rules={[{ required: true, message: "至少需要一个选项" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={
|
||||
controlType === "Choices"
|
||||
? "输入选项内容,按回车添加。例如:是、否、不确定"
|
||||
: "输入标签名称,按回车添加。例如:人物、车辆、建筑物"
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* Row 3: 描述 */}
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="描述"
|
||||
name={[field.name, "description"]}
|
||||
style={{ marginBottom: 0 }}
|
||||
tooltip="向标注人员显示的帮助信息"
|
||||
>
|
||||
<Input placeholder="为标注人员提供此控件的使用说明" maxLength={200} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() =>
|
||||
add({
|
||||
fromName: "",
|
||||
toName: "",
|
||||
type: "Choices",
|
||||
required: false,
|
||||
})
|
||||
}
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
添加标签控件
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateForm;
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Divider,
|
||||
Card,
|
||||
Checkbox,
|
||||
} from "antd";
|
||||
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
createAnnotationTemplateUsingPost,
|
||||
updateAnnotationTemplateByIdUsingPut,
|
||||
} from "../annotation.api";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
import TagSelector from "./components/TagSelector";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface TemplateFormProps {
|
||||
visible: boolean;
|
||||
mode: "create" | "edit";
|
||||
template?: AnnotationTemplate;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
visible,
|
||||
mode,
|
||||
template,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && template && mode === "edit") {
|
||||
form.setFieldsValue({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
dataType: template.dataType,
|
||||
labelingType: template.labelingType,
|
||||
style: template.style,
|
||||
category: template.category,
|
||||
labels: template.configuration.labels,
|
||||
objects: template.configuration.objects,
|
||||
});
|
||||
} else if (visible && mode === "create") {
|
||||
form.resetFields();
|
||||
// Set default values
|
||||
form.setFieldsValue({
|
||||
style: "horizontal",
|
||||
category: "custom",
|
||||
labels: [],
|
||||
objects: [{ name: "image", type: "Image", value: "$image" }],
|
||||
});
|
||||
}
|
||||
}, [visible, template, mode, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
console.log("Form values:", values);
|
||||
|
||||
const requestData = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
dataType: values.dataType,
|
||||
labelingType: values.labelingType,
|
||||
style: values.style,
|
||||
category: values.category,
|
||||
configuration: {
|
||||
labels: values.labels,
|
||||
objects: values.objects,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Request data:", requestData);
|
||||
|
||||
let response;
|
||||
if (mode === "create") {
|
||||
response = await createAnnotationTemplateUsingPost(requestData);
|
||||
} else {
|
||||
response = await updateAnnotationTemplateByIdUsingPut(template!.id, requestData);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
message.success(`模板${mode === "create" ? "创建" : "更新"}成功`);
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
} else {
|
||||
message.error(response.message || `模板${mode === "create" ? "创建" : "更新"}失败`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) {
|
||||
message.error("请填写所有必填字段");
|
||||
} else {
|
||||
message.error(`模板${mode === "create" ? "创建" : "更新"}失败`);
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const needsOptions = (type: string) => {
|
||||
return ["Choices", "RectangleLabels", "PolygonLabels", "Labels"].includes(type);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={mode === "create" ? "创建模板" : "编辑模板"}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={900}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ maxHeight: "70vh", overflowY: "auto", paddingRight: 8 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="模板名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入模板名称" }]}
|
||||
>
|
||||
<Input placeholder="例如:产品质量分类" maxLength={100} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="描述" name="description">
|
||||
<TextArea
|
||||
placeholder="描述此模板的用途"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: "100%" }} size="large">
|
||||
<Form.Item
|
||||
label="数据类型"
|
||||
name="dataType"
|
||||
rules={[{ required: true, message: "请选择数据类型" }]}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
<Select placeholder="选择数据类型">
|
||||
<Option value="image">图像</Option>
|
||||
<Option value="text">文本</Option>
|
||||
<Option value="audio">音频</Option>
|
||||
<Option value="video">视频</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="标注类型"
|
||||
name="labelingType"
|
||||
rules={[{ required: true, message: "请选择标注类型" }]}
|
||||
style={{ width: 220 }}
|
||||
>
|
||||
<Select placeholder="选择标注类型">
|
||||
<Option value="classification">分类</Option>
|
||||
<Option value="object-detection">目标检测</Option>
|
||||
<Option value="segmentation">分割</Option>
|
||||
<Option value="ner">命名实体识别</Option>
|
||||
<Option value="multi-stage">多阶段</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="样式"
|
||||
name="style"
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
<Select>
|
||||
<Option value="horizontal">水平</Option>
|
||||
<Option value="vertical">垂直</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="分类"
|
||||
name="category"
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<Select>
|
||||
<Option value="computer-vision">计算机视觉</Option>
|
||||
<Option value="nlp">自然语言处理</Option>
|
||||
<Option value="audio">音频</Option>
|
||||
<Option value="quality-control">质量控制</Option>
|
||||
<Option value="custom">自定义</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Divider>数据对象</Divider>
|
||||
|
||||
<Form.List name="objects">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Card key={field.key} size="small" style={{ marginBottom: 8 }}>
|
||||
<Space align="start" style={{ width: "100%" }}>
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="名称"
|
||||
name={[field.name, "name"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0, width: 150 }}
|
||||
>
|
||||
<Input placeholder="例如:image" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="类型"
|
||||
name={[field.name, "type"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0, width: 150 }}
|
||||
>
|
||||
<TagSelector type="object" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="值"
|
||||
name={[field.name, "value"]}
|
||||
rules={[
|
||||
{ required: true, message: "必填" },
|
||||
{ pattern: /^\$/, message: "必须以 $ 开头" },
|
||||
]}
|
||||
style={{ marginBottom: 0, width: 150 }}
|
||||
>
|
||||
<Input placeholder="$image" />
|
||||
</Form.Item>
|
||||
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined
|
||||
style={{ marginTop: 30, color: "red" }}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加对象
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Divider>标签控件</Divider>
|
||||
|
||||
<Form.List name="labels">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Card
|
||||
key={field.key}
|
||||
size="small"
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<Space>
|
||||
<span>控件 {fields.indexOf(field) + 1}</span>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const controlType = form.getFieldValue(["labels", field.name, "type"]);
|
||||
const fromName = form.getFieldValue(["labels", field.name, "fromName"]);
|
||||
if (controlType || fromName) {
|
||||
return (
|
||||
<span style={{ fontSize: 12, fontWeight: 'normal', color: '#999' }}>
|
||||
({fromName || '未命名'} - {controlType || '未设置类型'})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<MinusCircleOutlined
|
||||
style={{ color: "red" }}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
{/* Row 1: 控件名称, 标注目标对象, 控件类型 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 220px 1fr auto', gap: 12, alignItems: 'flex-end' }}>
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="来源名称"
|
||||
name={[field.name, "fromName"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
tooltip="此控件的唯一标识符"
|
||||
>
|
||||
<Input placeholder="例如:choice" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="标注目标对象"
|
||||
name={[field.name, "toName"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
tooltip="选择此控件将标注哪个数据对象"
|
||||
dependencies={['objects']}
|
||||
>
|
||||
<Select placeholder="选择数据对象">
|
||||
{(form.getFieldValue("objects") || []).map((obj: any, idx: number) => (
|
||||
<Option key={idx} value={obj?.name || ''}>
|
||||
{obj?.name || `对象 ${idx + 1}`} ({obj?.type || '未知类型'})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="控件类型"
|
||||
name={[field.name, "type"]}
|
||||
rules={[{ required: true, message: "必填" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<TagSelector type="control" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label=" "
|
||||
name={[field.name, "required"]}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Checkbox>必填</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 取值范围定义(添加选项) - Conditionally rendered based on type */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => {
|
||||
const prevType = prevValues.labels?.[field.name]?.type;
|
||||
const currType = currentValues.labels?.[field.name]?.type;
|
||||
return prevType !== currType;
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const controlType = getFieldValue(["labels", field.name, "type"]);
|
||||
const fieldName = controlType === "Choices" ? "options" : "labels";
|
||||
|
||||
if (needsOptions(controlType)) {
|
||||
return (
|
||||
<Form.Item
|
||||
{...field}
|
||||
label={controlType === "Choices" ? "选项" : "标签"}
|
||||
name={[field.name, fieldName]}
|
||||
rules={[{ required: true, message: "至少需要一个选项" }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
open={false}
|
||||
placeholder={
|
||||
controlType === "Choices"
|
||||
? "输入选项内容,按回车添加。例如:是、否、不确定"
|
||||
: "输入标签名称,按回车添加。例如:人物、车辆、建筑物"
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* Row 3: 描述 */}
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="描述"
|
||||
name={[field.name, "description"]}
|
||||
style={{ marginBottom: 0 }}
|
||||
tooltip="向标注人员显示的帮助信息"
|
||||
>
|
||||
<Input placeholder="为标注人员提供此控件的使用说明" maxLength={200} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() =>
|
||||
add({
|
||||
fromName: "",
|
||||
toName: "",
|
||||
type: "Choices",
|
||||
required: false,
|
||||
})
|
||||
}
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
添加标签控件
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateForm;
|
||||
|
||||
@@ -1,311 +1,311 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Card,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
queryAnnotationTemplatesUsingGet,
|
||||
deleteAnnotationTemplateByIdUsingDelete,
|
||||
} from "../annotation.api";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
import TemplateForm from "./TemplateForm.tsx";
|
||||
import TemplateDetail from "./TemplateDetail.tsx";
|
||||
import {SearchControls} from "@/components/SearchControls.tsx";
|
||||
import useFetchData from "@/hooks/useFetchData.ts";
|
||||
import {
|
||||
AnnotationTypeMap,
|
||||
ClassificationMap,
|
||||
DataTypeMap,
|
||||
TemplateTypeMap
|
||||
} from "@/pages/DataAnnotation/annotation.const.tsx";
|
||||
|
||||
const TemplateList: React.FC = () => {
|
||||
const filterOptions = [
|
||||
{
|
||||
key: "category",
|
||||
label: "分类",
|
||||
options: [...Object.values(ClassificationMap)],
|
||||
},
|
||||
{
|
||||
key: "dataType",
|
||||
label: "数据类型",
|
||||
options: [...Object.values(DataTypeMap)],
|
||||
},
|
||||
{
|
||||
key: "labelingType",
|
||||
label: "标注类型",
|
||||
options: [...Object.values(AnnotationTypeMap)],
|
||||
},
|
||||
{
|
||||
key: "builtIn",
|
||||
label: "模板类型",
|
||||
options: [...Object.values(TemplateTypeMap)],
|
||||
},
|
||||
];
|
||||
|
||||
// Modals
|
||||
const [isFormVisible, setIsFormVisible] = useState(false);
|
||||
const [isDetailVisible, setIsDetailVisible] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AnnotationTemplate | undefined>();
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
handleKeywordChange,
|
||||
} = useFetchData(queryAnnotationTemplatesUsingGet, undefined, undefined, undefined, undefined, 0);
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormMode("create");
|
||||
setSelectedTemplate(undefined);
|
||||
setIsFormVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (template: AnnotationTemplate) => {
|
||||
setFormMode("edit");
|
||||
setSelectedTemplate(template);
|
||||
setIsFormVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (template: AnnotationTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDetailVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (templateId: string) => {
|
||||
try {
|
||||
const response = await deleteAnnotationTemplateByIdUsingDelete(templateId);
|
||||
if (response.code === 200) {
|
||||
message.success("模板删除成功");
|
||||
fetchData();
|
||||
} else {
|
||||
message.error(response.message || "删除模板失败");
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("删除模板失败");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
setIsFormVisible(false);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"computer-vision": "blue",
|
||||
"nlp": "green",
|
||||
"audio": "purple",
|
||||
"quality-control": "orange",
|
||||
"custom": "default",
|
||||
};
|
||||
return colors[category] || "default";
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AnnotationTemplate> = [
|
||||
{
|
||||
title: "模板名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
onFilter: (value, record) =>
|
||||
record.name.toLowerCase().includes(value.toString().toLowerCase()) ||
|
||||
(record.description?.toLowerCase().includes(value.toString().toLowerCase()) ?? false),
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
ellipsis: {
|
||||
showTitle: false,
|
||||
},
|
||||
render: (description: string) => (
|
||||
<Tooltip title={description}>
|
||||
<div
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'normal',
|
||||
lineHeight: '1.5em',
|
||||
maxHeight: '3em',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "数据类型",
|
||||
dataIndex: "dataType",
|
||||
key: "dataType",
|
||||
width: 120,
|
||||
render: (dataType: string) => (
|
||||
<Tag color="cyan">{dataType}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "标注类型",
|
||||
dataIndex: "labelingType",
|
||||
key: "labelingType",
|
||||
width: 150,
|
||||
render: (labelingType: string) => (
|
||||
<Tag color="geekblue">{labelingType}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
key: "category",
|
||||
width: 150,
|
||||
render: (category: string) => (
|
||||
<Tag color={getCategoryColor(category)}>{category}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "builtIn",
|
||||
key: "builtIn",
|
||||
width: 100,
|
||||
render: (builtIn: boolean) => (
|
||||
<Tag color={builtIn ? "gold" : "default"}>
|
||||
{builtIn ? "系统内置" : "自定义"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "版本",
|
||||
dataIndex: "version",
|
||||
key: "version",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
render: (date: string) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 200,
|
||||
fixed: "right",
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="查看详情">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleView(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!record.builtIn && (
|
||||
<>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定要删除这个模板吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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 Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SearchControls
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={handleKeywordChange}
|
||||
searchPlaceholder="搜索任务名称、描述"
|
||||
filters={filterOptions}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
showViewToggle={true}
|
||||
onReload={fetchData}
|
||||
onClearFilters={() => setSearchParams({ ...searchParams, filter: {} })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side: Create button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
创建模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
scroll={{ x: 1400, y: "calc(100vh - 24rem)" }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<TemplateForm
|
||||
visible={isFormVisible}
|
||||
mode={formMode}
|
||||
template={selectedTemplate}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={() => setIsFormVisible(false)}
|
||||
/>
|
||||
|
||||
<TemplateDetail
|
||||
visible={isDetailVisible}
|
||||
template={selectedTemplate}
|
||||
onClose={() => setIsDetailVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateList;
|
||||
export { TemplateList };
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Card,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
queryAnnotationTemplatesUsingGet,
|
||||
deleteAnnotationTemplateByIdUsingDelete,
|
||||
} from "../annotation.api";
|
||||
import type { AnnotationTemplate } from "../annotation.model";
|
||||
import TemplateForm from "./TemplateForm.tsx";
|
||||
import TemplateDetail from "./TemplateDetail.tsx";
|
||||
import {SearchControls} from "@/components/SearchControls.tsx";
|
||||
import useFetchData from "@/hooks/useFetchData.ts";
|
||||
import {
|
||||
AnnotationTypeMap,
|
||||
ClassificationMap,
|
||||
DataTypeMap,
|
||||
TemplateTypeMap
|
||||
} from "@/pages/DataAnnotation/annotation.const.tsx";
|
||||
|
||||
const TemplateList: React.FC = () => {
|
||||
const filterOptions = [
|
||||
{
|
||||
key: "category",
|
||||
label: "分类",
|
||||
options: [...Object.values(ClassificationMap)],
|
||||
},
|
||||
{
|
||||
key: "dataType",
|
||||
label: "数据类型",
|
||||
options: [...Object.values(DataTypeMap)],
|
||||
},
|
||||
{
|
||||
key: "labelingType",
|
||||
label: "标注类型",
|
||||
options: [...Object.values(AnnotationTypeMap)],
|
||||
},
|
||||
{
|
||||
key: "builtIn",
|
||||
label: "模板类型",
|
||||
options: [...Object.values(TemplateTypeMap)],
|
||||
},
|
||||
];
|
||||
|
||||
// Modals
|
||||
const [isFormVisible, setIsFormVisible] = useState(false);
|
||||
const [isDetailVisible, setIsDetailVisible] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AnnotationTemplate | undefined>();
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
|
||||
const {
|
||||
loading,
|
||||
tableData,
|
||||
pagination,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
fetchData,
|
||||
handleFiltersChange,
|
||||
handleKeywordChange,
|
||||
} = useFetchData(queryAnnotationTemplatesUsingGet, undefined, undefined, undefined, undefined, 0);
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormMode("create");
|
||||
setSelectedTemplate(undefined);
|
||||
setIsFormVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (template: AnnotationTemplate) => {
|
||||
setFormMode("edit");
|
||||
setSelectedTemplate(template);
|
||||
setIsFormVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (template: AnnotationTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDetailVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (templateId: string) => {
|
||||
try {
|
||||
const response = await deleteAnnotationTemplateByIdUsingDelete(templateId);
|
||||
if (response.code === 200) {
|
||||
message.success("模板删除成功");
|
||||
fetchData();
|
||||
} else {
|
||||
message.error(response.message || "删除模板失败");
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("删除模板失败");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
setIsFormVisible(false);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"computer-vision": "blue",
|
||||
"nlp": "green",
|
||||
"audio": "purple",
|
||||
"quality-control": "orange",
|
||||
"custom": "default",
|
||||
};
|
||||
return colors[category] || "default";
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AnnotationTemplate> = [
|
||||
{
|
||||
title: "模板名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
onFilter: (value, record) =>
|
||||
record.name.toLowerCase().includes(value.toString().toLowerCase()) ||
|
||||
(record.description?.toLowerCase().includes(value.toString().toLowerCase()) ?? false),
|
||||
},
|
||||
{
|
||||
title: "描述",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
ellipsis: {
|
||||
showTitle: false,
|
||||
},
|
||||
render: (description: string) => (
|
||||
<Tooltip title={description}>
|
||||
<div
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'normal',
|
||||
lineHeight: '1.5em',
|
||||
maxHeight: '3em',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "数据类型",
|
||||
dataIndex: "dataType",
|
||||
key: "dataType",
|
||||
width: 120,
|
||||
render: (dataType: string) => (
|
||||
<Tag color="cyan">{dataType}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "标注类型",
|
||||
dataIndex: "labelingType",
|
||||
key: "labelingType",
|
||||
width: 150,
|
||||
render: (labelingType: string) => (
|
||||
<Tag color="geekblue">{labelingType}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
key: "category",
|
||||
width: 150,
|
||||
render: (category: string) => (
|
||||
<Tag color={getCategoryColor(category)}>{category}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "builtIn",
|
||||
key: "builtIn",
|
||||
width: 100,
|
||||
render: (builtIn: boolean) => (
|
||||
<Tag color={builtIn ? "gold" : "default"}>
|
||||
{builtIn ? "系统内置" : "自定义"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "版本",
|
||||
dataIndex: "version",
|
||||
key: "version",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
render: (date: string) => new Date(date).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 200,
|
||||
fixed: "right",
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="查看详情">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleView(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!record.builtIn && (
|
||||
<>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定要删除这个模板吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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 Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SearchControls
|
||||
searchTerm={searchParams.keyword}
|
||||
onSearchChange={handleKeywordChange}
|
||||
searchPlaceholder="搜索任务名称、描述"
|
||||
filters={filterOptions}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
showViewToggle={true}
|
||||
onReload={fetchData}
|
||||
onClearFilters={() => setSearchParams({ ...searchParams, filter: {} })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side: Create button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
创建模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
scroll={{ x: 1400, y: "calc(100vh - 24rem)" }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<TemplateForm
|
||||
visible={isFormVisible}
|
||||
mode={formMode}
|
||||
template={selectedTemplate}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={() => setIsFormVisible(false)}
|
||||
/>
|
||||
|
||||
<TemplateDetail
|
||||
visible={isDetailVisible}
|
||||
template={selectedTemplate}
|
||||
onClose={() => setIsDetailVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateList;
|
||||
export { TemplateList };
|
||||
|
||||
@@ -1,161 +1,161 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Drawer,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EyeOutlined,
|
||||
CodeOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { TagBrowser } from "./components";
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
interface VisualTemplateBuilderProps {
|
||||
onSave?: (templateCode: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual Template Builder
|
||||
* Provides a drag-and-drop interface for building Label Studio templates
|
||||
*/
|
||||
const VisualTemplateBuilder: React.FC<VisualTemplateBuilderProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<
|
||||
Array<{ name: string; category: "object" | "control" }>
|
||||
>([]);
|
||||
|
||||
const handleTagSelect = (tagName: string, category: "object" | "control") => {
|
||||
message.info(`选择了 ${category === "object" ? "对象" : "控件"}: ${tagName}`);
|
||||
setSelectedTags([...selectedTags, { name: tagName, category }]);
|
||||
setDrawerVisible(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: Generate template XML from selectedTags
|
||||
message.success("模板保存成功");
|
||||
onSave?.("<View><!-- Generated template --></View>");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title="可视化模板构建器"
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<AppstoreOutlined />}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
>
|
||||
浏览标签
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CodeOutlined />}
|
||||
onClick={() => setPreviewVisible(true)}
|
||||
>
|
||||
查看代码
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存模板
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "400px",
|
||||
border: "2px dashed #d9d9d9",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{selectedTags.length === 0 ? (
|
||||
<div>
|
||||
<Paragraph type="secondary">
|
||||
点击"浏览标签"开始构建您的标注模板
|
||||
</Paragraph>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
>
|
||||
添加标签
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size="large">
|
||||
{selectedTags.map((tag, index) => (
|
||||
<Card key={index} size="small">
|
||||
<div>
|
||||
{tag.category === "object" ? "对象" : "控件"}: {tag.name}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Drawer
|
||||
title="标签浏览器"
|
||||
placement="right"
|
||||
width={800}
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
>
|
||||
<TagBrowser onTagSelect={handleTagSelect} />
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="模板代码预览"
|
||||
placement="right"
|
||||
width={600}
|
||||
open={previewVisible}
|
||||
onClose={() => setPreviewVisible(false)}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: "16px",
|
||||
borderRadius: "4px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{`<View>
|
||||
<!-- 根据选择的标签生成的模板代码 -->
|
||||
${selectedTags
|
||||
.map(
|
||||
(tag) =>
|
||||
`<${tag.name}${tag.category === "object" ? ' name="obj" value="$data"' : ' name="ctrl" toName="obj"'} />`
|
||||
)
|
||||
.join("\n ")}
|
||||
</View>`}
|
||||
</code>
|
||||
</pre>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualTemplateBuilder;
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Drawer,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EyeOutlined,
|
||||
CodeOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { TagBrowser } from "./components";
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
interface VisualTemplateBuilderProps {
|
||||
onSave?: (templateCode: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual Template Builder
|
||||
* Provides a drag-and-drop interface for building Label Studio templates
|
||||
*/
|
||||
const VisualTemplateBuilder: React.FC<VisualTemplateBuilderProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<
|
||||
Array<{ name: string; category: "object" | "control" }>
|
||||
>([]);
|
||||
|
||||
const handleTagSelect = (tagName: string, category: "object" | "control") => {
|
||||
message.info(`选择了 ${category === "object" ? "对象" : "控件"}: ${tagName}`);
|
||||
setSelectedTags([...selectedTags, { name: tagName, category }]);
|
||||
setDrawerVisible(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: Generate template XML from selectedTags
|
||||
message.success("模板保存成功");
|
||||
onSave?.("<View><!-- Generated template --></View>");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title="可视化模板构建器"
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<AppstoreOutlined />}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
>
|
||||
浏览标签
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CodeOutlined />}
|
||||
onClick={() => setPreviewVisible(true)}
|
||||
>
|
||||
查看代码
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存模板
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "400px",
|
||||
border: "2px dashed #d9d9d9",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{selectedTags.length === 0 ? (
|
||||
<div>
|
||||
<Paragraph type="secondary">
|
||||
点击"浏览标签"开始构建您的标注模板
|
||||
</Paragraph>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
>
|
||||
添加标签
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size="large">
|
||||
{selectedTags.map((tag, index) => (
|
||||
<Card key={index} size="small">
|
||||
<div>
|
||||
{tag.category === "object" ? "对象" : "控件"}: {tag.name}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Drawer
|
||||
title="标签浏览器"
|
||||
placement="right"
|
||||
width={800}
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
>
|
||||
<TagBrowser onTagSelect={handleTagSelect} />
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="模板代码预览"
|
||||
placement="right"
|
||||
width={600}
|
||||
open={previewVisible}
|
||||
onClose={() => setPreviewVisible(false)}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: "16px",
|
||||
borderRadius: "4px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{`<View>
|
||||
<!-- 根据选择的标签生成的模板代码 -->
|
||||
${selectedTags
|
||||
.map(
|
||||
(tag) =>
|
||||
`<${tag.name}${tag.category === "object" ? ' name="obj" value="$data"' : ' name="ctrl" toName="obj"'} />`
|
||||
)
|
||||
.join("\n ")}
|
||||
</View>`}
|
||||
</code>
|
||||
</pre>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualTemplateBuilder;
|
||||
|
||||
@@ -1,260 +1,260 @@
|
||||
import React from "react";
|
||||
import { Card, Tabs, List, Tag, Typography, Space, Empty, Spin } from "antd";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
ControlOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useTagConfig } from "../../../../hooks/useTagConfig";
|
||||
import {
|
||||
getControlDisplayName,
|
||||
getObjectDisplayName,
|
||||
getControlGroups,
|
||||
} from "../../annotation.tagconfig";
|
||||
import type { TagOption } from "../../annotation.tagconfig";
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface TagBrowserProps {
|
||||
onTagSelect?: (tagName: string, category: "object" | "control") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag Browser Component
|
||||
* Displays all available Label Studio tags in a browsable interface
|
||||
*/
|
||||
const TagBrowser: React.FC<TagBrowserProps> = ({ onTagSelect }) => {
|
||||
const { config, objectOptions, controlOptions, loading, error } =
|
||||
useTagConfig();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>加载标签配置...</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<Empty
|
||||
description={
|
||||
<div>
|
||||
<div>{error}</div>
|
||||
<Text type="secondary">无法加载标签配置</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const renderObjectList = () => (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
|
||||
dataSource={objectOptions}
|
||||
renderItem={(item: TagOption) => {
|
||||
const objConfig = config?.objects[item.value];
|
||||
return (
|
||||
<List.Item>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
onClick={() => onTagSelect?.(item.value, "object")}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text strong>{getObjectDisplayName(item.value)}</Text>
|
||||
<Tag color="blue"><{item.value}></Tag>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
{objConfig && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text style={{ fontSize: 11, color: "#8c8c8c" }}>
|
||||
必需属性:{" "}
|
||||
{objConfig.required_attrs.join(", ") || "无"}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderControlsByGroup = () => {
|
||||
const groups = getControlGroups();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultActiveKey="classification"
|
||||
items={Object.entries(groups).map(([groupKey, groupConfig]) => {
|
||||
const groupControls = controlOptions.filter((opt: TagOption) =>
|
||||
groupConfig.controls.includes(opt.value)
|
||||
);
|
||||
|
||||
return {
|
||||
key: groupKey,
|
||||
label: groupConfig.label,
|
||||
children: (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
|
||||
dataSource={groupControls}
|
||||
locale={{ emptyText: "此分组暂无控件" }}
|
||||
renderItem={(item: TagOption) => {
|
||||
const ctrlConfig = config?.controls[item.value];
|
||||
return (
|
||||
<List.Item>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
onClick={() => onTagSelect?.(item.value, "control")}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text strong>
|
||||
{getControlDisplayName(item.value)}
|
||||
</Text>
|
||||
<Tag color="green"><{item.value}></Tag>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
{ctrlConfig && (
|
||||
<Space
|
||||
size={4}
|
||||
wrap
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{ctrlConfig.requires_children && (
|
||||
<Tag
|
||||
color="orange"
|
||||
style={{ fontSize: 10, margin: 0 }}
|
||||
>
|
||||
需要 <{ctrlConfig.child_tag}>
|
||||
</Tag>
|
||||
)}
|
||||
{ctrlConfig.required_attrs.includes("toName") && (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ fontSize: 10, margin: 0 }}
|
||||
>
|
||||
绑定对象
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultActiveKey="controls"
|
||||
items={[
|
||||
{
|
||||
key: "controls",
|
||||
label: (
|
||||
<span>
|
||||
<ControlOutlined />
|
||||
控件标签 ({controlOptions.length})
|
||||
</span>
|
||||
),
|
||||
children: renderControlsByGroup(),
|
||||
},
|
||||
{
|
||||
key: "objects",
|
||||
label: (
|
||||
<span>
|
||||
<AppstoreOutlined />
|
||||
数据对象 ({objectOptions.length})
|
||||
</span>
|
||||
),
|
||||
children: renderObjectList(),
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
label: (
|
||||
<span>
|
||||
<InfoCircleOutlined />
|
||||
使用说明
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ padding: "16px" }}>
|
||||
<Title level={4}>Label Studio 标签配置说明</Title>
|
||||
<Paragraph>
|
||||
标注模板由两类标签组成:
|
||||
</Paragraph>
|
||||
<ul>
|
||||
<li>
|
||||
<Text strong>数据对象标签</Text>:定义要标注的数据类型(如图像、文本、音频等)
|
||||
</li>
|
||||
<li>
|
||||
<Text strong>控件标签</Text>:定义标注工具和交互方式(如矩形框、分类选项、文本输入等)
|
||||
</li>
|
||||
</ul>
|
||||
<Title level={5} style={{ marginTop: 24 }}>
|
||||
基本结构
|
||||
</Title>
|
||||
<Paragraph>
|
||||
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 4 }}>
|
||||
{`<View>
|
||||
<!-- 数据对象 -->
|
||||
<Image name="image" value="$image" />
|
||||
|
||||
<!-- 控件 -->
|
||||
<RectangleLabels name="label" toName="image">
|
||||
<Label value="人物" />
|
||||
<Label value="车辆" />
|
||||
</RectangleLabels>
|
||||
</View>`}
|
||||
</pre>
|
||||
</Paragraph>
|
||||
<Title level={5} style={{ marginTop: 24 }}>
|
||||
属性说明
|
||||
</Title>
|
||||
<ul>
|
||||
<li>
|
||||
<Text code>name</Text>:控件的唯一标识符
|
||||
</li>
|
||||
<li>
|
||||
<Text code>toName</Text>:指向要标注的数据对象的 name
|
||||
</li>
|
||||
<li>
|
||||
<Text code>value</Text>:数据源字段,以 $ 开头(如 $image, $text)
|
||||
</li>
|
||||
<li>
|
||||
<Text code>required</Text>:是否必填(可选)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagBrowser;
|
||||
import React from "react";
|
||||
import { Card, Tabs, List, Tag, Typography, Space, Empty, Spin } from "antd";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
ControlOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useTagConfig } from "../../../../hooks/useTagConfig";
|
||||
import {
|
||||
getControlDisplayName,
|
||||
getObjectDisplayName,
|
||||
getControlGroups,
|
||||
} from "../../annotation.tagconfig";
|
||||
import type { TagOption } from "../../annotation.tagconfig";
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface TagBrowserProps {
|
||||
onTagSelect?: (tagName: string, category: "object" | "control") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag Browser Component
|
||||
* Displays all available Label Studio tags in a browsable interface
|
||||
*/
|
||||
const TagBrowser: React.FC<TagBrowserProps> = ({ onTagSelect }) => {
|
||||
const { config, objectOptions, controlOptions, loading, error } =
|
||||
useTagConfig();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>加载标签配置...</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<Empty
|
||||
description={
|
||||
<div>
|
||||
<div>{error}</div>
|
||||
<Text type="secondary">无法加载标签配置</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const renderObjectList = () => (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
|
||||
dataSource={objectOptions}
|
||||
renderItem={(item: TagOption) => {
|
||||
const objConfig = config?.objects[item.value];
|
||||
return (
|
||||
<List.Item>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
onClick={() => onTagSelect?.(item.value, "object")}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text strong>{getObjectDisplayName(item.value)}</Text>
|
||||
<Tag color="blue"><{item.value}></Tag>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
{objConfig && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text style={{ fontSize: 11, color: "#8c8c8c" }}>
|
||||
必需属性:{" "}
|
||||
{objConfig.required_attrs.join(", ") || "无"}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderControlsByGroup = () => {
|
||||
const groups = getControlGroups();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultActiveKey="classification"
|
||||
items={Object.entries(groups).map(([groupKey, groupConfig]) => {
|
||||
const groupControls = controlOptions.filter((opt: TagOption) =>
|
||||
groupConfig.controls.includes(opt.value)
|
||||
);
|
||||
|
||||
return {
|
||||
key: groupKey,
|
||||
label: groupConfig.label,
|
||||
children: (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 4 }}
|
||||
dataSource={groupControls}
|
||||
locale={{ emptyText: "此分组暂无控件" }}
|
||||
renderItem={(item: TagOption) => {
|
||||
const ctrlConfig = config?.controls[item.value];
|
||||
return (
|
||||
<List.Item>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
onClick={() => onTagSelect?.(item.value, "control")}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text strong>
|
||||
{getControlDisplayName(item.value)}
|
||||
</Text>
|
||||
<Tag color="green"><{item.value}></Tag>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
{ctrlConfig && (
|
||||
<Space
|
||||
size={4}
|
||||
wrap
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{ctrlConfig.requires_children && (
|
||||
<Tag
|
||||
color="orange"
|
||||
style={{ fontSize: 10, margin: 0 }}
|
||||
>
|
||||
需要 <{ctrlConfig.child_tag}>
|
||||
</Tag>
|
||||
)}
|
||||
{ctrlConfig.required_attrs.includes("toName") && (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ fontSize: 10, margin: 0 }}
|
||||
>
|
||||
绑定对象
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultActiveKey="controls"
|
||||
items={[
|
||||
{
|
||||
key: "controls",
|
||||
label: (
|
||||
<span>
|
||||
<ControlOutlined />
|
||||
控件标签 ({controlOptions.length})
|
||||
</span>
|
||||
),
|
||||
children: renderControlsByGroup(),
|
||||
},
|
||||
{
|
||||
key: "objects",
|
||||
label: (
|
||||
<span>
|
||||
<AppstoreOutlined />
|
||||
数据对象 ({objectOptions.length})
|
||||
</span>
|
||||
),
|
||||
children: renderObjectList(),
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
label: (
|
||||
<span>
|
||||
<InfoCircleOutlined />
|
||||
使用说明
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div style={{ padding: "16px" }}>
|
||||
<Title level={4}>Label Studio 标签配置说明</Title>
|
||||
<Paragraph>
|
||||
标注模板由两类标签组成:
|
||||
</Paragraph>
|
||||
<ul>
|
||||
<li>
|
||||
<Text strong>数据对象标签</Text>:定义要标注的数据类型(如图像、文本、音频等)
|
||||
</li>
|
||||
<li>
|
||||
<Text strong>控件标签</Text>:定义标注工具和交互方式(如矩形框、分类选项、文本输入等)
|
||||
</li>
|
||||
</ul>
|
||||
<Title level={5} style={{ marginTop: 24 }}>
|
||||
基本结构
|
||||
</Title>
|
||||
<Paragraph>
|
||||
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 4 }}>
|
||||
{`<View>
|
||||
<!-- 数据对象 -->
|
||||
<Image name="image" value="$image" />
|
||||
|
||||
<!-- 控件 -->
|
||||
<RectangleLabels name="label" toName="image">
|
||||
<Label value="人物" />
|
||||
<Label value="车辆" />
|
||||
</RectangleLabels>
|
||||
</View>`}
|
||||
</pre>
|
||||
</Paragraph>
|
||||
<Title level={5} style={{ marginTop: 24 }}>
|
||||
属性说明
|
||||
</Title>
|
||||
<ul>
|
||||
<li>
|
||||
<Text code>name</Text>:控件的唯一标识符
|
||||
</li>
|
||||
<li>
|
||||
<Text code>toName</Text>:指向要标注的数据对象的 name
|
||||
</li>
|
||||
<li>
|
||||
<Text code>value</Text>:数据源字段,以 $ 开头(如 $image, $text)
|
||||
</li>
|
||||
<li>
|
||||
<Text code>required</Text>:是否必填(可选)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagBrowser;
|
||||
|
||||
@@ -1,301 +1,301 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Tooltip, Spin, Collapse, Tag, Space } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { getTagConfigUsingGet } from "../../annotation.api";
|
||||
import type {
|
||||
LabelStudioTagConfig,
|
||||
TagOption,
|
||||
} from "../../annotation.tagconfig";
|
||||
import {
|
||||
parseTagConfig,
|
||||
getControlDisplayName,
|
||||
getObjectDisplayName,
|
||||
getControlGroups,
|
||||
} from "../../annotation.tagconfig";
|
||||
|
||||
const { Option, OptGroup } = Select;
|
||||
|
||||
interface TagSelectorProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
type: "object" | "control";
|
||||
placeholder?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag Selector Component
|
||||
* Dynamically fetches and displays available Label Studio tags from backend config
|
||||
*/
|
||||
const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
type,
|
||||
placeholder,
|
||||
style,
|
||||
disabled,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tagOptions, setTagOptions] = useState<TagOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTagConfig();
|
||||
}, []);
|
||||
|
||||
const fetchTagConfig = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await getTagConfigUsingGet();
|
||||
if (response.code === 200 && response.data) {
|
||||
const config: LabelStudioTagConfig = response.data;
|
||||
const { objectOptions, controlOptions } = parseTagConfig(config);
|
||||
|
||||
if (type === "object") {
|
||||
setTagOptions(objectOptions);
|
||||
} else {
|
||||
setTagOptions(controlOptions);
|
||||
}
|
||||
} else {
|
||||
setError(response.message || "获取标签配置失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch tag config:", err);
|
||||
setError("加载标签配置时出错");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Select
|
||||
placeholder="加载中..."
|
||||
style={style}
|
||||
disabled
|
||||
suffixIcon={<Spin size="small" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Tooltip title={error}>
|
||||
<Select
|
||||
placeholder="加载失败,点击重试"
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
status="error"
|
||||
onClick={() => fetchTagConfig()}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Group controls by usage pattern
|
||||
if (type === "control") {
|
||||
const groups = getControlGroups();
|
||||
const groupedOptions: Record<string, TagOption[]> = {};
|
||||
const ungroupedOptions: TagOption[] = [];
|
||||
|
||||
// Group the controls
|
||||
Object.entries(groups).forEach(([groupKey, groupConfig]) => {
|
||||
groupedOptions[groupKey] = tagOptions.filter((opt) =>
|
||||
groupConfig.controls.includes(opt.value)
|
||||
);
|
||||
});
|
||||
|
||||
// Find ungrouped controls
|
||||
const allGroupedControls = new Set(
|
||||
Object.values(groups).flatMap((g) => g.controls)
|
||||
);
|
||||
tagOptions.forEach((opt) => {
|
||||
if (!allGroupedControls.has(opt.value)) {
|
||||
ungroupedOptions.push(opt);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder || "选择控件类型"}
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{Object.entries(groups).map(([groupKey, groupConfig]) => {
|
||||
const options = groupedOptions[groupKey];
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<OptGroup key={groupKey} label={groupConfig.label}>
|
||||
{options.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value} label={opt.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{getControlDisplayName(opt.value)}</span>
|
||||
<Tooltip title={opt.description}>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: "#8c8c8c", fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</OptGroup>
|
||||
);
|
||||
})}
|
||||
{ungroupedOptions.length > 0 && (
|
||||
<OptGroup label="其他">
|
||||
{ungroupedOptions.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value} label={opt.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{getControlDisplayName(opt.value)}</span>
|
||||
<Tooltip title={opt.description}>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: "#8c8c8c", fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</OptGroup>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Objects selector (no grouping)
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder || "选择数据对象类型"}
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{tagOptions.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value} label={opt.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{getObjectDisplayName(opt.value)}</span>
|
||||
<Tooltip title={opt.description}>
|
||||
<InfoCircleOutlined style={{ color: "#8c8c8c", fontSize: 12 }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagSelector;
|
||||
|
||||
/**
|
||||
* Tag Info Panel Component
|
||||
* Displays detailed information about a selected tag
|
||||
*/
|
||||
interface TagInfoPanelProps {
|
||||
tagConfig: LabelStudioTagConfig | null;
|
||||
tagType: string;
|
||||
category: "object" | "control";
|
||||
}
|
||||
|
||||
export const TagInfoPanel: React.FC<TagInfoPanelProps> = ({
|
||||
tagConfig,
|
||||
tagType,
|
||||
category,
|
||||
}) => {
|
||||
if (!tagConfig || !tagType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config =
|
||||
category === "object"
|
||||
? tagConfig.objects[tagType]
|
||||
: tagConfig.controls[tagType];
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: "标签配置详情",
|
||||
children: (
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<strong>描述:</strong>
|
||||
{config.description}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>必需属性:</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{config.required_attrs.map((attr: string) => (
|
||||
<Tag key={attr} color="red">
|
||||
{attr}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.optional_attrs &&
|
||||
Object.keys(config.optional_attrs).length > 0 && (
|
||||
<div>
|
||||
<strong>可选属性:</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{Object.entries(config.optional_attrs).map(
|
||||
([attrName, attrConfig]: [string, any]) => (
|
||||
<Tooltip
|
||||
key={attrName}
|
||||
title={
|
||||
<div>
|
||||
{attrConfig.description && (
|
||||
<div>{attrConfig.description}</div>
|
||||
)}
|
||||
{attrConfig.type && (
|
||||
<div>类型: {attrConfig.type}</div>
|
||||
)}
|
||||
{attrConfig.default !== undefined && (
|
||||
<div>默认值: {String(attrConfig.default)}</div>
|
||||
)}
|
||||
{attrConfig.values && (
|
||||
<div>
|
||||
可选值: {attrConfig.values.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag color="blue" style={{ cursor: "help" }}>
|
||||
{attrName}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.requires_children && (
|
||||
<div>
|
||||
<strong>子元素:</strong>
|
||||
<Tag color="green">需要 <{config.child_tag}></Tag>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Tooltip, Spin, Collapse, Tag, Space } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { getTagConfigUsingGet } from "../../annotation.api";
|
||||
import type {
|
||||
LabelStudioTagConfig,
|
||||
TagOption,
|
||||
} from "../../annotation.tagconfig";
|
||||
import {
|
||||
parseTagConfig,
|
||||
getControlDisplayName,
|
||||
getObjectDisplayName,
|
||||
getControlGroups,
|
||||
} from "../../annotation.tagconfig";
|
||||
|
||||
const { Option, OptGroup } = Select;
|
||||
|
||||
interface TagSelectorProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
type: "object" | "control";
|
||||
placeholder?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag Selector Component
|
||||
* Dynamically fetches and displays available Label Studio tags from backend config
|
||||
*/
|
||||
const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
type,
|
||||
placeholder,
|
||||
style,
|
||||
disabled,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tagOptions, setTagOptions] = useState<TagOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTagConfig();
|
||||
}, []);
|
||||
|
||||
const fetchTagConfig = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await getTagConfigUsingGet();
|
||||
if (response.code === 200 && response.data) {
|
||||
const config: LabelStudioTagConfig = response.data;
|
||||
const { objectOptions, controlOptions } = parseTagConfig(config);
|
||||
|
||||
if (type === "object") {
|
||||
setTagOptions(objectOptions);
|
||||
} else {
|
||||
setTagOptions(controlOptions);
|
||||
}
|
||||
} else {
|
||||
setError(response.message || "获取标签配置失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch tag config:", err);
|
||||
setError("加载标签配置时出错");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Select
|
||||
placeholder="加载中..."
|
||||
style={style}
|
||||
disabled
|
||||
suffixIcon={<Spin size="small" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Tooltip title={error}>
|
||||
<Select
|
||||
placeholder="加载失败,点击重试"
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
status="error"
|
||||
onClick={() => fetchTagConfig()}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Group controls by usage pattern
|
||||
if (type === "control") {
|
||||
const groups = getControlGroups();
|
||||
const groupedOptions: Record<string, TagOption[]> = {};
|
||||
const ungroupedOptions: TagOption[] = [];
|
||||
|
||||
// Group the controls
|
||||
Object.entries(groups).forEach(([groupKey, groupConfig]) => {
|
||||
groupedOptions[groupKey] = tagOptions.filter((opt) =>
|
||||
groupConfig.controls.includes(opt.value)
|
||||
);
|
||||
});
|
||||
|
||||
// Find ungrouped controls
|
||||
const allGroupedControls = new Set(
|
||||
Object.values(groups).flatMap((g) => g.controls)
|
||||
);
|
||||
tagOptions.forEach((opt) => {
|
||||
if (!allGroupedControls.has(opt.value)) {
|
||||
ungroupedOptions.push(opt);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder || "选择控件类型"}
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{Object.entries(groups).map(([groupKey, groupConfig]) => {
|
||||
const options = groupedOptions[groupKey];
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<OptGroup key={groupKey} label={groupConfig.label}>
|
||||
{options.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value} label={opt.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{getControlDisplayName(opt.value)}</span>
|
||||
<Tooltip title={opt.description}>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: "#8c8c8c", fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</OptGroup>
|
||||
);
|
||||
})}
|
||||
{ungroupedOptions.length > 0 && (
|
||||
<OptGroup label="其他">
|
||||
{ungroupedOptions.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value} label={opt.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{getControlDisplayName(opt.value)}</span>
|
||||
<Tooltip title={opt.description}>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: "#8c8c8c", fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</OptGroup>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Objects selector (no grouping)
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder || "选择数据对象类型"}
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{tagOptions.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value} label={opt.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{getObjectDisplayName(opt.value)}</span>
|
||||
<Tooltip title={opt.description}>
|
||||
<InfoCircleOutlined style={{ color: "#8c8c8c", fontSize: 12 }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagSelector;
|
||||
|
||||
/**
|
||||
* Tag Info Panel Component
|
||||
* Displays detailed information about a selected tag
|
||||
*/
|
||||
interface TagInfoPanelProps {
|
||||
tagConfig: LabelStudioTagConfig | null;
|
||||
tagType: string;
|
||||
category: "object" | "control";
|
||||
}
|
||||
|
||||
export const TagInfoPanel: React.FC<TagInfoPanelProps> = ({
|
||||
tagConfig,
|
||||
tagType,
|
||||
category,
|
||||
}) => {
|
||||
if (!tagConfig || !tagType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config =
|
||||
category === "object"
|
||||
? tagConfig.objects[tagType]
|
||||
: tagConfig.controls[tagType];
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: "标签配置详情",
|
||||
children: (
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<strong>描述:</strong>
|
||||
{config.description}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>必需属性:</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{config.required_attrs.map((attr: string) => (
|
||||
<Tag key={attr} color="red">
|
||||
{attr}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.optional_attrs &&
|
||||
Object.keys(config.optional_attrs).length > 0 && (
|
||||
<div>
|
||||
<strong>可选属性:</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{Object.entries(config.optional_attrs).map(
|
||||
([attrName, attrConfig]: [string, any]) => (
|
||||
<Tooltip
|
||||
key={attrName}
|
||||
title={
|
||||
<div>
|
||||
{attrConfig.description && (
|
||||
<div>{attrConfig.description}</div>
|
||||
)}
|
||||
{attrConfig.type && (
|
||||
<div>类型: {attrConfig.type}</div>
|
||||
)}
|
||||
{attrConfig.default !== undefined && (
|
||||
<div>默认值: {String(attrConfig.default)}</div>
|
||||
)}
|
||||
{attrConfig.values && (
|
||||
<div>
|
||||
可选值: {attrConfig.values.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag color="blue" style={{ cursor: "help" }}>
|
||||
{attrName}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.requires_children && (
|
||||
<div>
|
||||
<strong>子元素:</strong>
|
||||
<Tag color="green">需要 <{config.child_tag}></Tag>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default as TagSelector } from "./TagSelector";
|
||||
export { default as TagBrowser } from "./TagBrowser";
|
||||
export { TagInfoPanel } from "./TagSelector";
|
||||
export { default as TagSelector } from "./TagSelector";
|
||||
export { default as TagBrowser } from "./TagBrowser";
|
||||
export { TagInfoPanel } from "./TagSelector";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default as TemplateList } from "./TemplateList";
|
||||
export { default as TemplateForm } from "./TemplateForm";
|
||||
export { default as TemplateDetail } from "./TemplateDetail";
|
||||
export { TagBrowser, TagSelector, TagInfoPanel } from "./components";
|
||||
export { default as TemplateList } from "./TemplateList";
|
||||
export { default as TemplateForm } from "./TemplateForm";
|
||||
export { default as TemplateDetail } from "./TemplateDetail";
|
||||
export { TagBrowser, TagSelector, TagInfoPanel } from "./components";
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import { get, post, put, del } from "@/utils/request";
|
||||
|
||||
// 标注任务管理相关接口
|
||||
export function queryAnnotationTasksUsingGet(params?: any) {
|
||||
return get("/api/annotation/project", params);
|
||||
}
|
||||
|
||||
export function createAnnotationTaskUsingPost(data: any) {
|
||||
return post("/api/annotation/project", data);
|
||||
}
|
||||
|
||||
export function syncAnnotationTaskUsingPost(data: any) {
|
||||
return post(`/api/annotation/task/sync`, data);
|
||||
}
|
||||
|
||||
export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) {
|
||||
// Backend expects mapping UUID as path parameter
|
||||
return del(`/api/annotation/project/${mappingId}`);
|
||||
}
|
||||
|
||||
export function loginAnnotationUsingGet(mappingId: string) {
|
||||
return get("/api/annotation/project/${mappingId}/login");
|
||||
}
|
||||
|
||||
// 标签配置管理
|
||||
export function getTagConfigUsingGet() {
|
||||
return get("/api/annotation/tags/config");
|
||||
}
|
||||
|
||||
// 标注模板管理
|
||||
export function queryAnnotationTemplatesUsingGet(params?: any) {
|
||||
return get("/api/annotation/template", params);
|
||||
}
|
||||
|
||||
export function createAnnotationTemplateUsingPost(data: any) {
|
||||
return post("/api/annotation/template", data);
|
||||
}
|
||||
|
||||
export function updateAnnotationTemplateByIdUsingPut(
|
||||
templateId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return put(`/api/annotation/template/${templateId}`, data);
|
||||
}
|
||||
|
||||
export function deleteAnnotationTemplateByIdUsingDelete(
|
||||
templateId: string | number
|
||||
) {
|
||||
return del(`/api/annotation/template/${templateId}`);
|
||||
}
|
||||
import { get, post, put, del } from "@/utils/request";
|
||||
|
||||
// 标注任务管理相关接口
|
||||
export function queryAnnotationTasksUsingGet(params?: any) {
|
||||
return get("/api/annotation/project", params);
|
||||
}
|
||||
|
||||
export function createAnnotationTaskUsingPost(data: any) {
|
||||
return post("/api/annotation/project", data);
|
||||
}
|
||||
|
||||
export function syncAnnotationTaskUsingPost(data: any) {
|
||||
return post(`/api/annotation/task/sync`, data);
|
||||
}
|
||||
|
||||
export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) {
|
||||
// Backend expects mapping UUID as path parameter
|
||||
return del(`/api/annotation/project/${mappingId}`);
|
||||
}
|
||||
|
||||
export function loginAnnotationUsingGet(mappingId: string) {
|
||||
return get("/api/annotation/project/${mappingId}/login");
|
||||
}
|
||||
|
||||
// 标签配置管理
|
||||
export function getTagConfigUsingGet() {
|
||||
return get("/api/annotation/tags/config");
|
||||
}
|
||||
|
||||
// 标注模板管理
|
||||
export function queryAnnotationTemplatesUsingGet(params?: any) {
|
||||
return get("/api/annotation/template", params);
|
||||
}
|
||||
|
||||
export function createAnnotationTemplateUsingPost(data: any) {
|
||||
return post("/api/annotation/template", data);
|
||||
}
|
||||
|
||||
export function updateAnnotationTemplateByIdUsingPut(
|
||||
templateId: string | number,
|
||||
data: any
|
||||
) {
|
||||
return put(`/api/annotation/template/${templateId}`, data);
|
||||
}
|
||||
|
||||
export function deleteAnnotationTemplateByIdUsingDelete(
|
||||
templateId: string | number
|
||||
) {
|
||||
return del(`/api/annotation/template/${templateId}`);
|
||||
}
|
||||
|
||||
@@ -1,140 +1,140 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import {AnnotationTaskStatus, AnnotationType, Classification, DataType, TemplateType} from "./annotation.model";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
export const AnnotationTaskStatusMap = {
|
||||
[AnnotationTaskStatus.ACTIVE]: {
|
||||
label: "活跃",
|
||||
value: AnnotationTaskStatus.ACTIVE,
|
||||
color: "#409f17ff",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
[AnnotationTaskStatus.PROCESSING]: {
|
||||
label: "处理中",
|
||||
value: AnnotationTaskStatus.PROCESSING,
|
||||
color: "#2673e5",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
[AnnotationTaskStatus.INACTIVE]: {
|
||||
label: "未激活",
|
||||
value: AnnotationTaskStatus.INACTIVE,
|
||||
color: "#4f4444ff",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
export function mapAnnotationTask(task: any) {
|
||||
// Normalize labeling project id from possible backend field names
|
||||
const labelingProjId = task?.labelingProjId || task?.labelingProjectId || task?.projId || task?.labeling_project_id || "";
|
||||
|
||||
const statsArray = task?.statistics
|
||||
? [
|
||||
{ label: "准确率", value: task.statistics.accuracy ?? "-" },
|
||||
{ label: "平均时长", value: task.statistics.averageTime ?? "-" },
|
||||
{ label: "待复核", value: task.statistics.reviewCount ?? "-" },
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
...task,
|
||||
id: task.id,
|
||||
// provide consistent field for components
|
||||
labelingProjId,
|
||||
projId: labelingProjId,
|
||||
name: task.name,
|
||||
description: task.description || "",
|
||||
datasetName: task.datasetName || task.dataset_name || "-",
|
||||
createdAt: task.createdAt || task.created_at || "-",
|
||||
updatedAt: task.updatedAt || task.updated_at || "-",
|
||||
icon: <StickyNote />,
|
||||
iconColor: "bg-blue-100",
|
||||
status: {
|
||||
label:
|
||||
task.status === "completed"
|
||||
? "已完成"
|
||||
: task.status === "processing"
|
||||
? "进行中"
|
||||
: task.status === "skipped"
|
||||
? "已跳过"
|
||||
: "待开始",
|
||||
color: "bg-blue-100",
|
||||
},
|
||||
statistics: statsArray,
|
||||
};
|
||||
}
|
||||
|
||||
export const DataTypeMap = {
|
||||
[DataType.TEXT]: {
|
||||
label: "文本",
|
||||
value: DataType.TEXT
|
||||
},
|
||||
[DataType.IMAGE]: {
|
||||
label: "图片",
|
||||
value: DataType.IMAGE
|
||||
},
|
||||
[DataType.AUDIO]: {
|
||||
label: "音频",
|
||||
value: DataType.AUDIO
|
||||
},
|
||||
[DataType.VIDEO]: {
|
||||
label: "视频",
|
||||
value: DataType.VIDEO
|
||||
},
|
||||
}
|
||||
|
||||
export const ClassificationMap = {
|
||||
[Classification.COMPUTER_VERSION]: {
|
||||
label: "计算机视觉",
|
||||
value: Classification.COMPUTER_VERSION
|
||||
},
|
||||
[Classification.NLP]: {
|
||||
label: "自然语言处理",
|
||||
value: Classification.NLP
|
||||
},
|
||||
[Classification.AUDIO]: {
|
||||
label: "音频",
|
||||
value: Classification.AUDIO
|
||||
},
|
||||
[Classification.QUALITY_CONTROL]: {
|
||||
label: "质量控制",
|
||||
value: Classification.QUALITY_CONTROL
|
||||
},
|
||||
[Classification.CUSTOM]: {
|
||||
label: "自定义",
|
||||
value: Classification.CUSTOM
|
||||
},
|
||||
}
|
||||
|
||||
export const AnnotationTypeMap = {
|
||||
[AnnotationType.CLASSIFICATION]: {
|
||||
label: "分类",
|
||||
value: AnnotationType.CLASSIFICATION
|
||||
},
|
||||
[AnnotationType.OBJECT_DETECTION]: {
|
||||
label: "目标检测",
|
||||
value: AnnotationType.OBJECT_DETECTION
|
||||
},
|
||||
[AnnotationType.SEGMENTATION]: {
|
||||
label: "分割",
|
||||
value: AnnotationType.SEGMENTATION
|
||||
},
|
||||
[AnnotationType.NER]: {
|
||||
label: "命名实体识别",
|
||||
value: AnnotationType.NER
|
||||
},
|
||||
}
|
||||
|
||||
export const TemplateTypeMap = {
|
||||
[TemplateType.SYSTEM]: {
|
||||
label: "系统内置",
|
||||
value: TemplateType.SYSTEM
|
||||
},
|
||||
[TemplateType.CUSTOM]: {
|
||||
label: "自定义",
|
||||
value: TemplateType.CUSTOM
|
||||
},
|
||||
import { StickyNote } from "lucide-react";
|
||||
import {AnnotationTaskStatus, AnnotationType, Classification, DataType, TemplateType} from "./annotation.model";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
export const AnnotationTaskStatusMap = {
|
||||
[AnnotationTaskStatus.ACTIVE]: {
|
||||
label: "活跃",
|
||||
value: AnnotationTaskStatus.ACTIVE,
|
||||
color: "#409f17ff",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
[AnnotationTaskStatus.PROCESSING]: {
|
||||
label: "处理中",
|
||||
value: AnnotationTaskStatus.PROCESSING,
|
||||
color: "#2673e5",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
[AnnotationTaskStatus.INACTIVE]: {
|
||||
label: "未激活",
|
||||
value: AnnotationTaskStatus.INACTIVE,
|
||||
color: "#4f4444ff",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
export function mapAnnotationTask(task: any) {
|
||||
// Normalize labeling project id from possible backend field names
|
||||
const labelingProjId = task?.labelingProjId || task?.labelingProjectId || task?.projId || task?.labeling_project_id || "";
|
||||
|
||||
const statsArray = task?.statistics
|
||||
? [
|
||||
{ label: "准确率", value: task.statistics.accuracy ?? "-" },
|
||||
{ label: "平均时长", value: task.statistics.averageTime ?? "-" },
|
||||
{ label: "待复核", value: task.statistics.reviewCount ?? "-" },
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
...task,
|
||||
id: task.id,
|
||||
// provide consistent field for components
|
||||
labelingProjId,
|
||||
projId: labelingProjId,
|
||||
name: task.name,
|
||||
description: task.description || "",
|
||||
datasetName: task.datasetName || task.dataset_name || "-",
|
||||
createdAt: task.createdAt || task.created_at || "-",
|
||||
updatedAt: task.updatedAt || task.updated_at || "-",
|
||||
icon: <StickyNote />,
|
||||
iconColor: "bg-blue-100",
|
||||
status: {
|
||||
label:
|
||||
task.status === "completed"
|
||||
? "已完成"
|
||||
: task.status === "processing"
|
||||
? "进行中"
|
||||
: task.status === "skipped"
|
||||
? "已跳过"
|
||||
: "待开始",
|
||||
color: "bg-blue-100",
|
||||
},
|
||||
statistics: statsArray,
|
||||
};
|
||||
}
|
||||
|
||||
export const DataTypeMap = {
|
||||
[DataType.TEXT]: {
|
||||
label: "文本",
|
||||
value: DataType.TEXT
|
||||
},
|
||||
[DataType.IMAGE]: {
|
||||
label: "图片",
|
||||
value: DataType.IMAGE
|
||||
},
|
||||
[DataType.AUDIO]: {
|
||||
label: "音频",
|
||||
value: DataType.AUDIO
|
||||
},
|
||||
[DataType.VIDEO]: {
|
||||
label: "视频",
|
||||
value: DataType.VIDEO
|
||||
},
|
||||
}
|
||||
|
||||
export const ClassificationMap = {
|
||||
[Classification.COMPUTER_VERSION]: {
|
||||
label: "计算机视觉",
|
||||
value: Classification.COMPUTER_VERSION
|
||||
},
|
||||
[Classification.NLP]: {
|
||||
label: "自然语言处理",
|
||||
value: Classification.NLP
|
||||
},
|
||||
[Classification.AUDIO]: {
|
||||
label: "音频",
|
||||
value: Classification.AUDIO
|
||||
},
|
||||
[Classification.QUALITY_CONTROL]: {
|
||||
label: "质量控制",
|
||||
value: Classification.QUALITY_CONTROL
|
||||
},
|
||||
[Classification.CUSTOM]: {
|
||||
label: "自定义",
|
||||
value: Classification.CUSTOM
|
||||
},
|
||||
}
|
||||
|
||||
export const AnnotationTypeMap = {
|
||||
[AnnotationType.CLASSIFICATION]: {
|
||||
label: "分类",
|
||||
value: AnnotationType.CLASSIFICATION
|
||||
},
|
||||
[AnnotationType.OBJECT_DETECTION]: {
|
||||
label: "目标检测",
|
||||
value: AnnotationType.OBJECT_DETECTION
|
||||
},
|
||||
[AnnotationType.SEGMENTATION]: {
|
||||
label: "分割",
|
||||
value: AnnotationType.SEGMENTATION
|
||||
},
|
||||
[AnnotationType.NER]: {
|
||||
label: "命名实体识别",
|
||||
value: AnnotationType.NER
|
||||
},
|
||||
}
|
||||
|
||||
export const TemplateTypeMap = {
|
||||
[TemplateType.SYSTEM]: {
|
||||
label: "系统内置",
|
||||
value: TemplateType.SYSTEM
|
||||
},
|
||||
[TemplateType.CUSTOM]: {
|
||||
label: "自定义",
|
||||
value: TemplateType.CUSTOM
|
||||
},
|
||||
}
|
||||
@@ -1,107 +1,107 @@
|
||||
import type { DatasetType } from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
export enum AnnotationTaskStatus {
|
||||
ACTIVE = "active",
|
||||
INACTIVE = "inactive",
|
||||
PROCESSING = "processing",
|
||||
COMPLETED = "completed",
|
||||
SKIPPED = "skipped",
|
||||
}
|
||||
|
||||
export interface AnnotationTask {
|
||||
id: string;
|
||||
name: string;
|
||||
labelingProjId: string;
|
||||
datasetId: string;
|
||||
|
||||
annotationCount: number;
|
||||
|
||||
description?: string;
|
||||
assignedTo?: string;
|
||||
progress: number;
|
||||
statistics: {
|
||||
accuracy: number;
|
||||
averageTime: number;
|
||||
reviewCount: number;
|
||||
};
|
||||
status: AnnotationTaskStatus;
|
||||
totalDataCount: number;
|
||||
type: DatasetType;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 标注模板相关类型
|
||||
export interface LabelDefinition {
|
||||
fromName: string;
|
||||
toName: string;
|
||||
type: string;
|
||||
options?: string[];
|
||||
labels?: string[];
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ObjectDefinition {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TemplateConfiguration {
|
||||
labels: LabelDefinition[];
|
||||
objects: ObjectDefinition[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AnnotationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
dataType: string;
|
||||
labelingType: string;
|
||||
configuration: TemplateConfiguration;
|
||||
labelConfig?: string;
|
||||
style: string;
|
||||
category: string;
|
||||
builtIn: boolean;
|
||||
version: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AnnotationTemplateListResponse {
|
||||
content: AnnotationTemplate[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export enum DataType {
|
||||
TEXT = "text",
|
||||
IMAGE = "image",
|
||||
AUDIO = "audio",
|
||||
VIDEO = "video",
|
||||
}
|
||||
|
||||
export enum Classification {
|
||||
COMPUTER_VERSION = "computer-vision",
|
||||
NLP = "nlp",
|
||||
AUDIO = "audio",
|
||||
QUALITY_CONTROL = "quality-control",
|
||||
CUSTOM = "custom"
|
||||
}
|
||||
|
||||
export enum AnnotationType {
|
||||
CLASSIFICATION = "classification",
|
||||
OBJECT_DETECTION = "object-detection",
|
||||
SEGMENTATION = "segmentation",
|
||||
NER = "ner"
|
||||
}
|
||||
|
||||
export enum TemplateType {
|
||||
SYSTEM = "true",
|
||||
CUSTOM = "false"
|
||||
}
|
||||
import type { DatasetType } from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
export enum AnnotationTaskStatus {
|
||||
ACTIVE = "active",
|
||||
INACTIVE = "inactive",
|
||||
PROCESSING = "processing",
|
||||
COMPLETED = "completed",
|
||||
SKIPPED = "skipped",
|
||||
}
|
||||
|
||||
export interface AnnotationTask {
|
||||
id: string;
|
||||
name: string;
|
||||
labelingProjId: string;
|
||||
datasetId: string;
|
||||
|
||||
annotationCount: number;
|
||||
|
||||
description?: string;
|
||||
assignedTo?: string;
|
||||
progress: number;
|
||||
statistics: {
|
||||
accuracy: number;
|
||||
averageTime: number;
|
||||
reviewCount: number;
|
||||
};
|
||||
status: AnnotationTaskStatus;
|
||||
totalDataCount: number;
|
||||
type: DatasetType;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 标注模板相关类型
|
||||
export interface LabelDefinition {
|
||||
fromName: string;
|
||||
toName: string;
|
||||
type: string;
|
||||
options?: string[];
|
||||
labels?: string[];
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ObjectDefinition {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TemplateConfiguration {
|
||||
labels: LabelDefinition[];
|
||||
objects: ObjectDefinition[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AnnotationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
dataType: string;
|
||||
labelingType: string;
|
||||
configuration: TemplateConfiguration;
|
||||
labelConfig?: string;
|
||||
style: string;
|
||||
category: string;
|
||||
builtIn: boolean;
|
||||
version: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AnnotationTemplateListResponse {
|
||||
content: AnnotationTemplate[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export enum DataType {
|
||||
TEXT = "text",
|
||||
IMAGE = "image",
|
||||
AUDIO = "audio",
|
||||
VIDEO = "video",
|
||||
}
|
||||
|
||||
export enum Classification {
|
||||
COMPUTER_VERSION = "computer-vision",
|
||||
NLP = "nlp",
|
||||
AUDIO = "audio",
|
||||
QUALITY_CONTROL = "quality-control",
|
||||
CUSTOM = "custom"
|
||||
}
|
||||
|
||||
export enum AnnotationType {
|
||||
CLASSIFICATION = "classification",
|
||||
OBJECT_DETECTION = "object-detection",
|
||||
SEGMENTATION = "segmentation",
|
||||
NER = "ner"
|
||||
}
|
||||
|
||||
export enum TemplateType {
|
||||
SYSTEM = "true",
|
||||
CUSTOM = "false"
|
||||
}
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
/**
|
||||
* Label Studio Tag Configuration Types
|
||||
* Corresponds to runtime/datamate-python/app/module/annotation/config/label_studio_tags.yaml
|
||||
*/
|
||||
|
||||
export interface TagAttributeConfig {
|
||||
type?: "boolean" | "number" | "string";
|
||||
values?: string[];
|
||||
default?: any;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TagConfig {
|
||||
description: string;
|
||||
required_attrs: string[];
|
||||
optional_attrs?: Record<string, TagAttributeConfig>;
|
||||
requires_children?: boolean;
|
||||
child_tag?: string;
|
||||
child_required_attrs?: string[];
|
||||
category?: string; // e.g., "labeling" or "layout" for controls; "image", "text", etc. for objects
|
||||
}
|
||||
|
||||
export interface LabelStudioTagConfig {
|
||||
objects: Record<string, TagConfig>;
|
||||
controls: Record<string, TagConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-friendly representation of a tag for selection
|
||||
*/
|
||||
export interface TagOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: "object" | "control";
|
||||
requiresChildren: boolean;
|
||||
childTag?: string;
|
||||
requiredAttrs: string[];
|
||||
optionalAttrs?: Record<string, TagAttributeConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend tag config to frontend tag options
|
||||
* @param config - The full tag configuration from backend
|
||||
* @param includeLabelingOnly - If true, only include controls with category="labeling" (default: true)
|
||||
*/
|
||||
export function parseTagConfig(
|
||||
config: LabelStudioTagConfig,
|
||||
includeLabelingOnly: boolean = true
|
||||
): {
|
||||
objectOptions: TagOption[];
|
||||
controlOptions: TagOption[];
|
||||
} {
|
||||
const objectOptions: TagOption[] = Object.entries(config.objects).map(
|
||||
([key, value]) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
description: value.description,
|
||||
category: "object" as const,
|
||||
requiresChildren: value.requires_children || false,
|
||||
childTag: value.child_tag,
|
||||
requiredAttrs: value.required_attrs,
|
||||
optionalAttrs: value.optional_attrs,
|
||||
})
|
||||
);
|
||||
|
||||
const controlOptions: TagOption[] = Object.entries(config.controls)
|
||||
.filter(([_, value]) => {
|
||||
// If includeLabelingOnly is true, filter out layout controls
|
||||
if (includeLabelingOnly) {
|
||||
return value.category === "labeling";
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(([key, value]) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
description: value.description,
|
||||
category: "control" as const,
|
||||
requiresChildren: value.requires_children || false,
|
||||
childTag: value.child_tag,
|
||||
requiredAttrs: value.required_attrs,
|
||||
optionalAttrs: value.optional_attrs,
|
||||
}));
|
||||
|
||||
return { objectOptions, controlOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly display name for control types
|
||||
*/
|
||||
export function getControlDisplayName(controlType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
Choices: "选项 (单选/多选)",
|
||||
RectangleLabels: "矩形框",
|
||||
PolygonLabels: "多边形",
|
||||
Labels: "标签",
|
||||
TextArea: "文本区域",
|
||||
Rating: "评分",
|
||||
Taxonomy: "分类树",
|
||||
Ranker: "排序",
|
||||
List: "列表",
|
||||
BrushLabels: "画笔分割",
|
||||
EllipseLabels: "椭圆",
|
||||
KeyPointLabels: "关键点",
|
||||
Rectangle: "矩形",
|
||||
Polygon: "多边形",
|
||||
Ellipse: "椭圆",
|
||||
KeyPoint: "关键点",
|
||||
Brush: "画笔",
|
||||
Number: "数字输入",
|
||||
DateTime: "日期时间",
|
||||
Relation: "关系",
|
||||
Relations: "关系组",
|
||||
Pairwise: "成对比较",
|
||||
};
|
||||
|
||||
return displayNames[controlType] || controlType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly display name for object types
|
||||
*/
|
||||
export function getObjectDisplayName(objectType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
Image: "图像",
|
||||
Text: "文本",
|
||||
Audio: "音频",
|
||||
Video: "视频",
|
||||
HyperText: "HTML内容",
|
||||
PDF: "PDF文档",
|
||||
Markdown: "Markdown内容",
|
||||
Paragraphs: "段落",
|
||||
Table: "表格",
|
||||
AudioPlus: "高级音频",
|
||||
Timeseries: "时间序列",
|
||||
Vector: "向量数据",
|
||||
Chat: "对话数据",
|
||||
};
|
||||
|
||||
return displayNames[objectType] || objectType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group control types by common usage patterns
|
||||
*/
|
||||
export function getControlGroups(): Record<
|
||||
string,
|
||||
{ label: string; controls: string[] }
|
||||
> {
|
||||
return {
|
||||
classification: {
|
||||
label: "分类标注",
|
||||
controls: ["Choices", "Taxonomy", "Labels", "Rating"],
|
||||
},
|
||||
detection: {
|
||||
label: "目标检测",
|
||||
controls: [
|
||||
"RectangleLabels",
|
||||
"PolygonLabels",
|
||||
"EllipseLabels",
|
||||
"KeyPointLabels",
|
||||
"Rectangle",
|
||||
"Polygon",
|
||||
"Ellipse",
|
||||
"KeyPoint",
|
||||
],
|
||||
},
|
||||
segmentation: {
|
||||
label: "分割标注",
|
||||
controls: ["BrushLabels", "Brush", "BitmaskLabels", "MagicWand"],
|
||||
},
|
||||
text: {
|
||||
label: "文本输入",
|
||||
controls: ["TextArea", "Number", "DateTime"],
|
||||
},
|
||||
other: {
|
||||
label: "其他",
|
||||
controls: [
|
||||
"TimeseriesLabels",
|
||||
"VectorLabels",
|
||||
"ParagraphLabels",
|
||||
"VideoRectangle",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Label Studio Tag Configuration Types
|
||||
* Corresponds to runtime/datamate-python/app/module/annotation/config/label_studio_tags.yaml
|
||||
*/
|
||||
|
||||
export interface TagAttributeConfig {
|
||||
type?: "boolean" | "number" | "string";
|
||||
values?: string[];
|
||||
default?: any;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TagConfig {
|
||||
description: string;
|
||||
required_attrs: string[];
|
||||
optional_attrs?: Record<string, TagAttributeConfig>;
|
||||
requires_children?: boolean;
|
||||
child_tag?: string;
|
||||
child_required_attrs?: string[];
|
||||
category?: string; // e.g., "labeling" or "layout" for controls; "image", "text", etc. for objects
|
||||
}
|
||||
|
||||
export interface LabelStudioTagConfig {
|
||||
objects: Record<string, TagConfig>;
|
||||
controls: Record<string, TagConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-friendly representation of a tag for selection
|
||||
*/
|
||||
export interface TagOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: "object" | "control";
|
||||
requiresChildren: boolean;
|
||||
childTag?: string;
|
||||
requiredAttrs: string[];
|
||||
optionalAttrs?: Record<string, TagAttributeConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend tag config to frontend tag options
|
||||
* @param config - The full tag configuration from backend
|
||||
* @param includeLabelingOnly - If true, only include controls with category="labeling" (default: true)
|
||||
*/
|
||||
export function parseTagConfig(
|
||||
config: LabelStudioTagConfig,
|
||||
includeLabelingOnly: boolean = true
|
||||
): {
|
||||
objectOptions: TagOption[];
|
||||
controlOptions: TagOption[];
|
||||
} {
|
||||
const objectOptions: TagOption[] = Object.entries(config.objects).map(
|
||||
([key, value]) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
description: value.description,
|
||||
category: "object" as const,
|
||||
requiresChildren: value.requires_children || false,
|
||||
childTag: value.child_tag,
|
||||
requiredAttrs: value.required_attrs,
|
||||
optionalAttrs: value.optional_attrs,
|
||||
})
|
||||
);
|
||||
|
||||
const controlOptions: TagOption[] = Object.entries(config.controls)
|
||||
.filter(([_, value]) => {
|
||||
// If includeLabelingOnly is true, filter out layout controls
|
||||
if (includeLabelingOnly) {
|
||||
return value.category === "labeling";
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(([key, value]) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
description: value.description,
|
||||
category: "control" as const,
|
||||
requiresChildren: value.requires_children || false,
|
||||
childTag: value.child_tag,
|
||||
requiredAttrs: value.required_attrs,
|
||||
optionalAttrs: value.optional_attrs,
|
||||
}));
|
||||
|
||||
return { objectOptions, controlOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly display name for control types
|
||||
*/
|
||||
export function getControlDisplayName(controlType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
Choices: "选项 (单选/多选)",
|
||||
RectangleLabels: "矩形框",
|
||||
PolygonLabels: "多边形",
|
||||
Labels: "标签",
|
||||
TextArea: "文本区域",
|
||||
Rating: "评分",
|
||||
Taxonomy: "分类树",
|
||||
Ranker: "排序",
|
||||
List: "列表",
|
||||
BrushLabels: "画笔分割",
|
||||
EllipseLabels: "椭圆",
|
||||
KeyPointLabels: "关键点",
|
||||
Rectangle: "矩形",
|
||||
Polygon: "多边形",
|
||||
Ellipse: "椭圆",
|
||||
KeyPoint: "关键点",
|
||||
Brush: "画笔",
|
||||
Number: "数字输入",
|
||||
DateTime: "日期时间",
|
||||
Relation: "关系",
|
||||
Relations: "关系组",
|
||||
Pairwise: "成对比较",
|
||||
};
|
||||
|
||||
return displayNames[controlType] || controlType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly display name for object types
|
||||
*/
|
||||
export function getObjectDisplayName(objectType: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
Image: "图像",
|
||||
Text: "文本",
|
||||
Audio: "音频",
|
||||
Video: "视频",
|
||||
HyperText: "HTML内容",
|
||||
PDF: "PDF文档",
|
||||
Markdown: "Markdown内容",
|
||||
Paragraphs: "段落",
|
||||
Table: "表格",
|
||||
AudioPlus: "高级音频",
|
||||
Timeseries: "时间序列",
|
||||
Vector: "向量数据",
|
||||
Chat: "对话数据",
|
||||
};
|
||||
|
||||
return displayNames[objectType] || objectType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group control types by common usage patterns
|
||||
*/
|
||||
export function getControlGroups(): Record<
|
||||
string,
|
||||
{ label: string; controls: string[] }
|
||||
> {
|
||||
return {
|
||||
classification: {
|
||||
label: "分类标注",
|
||||
controls: ["Choices", "Taxonomy", "Labels", "Rating"],
|
||||
},
|
||||
detection: {
|
||||
label: "目标检测",
|
||||
controls: [
|
||||
"RectangleLabels",
|
||||
"PolygonLabels",
|
||||
"EllipseLabels",
|
||||
"KeyPointLabels",
|
||||
"Rectangle",
|
||||
"Polygon",
|
||||
"Ellipse",
|
||||
"KeyPoint",
|
||||
],
|
||||
},
|
||||
segmentation: {
|
||||
label: "分割标注",
|
||||
controls: ["BrushLabels", "Brush", "BitmaskLabels", "MagicWand"],
|
||||
},
|
||||
text: {
|
||||
label: "文本输入",
|
||||
controls: ["TextArea", "Number", "DateTime"],
|
||||
},
|
||||
other: {
|
||||
label: "其他",
|
||||
controls: [
|
||||
"TimeseriesLabels",
|
||||
"VectorLabels",
|
||||
"ParagraphLabels",
|
||||
"VideoRectangle",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user