You've already forked DataMate
init datamate
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user