You've already forked DataMate
* refactor: clean up tag management and dataset handling, update API endpoints * feat: add showTime prop to DevelopmentInProgress component across multiple pages * refactor: update component styles and improve layout with new utility classes
618 lines
19 KiB
TypeScript
618 lines
19 KiB
TypeScript
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-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-card 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>
|
|
);
|
|
}
|