init datamate

This commit is contained in:
Dallas98
2025-10-21 23:00:48 +08:00
commit 1c97afed7d
692 changed files with 135442 additions and 0 deletions

View File

@@ -0,0 +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>
);
}

View File

@@ -0,0 +1,713 @@
import { useState, useRef, useEffect } from "react";
import { Card, Button, Badge, Slider, message } from "antd";
import {
Play,
Pause,
Square,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Scissors,
Save,
CheckCircle,
Trash2,
Edit,
Mic,
AudioWaveformIcon as Waveform,
} from "lucide-react";
interface AudioSegment {
id: string;
startTime: number;
endTime: number;
transcription: string;
label: string;
confidence?: number;
speaker?: string;
}
interface AudioAnnotationWorkspaceProps {
task: any;
currentFileIndex: number;
onSaveAndNext: () => void;
onSkipAndNext: () => void;
}
// 模拟音频数据
const mockAudioFiles = [
{
id: "1",
name: "interview_001.wav",
url: "/placeholder-audio.mp3", // 这里应该是实际的音频文件URL
duration: 180, // 3分钟
segments: [
{
id: "1",
startTime: 0,
endTime: 15,
transcription: "你好,欢迎参加今天的访谈。请先介绍一下自己。",
label: "问题",
confidence: 0.95,
speaker: "主持人",
},
{
id: "2",
startTime: 15,
endTime: 45,
transcription:
"大家好,我是张三,目前在一家科技公司担任产品经理,有五年的工作经验。",
label: "回答",
confidence: 0.88,
speaker: "受访者",
},
{
id: "3",
startTime: 45,
endTime: 60,
transcription: "很好,那么请谈谈你对人工智能发展的看法。",
label: "问题",
confidence: 0.92,
speaker: "主持人",
},
],
},
];
// 预定义标签
const audioLabels = [
{ name: "问题", color: "#3B82F6" },
{ name: "回答", color: "#10B981" },
{ name: "讨论", color: "#F59E0B" },
{ name: "总结", color: "#EF4444" },
{ name: "背景音", color: "#8B5CF6" },
{ name: "其他", color: "#6B7280" },
];
export default function AudioAnnotationWorkspace({
task,
currentFileIndex,
onSaveAndNext,
onSkipAndNext,
}: AudioAnnotationWorkspaceProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentAudio] = useState(mockAudioFiles[0]);
const [segments, setSegments] = useState<AudioSegment[]>(
currentAudio.segments
);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(currentAudio.duration);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [selectedSegment, setSelectedSegment] = useState<string | null>(null);
const [isCreatingSegment, setIsCreatingSegment] = useState(false);
const [newSegmentStart, setNewSegmentStart] = useState(0);
const [editingSegment, setEditingSegment] = useState<AudioSegment | null>(
null
);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const updateTime = () => setCurrentTime(audio.currentTime);
const updateDuration = () => setDuration(audio.duration);
const handleEnded = () => setIsPlaying(false);
audio.addEventListener("timeupdate", updateTime);
audio.addEventListener("loadedmetadata", updateDuration);
audio.addEventListener("ended", handleEnded);
return () => {
audio.removeEventListener("timeupdate", updateTime);
audio.removeEventListener("loadedmetadata", updateDuration);
audio.removeEventListener("ended", handleEnded);
};
}, []);
const togglePlayPause = () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
setIsPlaying(!isPlaying);
};
const handleSeek = (time: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = time;
setCurrentTime(time);
};
const handleVolumeChange = (value: number[]) => {
const audio = audioRef.current;
if (!audio) return;
const newVolume = value[0];
audio.volume = newVolume;
setVolume(newVolume);
setIsMuted(newVolume === 0);
};
const toggleMute = () => {
const audio = audioRef.current;
if (!audio) return;
if (isMuted) {
audio.volume = volume;
setIsMuted(false);
} else {
audio.volume = 0;
setIsMuted(true);
}
};
const startCreatingSegment = () => {
setIsCreatingSegment(true);
setNewSegmentStart(currentTime);
toast({
title: "开始创建片段",
description: `片段起始时间: ${formatTime(currentTime)}`,
});
};
const finishCreatingSegment = () => {
if (!isCreatingSegment) return;
const newSegment: AudioSegment = {
id: Date.now().toString(),
startTime: newSegmentStart,
endTime: currentTime,
transcription: "",
label: audioLabels[0].name,
speaker: "",
};
setSegments([...segments, newSegment]);
setIsCreatingSegment(false);
setEditingSegment(newSegment);
toast({
title: "片段已创建",
description: `时长: ${formatTime(currentTime - newSegmentStart)}`,
});
};
const deleteSegment = (id: string) => {
setSegments(segments.filter((s) => s.id !== id));
setSelectedSegment(null);
toast({
title: "片段已删除",
description: "音频片段已被删除",
});
};
const updateSegment = (updatedSegment: AudioSegment) => {
setSegments(
segments.map((s) => (s.id === updatedSegment.id ? updatedSegment : s))
);
setEditingSegment(null);
toast({
title: "片段已更新",
description: "转录内容已保存",
});
};
const playSegment = (segment: AudioSegment) => {
handleSeek(segment.startTime);
setSelectedSegment(segment.id);
const audio = audioRef.current;
if (!audio) return;
audio.play();
setIsPlaying(true);
// 在片段结束时暂停
const checkEnd = () => {
if (audio.currentTime >= segment.endTime) {
audio.pause();
setIsPlaying(false);
audio.removeEventListener("timeupdate", checkEnd);
}
};
audio.addEventListener("timeupdate", checkEnd);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
const getSegmentColor = (label: string) => {
const labelConfig = audioLabels.find((l) => l.name === label);
return labelConfig?.color || "#6B7280";
};
return (
<div className="flex-1 flex flex-col">
{/* Audio Player */}
<div className="border-b bg-white p-4">
<div className="space-y-4">
{/* Audio Element */}
<audio ref={audioRef} src={currentAudio.url} preload="metadata" />
{/* Player Controls */}
<div className="flex items-center justify-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => handleSeek(Math.max(0, currentTime - 10))}
>
<SkipBack className="w-4 h-4" />
</Button>
<Button
onClick={togglePlayPause}
size="lg"
className="bg-blue-600 hover:bg-blue-700"
>
{isPlaying ? (
<Pause className="w-6 h-6" />
) : (
<Play className="w-6 h-6" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleSeek(Math.min(duration, currentTime + 10))}
>
<SkipForward className="w-4 h-4" />
</Button>
</div>
{/* Timeline */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm text-gray-600">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="relative">
<Slider
value={[currentTime]}
max={duration}
step={0.1}
onValueChange={(value) => handleSeek(value[0])}
className="w-full"
/>
{/* Segment Visualization */}
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
{segments.map((segment) => {
const left = (segment.startTime / duration) * 100;
const width =
((segment.endTime - segment.startTime) / duration) * 100;
return (
<div
key={segment.id}
className="absolute top-0 h-full opacity-30 rounded"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: getSegmentColor(segment.label),
}}
/>
);
})}
</div>
{/* Current Creating Segment */}
{isCreatingSegment && (
<div
className="absolute top-0 h-full bg-red-400 opacity-50 rounded"
style={{
left: `${(newSegmentStart / duration) * 100}%`,
width: `${
((currentTime - newSegmentStart) / duration) * 100
}%`,
}}
/>
)}
</div>
</div>
{/* Volume Control */}
<div className="flex items-center justify-center space-x-2">
<Button variant="ghost" size="sm" onClick={toggleMute}>
{isMuted ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</Button>
<Slider
value={[isMuted ? 0 : volume]}
max={1}
step={0.1}
onValueChange={handleVolumeChange}
className="w-24"
/>
</div>
{/* Annotation Controls */}
<div className="flex items-center justify-center space-x-2">
{isCreatingSegment ? (
<Button
onClick={finishCreatingSegment}
className="bg-green-600 hover:bg-green-700"
>
<Square className="w-4 h-4 mr-2" />
</Button>
) : (
<Button onClick={startCreatingSegment} variant="outline">
<Scissors className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex">
{/* Segments List */}
<div className="w-96 border-r bg-gray-50 p-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium"></h3>
<Badge variant="outline">{segments.length} </Badge>
</div>
<ScrollArea className="h-96">
<div className="space-y-2">
{segments.map((segment) => (
<Card
key={segment.id}
className={`cursor-pointer transition-colors ${
selectedSegment === segment.id
? "border-blue-500 bg-blue-50"
: "hover:bg-gray-50"
}`}
onClick={() => setSelectedSegment(segment.id)}
>
<CardContent className="p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded"
style={{
backgroundColor: getSegmentColor(segment.label),
}}
/>
<span className="text-sm font-medium">
{segment.label}
</span>
</div>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
className="p-1 h-auto"
onClick={(e) => {
e.stopPropagation();
playSegment(segment);
}}
>
<Play className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="p-1 h-auto"
onClick={(e) => {
e.stopPropagation();
setEditingSegment(segment);
}}
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="p-1 h-auto text-red-500"
onClick={(e) => {
e.stopPropagation();
deleteSegment(segment.id);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="text-xs text-gray-500">
{formatTime(segment.startTime)} -{" "}
{formatTime(segment.endTime)}
{segment.speaker && ` | ${segment.speaker}`}
</div>
<p className="text-sm text-gray-700 line-clamp-2">
{segment.transcription || "未转录"}
</p>
{segment.confidence && (
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">
:
</span>
<Badge variant="outline" className="text-xs">
{(segment.confidence * 100).toFixed(1)}%
</Badge>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
</div>
</div>
{/* Transcription Editor */}
<div className="flex-1 p-6">
{editingSegment ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Mic className="w-5 h-5" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium"></label>
<Input
value={formatTime(editingSegment.startTime)}
readOnly
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium"></label>
<Input
value={formatTime(editingSegment.endTime)}
readOnly
className="mt-1"
/>
</div>
</div>
<div>
<label className="text-sm font-medium"></label>
<select
value={editingSegment.label}
onChange={(e) =>
setEditingSegment({
...editingSegment,
label: e.target.value,
})
}
className="w-full mt-1 px-3 py-2 border rounded-md"
>
{audioLabels.map((label) => (
<option key={label.name} value={label.name}>
{label.name}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium"></label>
<Input
value={editingSegment.speaker || ""}
onChange={(e) =>
setEditingSegment({
...editingSegment,
speaker: e.target.value,
})
}
placeholder="输入说话人名称"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium"></label>
<Textarea
value={editingSegment.transcription}
onChange={(e) =>
setEditingSegment({
...editingSegment,
transcription: e.target.value,
})
}
placeholder="输入或编辑转录内容..."
rows={6}
className="mt-1"
/>
</div>
<div className="flex items-center justify-end space-x-2">
<Button
variant="outline"
onClick={() => setEditingSegment(null)}
>
</Button>
<Button
onClick={() => updateSegment(editingSegment)}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
) : selectedSegment ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Waveform className="w-5 h-5" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const segment = segments.find(
(s) => s.id === selectedSegment
);
if (!segment) return null;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm text-gray-500">
</span>
<p className="font-medium">
{formatTime(segment.startTime)} -{" "}
{formatTime(segment.endTime)}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium">
{formatTime(segment.endTime - segment.startTime)}
</p>
</div>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<div className="flex items-center space-x-2 mt-1">
<div
className="w-3 h-3 rounded"
style={{
backgroundColor: getSegmentColor(segment.label),
}}
/>
<span className="font-medium">{segment.label}</span>
</div>
</div>
{segment.speaker && (
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium">{segment.speaker}</p>
</div>
)}
<div>
<span className="text-sm text-gray-500"></span>
<p className="mt-1 p-3 bg-gray-50 rounded text-sm">
{segment.transcription || "暂无转录内容"}
</p>
</div>
{segment.confidence && (
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium">
{(segment.confidence * 100).toFixed(1)}%
</p>
</div>
)}
<div className="flex items-center space-x-2">
<Button
onClick={() => playSegment(segment)}
variant="outline"
>
<Play className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => setEditingSegment(segment)}
variant="outline"
>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
);
})()}
</CardContent>
</Card>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Mic 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 mb-4">
</p>
<div className="space-y-2 text-sm text-gray-600">
<p> "创建片段"</p>
<p> </p>
<p> 使</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Bottom Actions */}
<div className="border-t bg-white p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
: {currentAudio.name} | : {segments.length} | :{" "}
{formatTime(duration)}
</div>
<div className="flex items-center space-x-2">
<Button onClick={onSkipAndNext} variant="outline">
</Button>
<Button
onClick={onSaveAndNext}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,617 @@
import type React from "react";
import { useState, useRef, useEffect } from "react";
import { Button, Badge, Checkbox, message } from "antd";
import {
Square,
Circle,
MousePointer,
ZoomIn,
ZoomOut,
RotateCcw,
ArrowLeft,
ArrowRight,
MoreHorizontal,
} from "lucide-react";
interface Annotation {
id: string;
type: "rectangle" | "circle" | "polygon";
label: string;
color: string;
coordinates: number[];
visible: boolean;
}
interface ImageAnnotationWorkspaceProps {
task: any;
currentFileIndex: number;
onSaveAndNext: () => void;
onSkipAndNext: () => void;
}
// 模拟医学图像数据
const mockMedicalImages = [
{
id: "1",
name: "2024-123456",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide1",
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_e6dd5540-9ca4-4277-ad2b-4debaa1c8ddg.jpg-oibLbUmFpZMkLTmwZB7lT1UWKFlOLA.jpeg",
},
{
id: "2",
name: "2024-234567",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide2",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 2",
},
{
id: "3",
name: "2025-345678",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide3",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 3",
},
{
id: "4",
name: "1234-123456",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide4",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 4",
},
{
id: "5",
name: "2025-456789",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide5",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 5",
},
{
id: "6",
name: "2025-567890",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide6",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 6",
},
{
id: "7",
name: "2025-678901",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide7",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 7",
},
{
id: "8",
name: "2025-789012",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide8",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 8",
},
{
id: "9",
name: "2025-890123",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide9",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 9",
},
{
id: "10",
name: "2025-901234",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide10",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 10",
},
];
// 医学标注选项
const medicalAnnotationOptions = [
{
id: "tumor_present",
label: "是否有肿瘤",
type: "radio",
options: ["是", "否"],
},
{
id: "tumor_type",
label: "肿瘤形成",
type: "checkbox",
options: ["腺管形成"],
},
{ id: "grade_1", label: "1级", type: "checkbox", options: ["1[x]"] },
{ id: "grade_2", label: "2级", type: "checkbox", options: ["2[x]"] },
{ id: "remarks", label: "备注", type: "textarea" },
{
id: "nuclear_polymorphism",
label: "核多形性",
type: "checkbox",
options: ["核分裂象"],
},
{
id: "histological_type",
label: "组织学类型",
type: "checkbox",
options: ["1[b]", "2[y]", "3[t]"],
},
{
id: "small_time_lesion",
label: "小时病位置[3]",
type: "checkbox",
options: ["1[b]", "2[y]", "3[t]"],
},
{
id: "ductal_position",
label: "导管原位置[4]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "ductal_position_large",
label: "导管原位置件大于腺分",
type: "checkbox",
options: ["腺分裂象"],
},
{
id: "mitosis",
label: "化[5]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "original_position",
label: "原位实性乳头状[6]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "infiltrating_lesion",
label: "浸润性病(非特殊型)[7]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "infiltrating_small",
label: "浸润性小叶癌[8]",
type: "checkbox",
options: ["脉管侵犯"],
},
{
id: "infiltrating_real",
label: "浸润实性乳头状癌[9]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "other_lesion",
label: "其他病[0]",
type: "checkbox",
options: ["+[k]"],
},
];
export default function ImageAnnotationWorkspace({
currentFileIndex,
}: ImageAnnotationWorkspaceProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [selectedImageIndex, setSelectedImageIndex] = useState(
currentFileIndex || 0
);
const [currentImage, setCurrentImage] = useState(
mockMedicalImages[selectedImageIndex]
);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [selectedTool, setSelectedTool] = useState<
"select" | "rectangle" | "circle"
>("select");
const [isDrawing, setIsDrawing] = useState(false);
const [startPoint, setStartPoint] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [selectedAnnotation, setSelectedAnnotation] = useState<string | null>(
null
);
const [annotationValues, setAnnotationValues] = useState<Record<string, any>>(
{}
);
useEffect(() => {
setCurrentImage(mockMedicalImages[selectedImageIndex]);
drawCanvas();
}, [selectedImageIndex, annotations, zoom, pan]);
const drawCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
ctx.save();
ctx.scale(zoom, zoom);
ctx.translate(pan.x, pan.y);
ctx.drawImage(img, 0, 0, canvas.width / zoom, canvas.height / zoom);
// 绘制标注
annotations.forEach((annotation) => {
if (!annotation.visible) return;
ctx.strokeStyle = annotation.color;
ctx.fillStyle = annotation.color + "20";
ctx.lineWidth = 2;
if (annotation.type === "rectangle") {
const [x, y, width, height] = annotation.coordinates;
ctx.strokeRect(x, y, width, height);
ctx.fillRect(x, y, width, height);
} else if (annotation.type === "circle") {
const [centerX, centerY, radius] = annotation.coordinates;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.fill();
}
if (selectedAnnotation === annotation.id) {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 3;
ctx.setLineDash([5, 5]);
if (annotation.type === "rectangle") {
const [x, y, width, height] = annotation.coordinates;
ctx.strokeRect(x - 2, y - 2, width + 4, height + 4);
} else if (annotation.type === "circle") {
const [centerX, centerY, radius] = annotation.coordinates;
ctx.beginPath();
ctx.arc(centerX, centerY, radius + 2, 0, 2 * Math.PI);
ctx.stroke();
}
ctx.setLineDash([]);
}
});
ctx.restore();
};
img.src = currentImage.url;
};
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - pan.x) / zoom;
const y = (e.clientY - rect.top - pan.y) / zoom;
if (selectedTool === "rectangle" || selectedTool === "circle") {
setIsDrawing(true);
setStartPoint({ x, y });
} else if (selectedTool === "select") {
const clickedAnnotation = annotations.find((annotation) => {
if (annotation.type === "rectangle") {
const [ax, ay, width, height] = annotation.coordinates;
return x >= ax && x <= ax + width && y >= ay && y <= ay + height;
} else if (annotation.type === "circle") {
const [centerX, centerY, radius] = annotation.coordinates;
const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
return distance <= radius;
}
return false;
});
setSelectedAnnotation(clickedAnnotation?.id || null);
}
};
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - pan.x) / zoom;
const y = (e.clientY - rect.top - pan.y) / zoom;
drawCanvas();
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.scale(zoom, zoom);
ctx.translate(pan.x, pan.y);
ctx.strokeStyle = "#3B82F6";
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
if (selectedTool === "rectangle") {
const width = x - startPoint.x;
const height = y - startPoint.y;
ctx.strokeRect(startPoint.x, startPoint.y, width, height);
} else if (selectedTool === "circle") {
const radius = Math.sqrt(
(x - startPoint.x) ** 2 + (y - startPoint.y) ** 2
);
ctx.beginPath();
ctx.arc(startPoint.x, startPoint.y, radius, 0, 2 * Math.PI);
ctx.stroke();
}
ctx.restore();
};
const handleCanvasMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - pan.x) / zoom;
const y = (e.clientY - rect.top - pan.y) / zoom;
let coordinates: number[] = [];
if (selectedTool === "rectangle") {
const width = x - startPoint.x;
const height = y - startPoint.y;
coordinates = [startPoint.x, startPoint.y, width, height];
} else if (selectedTool === "circle") {
const radius = Math.sqrt(
(x - startPoint.x) ** 2 + (y - startPoint.y) ** 2
);
coordinates = [startPoint.x, startPoint.y, radius];
}
if (coordinates.length > 0) {
const newAnnotation: Annotation = {
id: Date.now().toString(),
type: selectedTool as "rectangle" | "circle",
label: "标注",
color: "#3B82F6",
coordinates,
visible: true,
};
setAnnotations([...annotations, newAnnotation]);
}
setIsDrawing(false);
};
const handleAnnotationValueChange = (optionId: string, value: any) => {
setAnnotationValues((prev) => ({
...prev,
[optionId]: value,
}));
};
const handleUpdate = () => {
message({
title: "标注已更新",
description: "医学标注信息已保存",
});
};
return (
<div className="h-screen flex">
{/* Left Sidebar - Image List */}
<div className="w-80 border-r bg-white">
{/* Header */}
<div className="p-4 border-b bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="outline">image</Badge>
<Badge className="bg-blue-100 text-blue-800">img</Badge>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">case_id</span>
<span className="text-sm font-mono">#13754</span>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center space-x-2">
<div className="w-6 h-6 bg-purple-500 rounded-full flex items-center justify-center text-white text-xs">
DE
</div>
<span className="text-sm text-gray-600">de #14803</span>
</div>
<span className="text-xs text-gray-500">11 days ago</span>
</div>
</div>
{/* Image List */}
<div className="p-2">
{mockMedicalImages.map((image, index) => (
<div
key={image.id}
className={`flex items-center p-3 mb-2 rounded-lg cursor-pointer transition-colors ${
selectedImageIndex === index
? "bg-blue-50 border border-blue-200"
: "hover:bg-gray-50"
}`}
onClick={() => setSelectedImageIndex(index)}
>
<div className="w-8 h-8 bg-gray-200 rounded flex items-center justify-center text-sm font-medium mr-3">
{index + 1}
</div>
<img
src={image.thumbnail || "/placeholder.svg"}
alt={`Slide ${index + 1}`}
className="w-12 h-12 rounded border mr-3"
/>
<div className="flex-1">
<div className="font-medium text-sm">{image.name}</div>
</div>
</div>
))}
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col">
{/* Main Image Display */}
<div className="flex-1 p-4">
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">WSI图像预览</h2>
<div className="flex items-center space-x-2">
<div className="text-sm text-gray-600">
: <span className="font-mono">1234-123456</span>
</div>
<div className="text-sm text-gray-600">
: <span></span>
</div>
</div>
</div>
<div className="flex-1 border rounded-lg overflow-hidden bg-gray-100 relative">
<canvas
ref={canvasRef}
width={800}
height={600}
className="w-full h-full object-contain cursor-crosshair"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
/>
{/* Zoom Controls */}
<div className="absolute bottom-4 left-4 flex items-center space-x-2 bg-white rounded-lg shadow-lg p-2">
<Button onClick={() => setZoom(Math.max(zoom / 1.2, 0.1))}>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-sm px-2">{Math.round(zoom * 100)}%</span>
<Button onClick={() => setZoom(Math.min(zoom * 1.2, 5))}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
onClick={() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}}
>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
{/* Tool Selection */}
<div className="absolute top-4 left-4 flex items-center space-x-2 bg-white rounded-lg shadow-lg p-2">
<Button
variant={selectedTool === "select" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTool("select")}
>
<MousePointer className="w-4 h-4" />
</Button>
<Button
variant={selectedTool === "rectangle" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTool("rectangle")}
>
<Square className="w-4 h-4" />
</Button>
<Button
variant={selectedTool === "circle" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTool("circle")}
>
<Circle className="w-4 h-4" />
</Button>
</div>
</div>
{/* Navigation Controls */}
<div className="flex items-center justify-center mt-4 space-x-2">
<Button>
<ArrowLeft className="w-4 h-4" />
</Button>
<Button>
<ArrowRight className="w-4 h-4" />
</Button>
<span className="text-sm text-gray-600 mx-4"></span>
</div>
</div>
</div>
</div>
{/* Right Sidebar - Annotation Panel */}
<div className="w-80 border-l bg-gray-50 p-4">
<div className="space-y-4">
<div>
<h3 className="font-semibold text-lg mb-4"></h3>
<div className="space-y-4">
{medicalAnnotationOptions.map((option) => (
<div key={option.id} className="space-y-2">
<span className="text-sm font-medium">{option.label}</span>
{option.type === "radio" && (
<div className="space-y-1">
{option.options?.map((opt) => (
<div key={opt} className="flex items-center space-x-2">
<input
type="radio"
name={option.id}
value={opt}
onChange={(e) =>
handleAnnotationValueChange(
option.id,
e.target.value
)
}
className="w-4 h-4"
/>
<span className="text-sm">{opt}</span>
</div>
))}
</div>
)}
{option.type === "checkbox" && (
<div className="space-y-1">
{option.options?.map((opt) => (
<div key={opt} className="flex items-center space-x-2">
<Checkbox
checked={
annotationValues[`${option.id}_${opt}`] || false
}
onChange={(checked) =>
handleAnnotationValueChange(
`${option.id}_${opt}`,
checked
)
}
/>
<span className="text-sm">{opt}</span>
</div>
))}
</div>
)}
{option.type === "textarea" && (
<textarea
className="w-full p-2 border rounded-md text-sm resize-none"
rows={3}
placeholder={`请输入${option.label}`}
value={annotationValues[option.id] || ""}
onChange={(e) =>
handleAnnotationValueChange(option.id, e.target.value)
}
/>
)}
</div>
))}
</div>
</div>
<div className="pt-4 border-t">
<Button
onClick={handleUpdate}
className="w-full bg-amber-600 hover:bg-amber-700"
>
Update
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +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>
);
}

View File

@@ -0,0 +1,688 @@
import type React from "react";
import { useState, useRef, useEffect } from "react";
import { Card, Button, Badge, Slider, message } from "antd";
import {
Play,
Pause,
Square,
SkipBack,
SkipForward,
Volume2,
VolumeX,
MousePointer,
CheckCircle,
Trash2,
Eye,
EyeOff,
Target,
Maximize,
} from "lucide-react";
interface VideoAnnotation {
id: string;
frameTime: number;
type: "rectangle" | "point" | "polygon";
coordinates: number[];
label: string;
color: string;
trackId?: string;
visible: boolean;
}
interface VideoTrack {
id: string;
label: string;
color: string;
annotations: VideoAnnotation[];
startTime: number;
endTime: number;
}
interface VideoAnnotationWorkspaceProps {
task: any;
currentFileIndex: number;
onSaveAndNext: () => void;
onSkipAndNext: () => void;
}
// 模拟视频数据
const mockVideoFiles = [
{
id: "1",
name: "traffic_scene_001.mp4",
url: "/placeholder-video.mp4", // 这里应该是实际的视频文件URL
duration: 120, // 2分钟
fps: 30,
width: 1920,
height: 1080,
},
];
// 预定义标签
const videoLabels = [
{ name: "车辆", color: "#3B82F6" },
{ name: "行人", color: "#10B981" },
{ name: "自行车", color: "#F59E0B" },
{ name: "交通灯", color: "#EF4444" },
{ name: "路标", color: "#8B5CF6" },
{ name: "其他", color: "#6B7280" },
];
export default function VideoAnnotationWorkspace({
task,
currentFileIndex,
onSaveAndNext,
onSkipAndNext,
}: VideoAnnotationWorkspaceProps) {
const [messageApi, contextHolder] = message.useMessage();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [currentVideo] = useState(mockVideoFiles[0]);
const [tracks, setTracks] = useState<VideoTrack[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(currentVideo.duration);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [selectedTool, setSelectedTool] = useState<
"select" | "rectangle" | "point"
>("select");
const [selectedLabel, setSelectedLabel] = useState(videoLabels[0]);
const [selectedTrack, setSelectedTrack] = useState<string | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [startPoint, setStartPoint] = useState({ x: 0, y: 0 });
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const updateTime = () => setCurrentTime(video.currentTime);
const updateDuration = () => setDuration(video.duration);
const handleEnded = () => setIsPlaying(false);
video.addEventListener("timeupdate", updateTime);
video.addEventListener("loadedmetadata", updateDuration);
video.addEventListener("ended", handleEnded);
return () => {
video.removeEventListener("timeupdate", updateTime);
video.removeEventListener("loadedmetadata", updateDuration);
video.removeEventListener("ended", handleEnded);
};
}, []);
useEffect(() => {
drawCanvas();
}, [currentTime, tracks, selectedTrack]);
const drawCanvas = () => {
const canvas = canvasRef.current;
const video = videoRef.current;
if (!canvas || !video) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制当前帧的标注
tracks.forEach((track) => {
if (!track.annotations.length) return;
// 找到当前时间最近的标注
const currentAnnotation = track.annotations
.filter((ann) => Math.abs(ann.frameTime - currentTime) < 0.1)
.sort(
(a, b) =>
Math.abs(a.frameTime - currentTime) -
Math.abs(b.frameTime - currentTime)
)[0];
if (!currentAnnotation || !currentAnnotation.visible) return;
ctx.strokeStyle = track.color;
ctx.fillStyle = track.color + "20";
ctx.lineWidth = selectedTrack === track.id ? 3 : 2;
if (currentAnnotation.type === "rectangle") {
const [x, y, width, height] = currentAnnotation.coordinates;
ctx.strokeRect(x, y, width, height);
ctx.fillRect(x, y, width, height);
// 绘制标签
ctx.fillStyle = track.color;
ctx.fillRect(x, y - 20, ctx.measureText(track.label).width + 8, 20);
ctx.fillStyle = "white";
ctx.font = "12px Arial";
ctx.fillText(track.label, x + 4, y - 6);
} else if (currentAnnotation.type === "point") {
const [x, y] = currentAnnotation.coordinates;
ctx.beginPath();
ctx.arc(x, y, 5, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
// 绘制标签
ctx.fillStyle = track.color;
ctx.fillRect(
x + 10,
y - 10,
ctx.measureText(track.label).width + 8,
20
);
ctx.fillStyle = "white";
ctx.font = "12px Arial";
ctx.fillText(track.label, x + 14, y + 4);
}
});
};
const togglePlayPause = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
video.play();
}
setIsPlaying(!isPlaying);
};
const handleSeek = (time: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = time;
setCurrentTime(time);
};
const handleVolumeChange = (value: number[]) => {
const video = videoRef.current;
if (!video) return;
const newVolume = value[0];
video.volume = newVolume;
setVolume(newVolume);
setIsMuted(newVolume === 0);
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
if (isMuted) {
video.volume = volume;
setIsMuted(false);
} else {
video.volume = 0;
setIsMuted(true);
}
};
const handleSpeedChange = (speed: number) => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = speed;
setPlaybackSpeed(speed);
};
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (selectedTool === "select") return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (selectedTool === "point") {
createPointAnnotation(x, y);
} else if (selectedTool === "rectangle") {
setIsDrawing(true);
setStartPoint({ x, y });
}
};
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || selectedTool !== "rectangle") return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 实时预览
drawCanvas();
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.strokeStyle = selectedLabel.color;
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.strokeRect(
startPoint.x,
startPoint.y,
x - startPoint.x,
y - startPoint.y
);
ctx.setLineDash([]);
};
const handleCanvasMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || selectedTool !== "rectangle") return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const width = x - startPoint.x;
const height = y - startPoint.y;
if (Math.abs(width) > 10 && Math.abs(height) > 10) {
createRectangleAnnotation(startPoint.x, startPoint.y, width, height);
}
setIsDrawing(false);
};
const createPointAnnotation = (x: number, y: number) => {
const newAnnotation: VideoAnnotation = {
id: Date.now().toString(),
frameTime: currentTime,
type: "point",
coordinates: [x, y],
label: selectedLabel.name,
color: selectedLabel.color,
visible: true,
};
const newTrack: VideoTrack = {
id: Date.now().toString(),
label: selectedLabel.name,
color: selectedLabel.color,
annotations: [newAnnotation],
startTime: currentTime,
endTime: currentTime,
};
setTracks([...tracks, newTrack]);
messageApi({
title: "点标注已添加",
description: `在时间 ${formatTime(currentTime)} 添加了点标注`,
});
};
const createRectangleAnnotation = (
x: number,
y: number,
width: number,
height: number
) => {
const newAnnotation: VideoAnnotation = {
id: Date.now().toString(),
frameTime: currentTime,
type: "rectangle",
coordinates: [x, y, width, height],
label: selectedLabel.name,
color: selectedLabel.color,
visible: true,
};
const newTrack: VideoTrack = {
id: Date.now().toString(),
label: selectedLabel.name,
color: selectedLabel.color,
annotations: [newAnnotation],
startTime: currentTime,
endTime: currentTime,
};
setTracks([...tracks, newTrack]);
messageApi.success(`在时间 ${formatTime(currentTime)} 添加了矩形标注`);
};
const deleteTrack = (trackId: string) => {
setTracks(tracks.filter((t) => t.id !== trackId));
setSelectedTrack(null);
messageApi.success("标注轨迹已被删除");
};
const toggleTrackVisibility = (trackId: string) => {
setTracks(
tracks.map((track) =>
track.id === trackId
? {
...track,
annotations: track.annotations.map((ann) => ({
...ann,
visible: !ann.visible,
})),
}
: track
)
);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
const toggleFullscreen = () => {
const video = videoRef.current;
if (!video) return;
if (!isFullscreen) {
if (video.requestFullscreen) {
video.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
setIsFullscreen(!isFullscreen);
};
return (
<div className="flex-1 flex">
{/* Tools Panel */}
<div className="w-64 border-r bg-gray-50 p-4 space-y-4">
{/* Tool Selection */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant={selectedTool === "select" ? "default" : "outline"}
size="sm"
className="w-full justify-start"
onClick={() => setSelectedTool("select")}
>
<MousePointer className="w-4 h-4 mr-2" />
</Button>
<Button
variant={selectedTool === "rectangle" ? "default" : "outline"}
size="sm"
className="w-full justify-start"
onClick={() => setSelectedTool("rectangle")}
>
<Square className="w-4 h-4 mr-2" />
</Button>
<Button
variant={selectedTool === "point" ? "default" : "outline"}
size="sm"
className="w-full justify-start"
onClick={() => setSelectedTool("point")}
>
<Target className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
{/* Labels */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{videoLabels.map((label) => (
<Button
key={label.name}
variant={
selectedLabel.name === label.name ? "default" : "outline"
}
size="sm"
className="w-full justify-start"
onClick={() => setSelectedLabel(label)}
>
<div
className="w-4 h-4 mr-2 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</Button>
))}
</CardContent>
</Card>
{/* Playback Speed */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{[0.25, 0.5, 1, 1.5, 2].map((speed) => (
<Button
key={speed}
variant={playbackSpeed === speed ? "default" : "outline"}
size="sm"
className="w-full"
onClick={() => handleSpeedChange(speed)}
>
{speed}x
</Button>
))}
</CardContent>
</Card>
{/* Tracks List */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
<div className="space-y-2">
{tracks.map((track) => (
<div
key={track.id}
className={`p-2 border rounded cursor-pointer ${
selectedTrack === track.id
? "border-blue-500 bg-blue-50"
: "border-gray-200"
}`}
onClick={() => setSelectedTrack(track.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: track.color }}
/>
<span className="text-sm font-medium">
{track.label}
</span>
</div>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
className="p-1 h-auto"
onClick={(e) => {
e.stopPropagation();
toggleTrackVisibility(track.id);
}}
>
{track.annotations[0]?.visible ? (
<Eye className="w-3 h-3" />
) : (
<EyeOff className="w-3 h-3" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="p-1 h-auto text-red-500"
onClick={(e) => {
e.stopPropagation();
deleteTrack(track.id);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="text-xs text-gray-500 mt-1">
{track.annotations.length}
</div>
</div>
))}
{tracks.length === 0 && (
<div className="text-center py-4 text-gray-500 text-sm">
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
{/* Video Player and Canvas */}
<div className="flex-1 flex flex-col">
{/* Video Container */}
<div className="flex-1 relative bg-black">
<video
ref={videoRef}
src={currentVideo.url}
className="w-full h-full object-contain"
preload="metadata"
/>
<canvas
ref={canvasRef}
width={800}
height={450}
className="absolute top-0 left-0 w-full h-full cursor-crosshair"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
/>
{/* Video Info Overlay */}
<div className="absolute top-4 left-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm">
{currentVideo.name} | {formatTime(currentTime)} /{" "}
{formatTime(duration)}
</div>
{/* Tool Info Overlay */}
<div className="absolute top-4 right-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm">
{selectedTool === "select"
? "选择模式"
: selectedTool === "rectangle"
? "矩形标注"
: "点标注"}{" "}
| {selectedLabel.name}
</div>
</div>
{/* Video Controls */}
<div className="border-t bg-white p-4 space-y-4">
{/* Timeline */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm text-gray-600">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<Slider
value={[currentTime]}
max={duration}
step={0.1}
onValueChange={(value) => handleSeek(value[0])}
className="w-full"
/>
</div>
{/* Player Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => handleSeek(Math.max(0, currentTime - 10))}
>
<SkipBack className="w-4 h-4" />
</Button>
<Button
onClick={togglePlayPause}
size="lg"
className="bg-blue-600 hover:bg-blue-700"
>
{isPlaying ? (
<Pause className="w-6 h-6" />
) : (
<Play className="w-6 h-6" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleSeek(Math.min(duration, currentTime + 10))}
>
<SkipForward className="w-4 h-4" />
</Button>
{/* Volume Control */}
<div className="flex items-center space-x-2">
<Button variant="ghost" size="sm" onClick={toggleMute}>
{isMuted ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</Button>
<Slider
value={[isMuted ? 0 : volume]}
max={1}
step={0.1}
onValueChange={handleVolumeChange}
className="w-24"
/>
</div>
<Button variant="outline" size="sm" onClick={toggleFullscreen}>
<Maximize className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline">{playbackSpeed}x</Badge>
<Badge variant="outline">{tracks.length} </Badge>
<Button onClick={onSkipAndNext} variant="outline">
</Button>
<Button
onClick={onSaveAndNext}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +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="h-full flex flex-col 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="h-full flex-1 overflow-y-auto flex flex-col 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>
);
}

View File

@@ -0,0 +1,98 @@
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
import { datasetTypeMap } from "@/pages/DataManagement/dataset.const";
import { Button, Form, Input, Modal, Select } from "antd";
import TextArea from "antd/es/input/TextArea";
import { Database } from "lucide-react";
import { useEffect, useState } from "react";
import { createAnnotationTaskUsingPost } from "../../annotation.api";
import { Dataset } from "@/pages/DataManagement/dataset.model";
export default function CreateAnnotationTask({
open,
onClose,
onRefresh,
}: {
open: boolean;
onClose: () => void;
onRefresh: () => void;
}) {
const [form] = Form.useForm();
const [datasets, setDatasets] = useState<Dataset[]>([]);
useEffect(() => {
if (!open) return;
const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet({
page: 0,
size: 1000,
});
setDatasets(data.content || []);
};
fetchDatasets();
}, [open]);
const handleSubmit = async () => {
const values = await form.validateFields();
await createAnnotationTaskUsingPost(values);
onClose();
onRefresh();
};
return (
<Modal
open={open}
onCancel={onClose}
title="创建标注任务"
footer={
<>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={handleSubmit}>
</Button>
</>
}
>
<Form layout="vertical">
<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
placeholder="请选择数据集"
options={datasets.map((dataset) => ({
label: (
<div className="flex items-center justify-between gap-3 py-2">
<div className="flex items-center font-sm text-gray-900">
<span>
{dataset.icon || <Database className="w-4 h-4 mr-2" />}
</span>
<span>{dataset.name}</span>
</div>
<div className="text-xs text-gray-500">
{datasetTypeMap[dataset?.datasetType]?.label}
</div>
</div>
),
value: dataset.id,
}))}
/>
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -0,0 +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>
);
}

View File

@@ -0,0 +1,181 @@
import { useState } from "react";
import { Card, Button, Table, message } from "antd";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SyncOutlined,
} from "@ant-design/icons";
import { SearchControls } from "@/components/SearchControls";
import CardView from "@/components/CardView";
import { useNavigate } from "react-router";
import type { AnnotationTask } from "../annotation.model";
import useFetchData from "@/hooks/useFetchData";
import {
deleteAnnotationTaskByIdUsingDelete,
queryAnnotationTasksUsingGet,
syncAnnotationTaskUsingPost,
} from "../annotation.api";
import { mapAnnotationTask } from "../annotation.const";
import CreateAnnotationTask from "../Create/components/CreateAnnptationTaskDialog";
import { ColumnType } from "antd/es/table";
export default function DataAnnotation() {
const navigate = useNavigate();
const [viewMode, setViewMode] = useState<"list" | "card">("list");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const {
loading,
tableData,
pagination,
searchParams,
setSearchParams,
fetchData,
handleFiltersChange,
} = useFetchData(queryAnnotationTasksUsingGet, mapAnnotationTask);
const handleAnnotate = (task: AnnotationTask) => {
navigate(`/data/annotation/task-annotate/${task.datasetType}/${task.id}`);
};
const handleDelete = async (task: AnnotationTask) => {
await deleteAnnotationTaskByIdUsingDelete({
m: task.id,
proj: task.projId,
});
};
const handleSync = async (task: AnnotationTask, format: string) => {
await syncAnnotationTaskUsingPost({ task, format });
message.success("任务同步请求已发送");
};
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[] = [
{
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: AnnotationTask) => (
<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?.(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>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setShowCreateDialog(true)}
>
</Button>
</div>
{/* Filters Toolbar */}
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={(keyword) =>
setSearchParams({ ...searchParams, keyword })
}
searchPlaceholder="搜索任务名称、描述"
onFiltersChange={handleFiltersChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
showViewToggle={true}
onReload={fetchData}
/>
{/* Task List/Card */}
{viewMode === "list" ? (
<Card>
<Table
key="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
scroll={{ x: "max-content", y: "calc(100vh - 20rem)" }}
/>
</Card>
) : (
<CardView data={tableData} operations={operations} />
)}
<CreateAnnotationTask
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onRefresh={fetchData}
/>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { get, post, put, del, download } from "@/utils/request";
// 标注任务管理相关接口
export function queryAnnotationTasksUsingGet(params?: any) {
return get("/api/project/mappings/list", params);
}
export function createAnnotationTaskUsingPost(data: any) {
return post("/api/project/create", data);
}
export function syncAnnotationTaskUsingPost(data: any) {
return post(`/api/project/sync`, data);
}
export function queryAnnotationTaskByIdUsingGet(taskId: string | number) {
return get(`/api/v1/annotation/tasks/${taskId}`);
}
export function deleteAnnotationTaskByIdUsingDelete(params?: any) {
return del(`/api/project/mappings`, params);
}
// 智能预标注相关接口
export function preAnnotateUsingPost(data: any) {
return post("/api/v1/annotation/pre-annotate", data);
}
// 标注数据管理接口
export function queryAnnotationDataUsingGet(
taskId: string | number,
params?: any
) {
return get(`/api/v1/annotation/tasks/${taskId}/data`, params);
}
export function submitAnnotationUsingPost(taskId: string | number, data: any) {
return post(`/api/v1/annotation/tasks/${taskId}/annotations`, data);
}
export function updateAnnotationUsingPut(
taskId: string | number,
annotationId: string | number,
data: any
) {
return put(
`/api/v1/annotation/tasks/${taskId}/annotations/${annotationId}`,
data
);
}
export function deleteAnnotationUsingDelete(
taskId: string | number,
annotationId: string | number
) {
return del(`/api/v1/annotation/tasks/${taskId}/annotations/${annotationId}`);
}
// 标注任务执行控制
export function startAnnotationTaskUsingPost(taskId: string | number) {
return post(`/api/v1/annotation/tasks/${taskId}/start`);
}
export function pauseAnnotationTaskUsingPost(taskId: string | number) {
return post(`/api/v1/annotation/tasks/${taskId}/pause`);
}
export function resumeAnnotationTaskUsingPost(taskId: string | number) {
return post(`/api/v1/annotation/tasks/${taskId}/resume`);
}
export function completeAnnotationTaskUsingPost(taskId: string | number) {
return post(`/api/v1/annotation/tasks/${taskId}/complete`);
}
// 标注任务统计信息
export function getAnnotationTaskStatisticsUsingGet(taskId: string | number) {
return get(`/api/v1/annotation/tasks/${taskId}/statistics`);
}
export function getAnnotationStatisticsUsingGet(params?: any) {
return get("/api/v1/annotation/statistics", params);
}
// 标注模板管理
export function queryAnnotationTemplatesUsingGet(params?: any) {
return get("/api/v1/annotation/templates", params);
}
export function createAnnotationTemplateUsingPost(data: any) {
return post("/api/v1/annotation/templates", data);
}
export function queryAnnotationTemplateByIdUsingGet(
templateId: string | number
) {
return get(`/api/v1/annotation/templates/${templateId}`);
}
export function updateAnnotationTemplateByIdUsingPut(
templateId: string | number,
data: any
) {
return put(`/api/v1/annotation/templates/${templateId}`, data);
}
export function deleteAnnotationTemplateByIdUsingDelete(
templateId: string | number
) {
return del(`/api/v1/annotation/templates/${templateId}`);
}
// 主动学习相关接口
export function queryActiveLearningCandidatesUsingGet(
taskId: string | number,
params?: any
) {
return get(
`/api/v1/annotation/tasks/${taskId}/active-learning/candidates`,
params
);
}
export function submitActiveLearningFeedbackUsingPost(
taskId: string | number,
data: any
) {
return post(
`/api/v1/annotation/tasks/${taskId}/active-learning/feedback`,
data
);
}
export function updateActiveLearningModelUsingPost(
taskId: string | number,
data: any
) {
return post(
`/api/v1/annotation/tasks/${taskId}/active-learning/update-model`,
data
);
}
// 标注质量控制
export function validateAnnotationsUsingPost(
taskId: string | number,
data: any
) {
return post(`/api/v1/annotation/tasks/${taskId}/validate`, data);
}
export function getAnnotationQualityReportUsingGet(taskId: string | number) {
return get(`/api/v1/annotation/tasks/${taskId}/quality-report`);
}
// 标注数据导入导出
export function exportAnnotationsUsingPost(taskId: string | number, data: any) {
return post(`/api/v1/annotation/tasks/${taskId}/export`, data);
}
export function importAnnotationsUsingPost(taskId: string | number, data: any) {
return post(`/api/v1/annotation/tasks/${taskId}/import`, data);
}
export function downloadAnnotationsUsingGet(
taskId: string | number,
filename?: string
) {
return download(
`/api/v1/annotation/tasks/${taskId}/download`,
null,
filename
);
}
// 标注者管理
export function queryAnnotatorsUsingGet(params?: any) {
return get("/api/v1/annotation/annotators", params);
}
export function assignAnnotatorUsingPost(taskId: string | number, data: any) {
return post(`/api/v1/annotation/tasks/${taskId}/assign`, data);
}
export function getAnnotatorStatisticsUsingGet(annotatorId: string | number) {
return get(`/api/v1/annotation/annotators/${annotatorId}/statistics`);
}
// 标注配置管理
export function getAnnotationConfigUsingGet(taskId: string | number) {
return get(`/api/v1/annotation/tasks/${taskId}/config`);
}
export function updateAnnotationConfigUsingPut(
taskId: string | number,
data: any
) {
return put(`/api/v1/annotation/tasks/${taskId}/config`, data);
}
// 标注类型和标签管理
export function queryAnnotationTypesUsingGet() {
return get("/api/v1/annotation/types");
}
export function queryAnnotationLabelsUsingGet(taskId: string | number) {
return get(`/api/v1/annotation/tasks/${taskId}/labels`);
}
export function createAnnotationLabelUsingPost(
taskId: string | number,
data: any
) {
return post(`/api/v1/annotation/tasks/${taskId}/labels`, data);
}
export function updateAnnotationLabelUsingPut(
taskId: string | number,
labelId: string | number,
data: any
) {
return put(`/api/v1/annotation/tasks/${taskId}/labels/${labelId}`, data);
}
export function deleteAnnotationLabelUsingDelete(
taskId: string | number,
labelId: string | number
) {
return del(`/api/v1/annotation/tasks/${taskId}/labels/${labelId}`);
}
// 批量操作
export function batchAssignAnnotatorsUsingPost(data: any) {
return post("/api/v1/annotation/tasks/batch-assign", data);
}
export function batchUpdateTaskStatusUsingPost(data: any) {
return post("/api/v1/annotation/tasks/batch-update-status", data);
}
export function batchDeleteTasksUsingPost(data: { taskIds: string[] }) {
return post("/api/v1/annotation/tasks/batch-delete", data);
}
// 标注进度跟踪
export function getAnnotationProgressUsingGet(taskId: string | number) {
return get(`/api/v1/annotation/tasks/${taskId}/progress`);
}
// 标注审核
export function submitAnnotationReviewUsingPost(
taskId: string | number,
data: any
) {
return post(`/api/v1/annotation/tasks/${taskId}/review`, data);
}
export function getAnnotationReviewResultsUsingGet(
taskId: string | number,
params?: any
) {
return get(`/api/v1/annotation/tasks/${taskId}/reviews`, params);
}

View File

@@ -0,0 +1,56 @@
import { StickyNote } from "lucide-react";
import { AnnotationTask, AnnotationTaskStatus } from "./annotation.model";
import {
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
CustomerServiceOutlined,
FileTextOutlined,
PictureOutlined,
VideoCameraOutlined,
} 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: AnnotationTask) {
return {
...task,
id: task.mapping_id,
projId: task.labelling_project_id,
name: task.labelling_project_name,
createdAt: task.created_at,
updatedAt: task.last_updated_at,
icon: <StickyNote />,
iconColor: "bg-blue-100",
status: {
label:
task.status === "completed"
? "已完成"
: task.status === "in_progress"
? "进行中"
: task.status === "skipped"
? "已跳过"
: "待开始",
color: "bg-blue-100",
},
};
}

View File

@@ -0,0 +1,27 @@
import type { DatasetType } from "@/pages/DataManagement/dataset.model";
export enum AnnotationTaskStatus {
ACTIVE = "active",
PROCESSING = "processing",
INACTIVE = "inactive",
}
export interface AnnotationTask {
id: string;
name: string;
annotationCount: number;
createdAt: string;
datasetId: string;
description?: string;
assignedTo?: string;
progress: number;
statistics: {
accuracy: number;
averageTime: number;
reviewCount: number;
};
status: AnnotationTaskStatus;
totalDataCount: number;
type: DatasetType;
updatedAt: string;
}