|
|
@@ -0,0 +1,642 @@
|
|
|
+/**
|
|
|
+ * ProjectAnnotationView Component
|
|
|
+ *
|
|
|
+ * Project-based sequential annotation interface with task sidebar.
|
|
|
+ * Allows annotating multiple tasks in a project sequentially.
|
|
|
+ */
|
|
|
+import React, { useEffect, useState, useRef } from 'react';
|
|
|
+import { useParams, useNavigate } from 'react-router-dom';
|
|
|
+import { useAtom } from 'jotai';
|
|
|
+import { onSnapshot } from 'mobx-state-tree';
|
|
|
+import {
|
|
|
+ Button,
|
|
|
+ IconArrowLeft,
|
|
|
+ IconCheck,
|
|
|
+ IconForward,
|
|
|
+} from '@humansignal/ui';
|
|
|
+import {
|
|
|
+ getProject,
|
|
|
+ getProjectTasks,
|
|
|
+ createAnnotation,
|
|
|
+ updateTask,
|
|
|
+ getTaskAnnotations,
|
|
|
+ updateAnnotation
|
|
|
+} from '../../services/api';
|
|
|
+import { currentProjectAtom } from '../../atoms/project-atoms';
|
|
|
+import { LoadingSpinner } from '../../components/loading-spinner';
|
|
|
+import { CheckCircle, Circle, PlayCircle, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
|
|
|
+import styles from './project-annotation-view.module.scss';
|
|
|
+import type { Task } from '../../atoms/task-atoms';
|
|
|
+
|
|
|
+// Clear localStorage of any LabelStudio:settings
|
|
|
+if (typeof localStorage !== 'undefined') {
|
|
|
+ localStorage.removeItem('labelStudio:settings');
|
|
|
+}
|
|
|
+
|
|
|
+export const ProjectAnnotationView: React.FC = () => {
|
|
|
+ const { projectId } = useParams<{ projectId: string }>();
|
|
|
+ const navigate = useNavigate();
|
|
|
+
|
|
|
+ const [currentProject, setCurrentProject] = useAtom(currentProjectAtom);
|
|
|
+ const [tasks, setTasks] = useState<Task[]>([]);
|
|
|
+ const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
+ const [isSaving, setIsSaving] = useState(false);
|
|
|
+ const [editorReady, setEditorReady] = useState(false);
|
|
|
+ const [imageScale, setImageScale] = useState(100); // 图片缩放比例(百分比)
|
|
|
+
|
|
|
+ const editorContainerRef = useRef<HTMLDivElement>(null);
|
|
|
+ const lsfInstanceRef = useRef<any>(null);
|
|
|
+ const snapshotDisposerRef = useRef<any>(null);
|
|
|
+ const annotationResultRef = useRef<any>(null);
|
|
|
+ const loadedAnnotationRef = useRef<any>(null);
|
|
|
+ const rafIdRef = useRef<number | null>(null);
|
|
|
+
|
|
|
+ const currentTask = tasks[currentTaskIndex];
|
|
|
+
|
|
|
+ // Load project and tasks
|
|
|
+ useEffect(() => {
|
|
|
+ if (!projectId) return;
|
|
|
+
|
|
|
+ const loadData = async () => {
|
|
|
+ try {
|
|
|
+ setLoading(true);
|
|
|
+ setError(null);
|
|
|
+
|
|
|
+ // Load project details
|
|
|
+ const projectData = await getProject(projectId);
|
|
|
+ setCurrentProject(projectData);
|
|
|
+
|
|
|
+ // Load all tasks for this project
|
|
|
+ const tasksData = await getProjectTasks(projectId);
|
|
|
+ setTasks(tasksData);
|
|
|
+
|
|
|
+ // Find first incomplete task
|
|
|
+ const firstIncompleteIndex = tasksData.findIndex(
|
|
|
+ (task) => task.status !== 'completed'
|
|
|
+ );
|
|
|
+ setCurrentTaskIndex(firstIncompleteIndex >= 0 ? firstIncompleteIndex : 0);
|
|
|
+ } catch (err: any) {
|
|
|
+ setError(err.message || '加载项目失败');
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ loadData();
|
|
|
+ }, [projectId]);
|
|
|
+
|
|
|
+ // Load annotation for current task
|
|
|
+ useEffect(() => {
|
|
|
+ if (!currentTask) return;
|
|
|
+
|
|
|
+ const loadAnnotation = async () => {
|
|
|
+ try {
|
|
|
+ const existingAnnotations = await getTaskAnnotations(currentTask.id);
|
|
|
+
|
|
|
+ if (existingAnnotations.length > 0) {
|
|
|
+ const latestAnnotation = existingAnnotations[0];
|
|
|
+ loadedAnnotationRef.current = latestAnnotation.result;
|
|
|
+ annotationResultRef.current = latestAnnotation.result;
|
|
|
+ } else {
|
|
|
+ loadedAnnotationRef.current = null;
|
|
|
+ }
|
|
|
+ } catch (err: any) {
|
|
|
+ console.error('Error loading annotation:', err);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ loadAnnotation();
|
|
|
+ }, [currentTask]);
|
|
|
+
|
|
|
+ // Initialize LabelStudio editor
|
|
|
+ useEffect(() => {
|
|
|
+ if (loading || error || !currentTask || !currentProject) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let LabelStudio: any;
|
|
|
+ let dependencies: any;
|
|
|
+ let snapshotDisposer: any;
|
|
|
+ let isCleanedUp = false;
|
|
|
+
|
|
|
+ function cleanup() {
|
|
|
+ if (isCleanedUp) return;
|
|
|
+ isCleanedUp = true;
|
|
|
+
|
|
|
+ if (snapshotDisposer) {
|
|
|
+ try {
|
|
|
+ snapshotDisposer();
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore cleanup errors
|
|
|
+ }
|
|
|
+ snapshotDisposer = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (snapshotDisposerRef.current) {
|
|
|
+ try {
|
|
|
+ snapshotDisposerRef.current();
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore cleanup errors
|
|
|
+ }
|
|
|
+ snapshotDisposerRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (rafIdRef.current !== null) {
|
|
|
+ cancelAnimationFrame(rafIdRef.current);
|
|
|
+ rafIdRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (lsfInstanceRef.current) {
|
|
|
+ try {
|
|
|
+ lsfInstanceRef.current.destroy();
|
|
|
+ lsfInstanceRef.current = null;
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore cleanup errors
|
|
|
+ lsfInstanceRef.current = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof window !== 'undefined' && (window as any).LabelStudio) {
|
|
|
+ delete (window as any).LabelStudio;
|
|
|
+ }
|
|
|
+
|
|
|
+ setEditorReady(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadLSF() {
|
|
|
+ try {
|
|
|
+ // @ts-ignore - LabelStudio doesn't have TypeScript declarations
|
|
|
+ dependencies = await import('@humansignal/editor');
|
|
|
+ LabelStudio = dependencies.LabelStudio;
|
|
|
+
|
|
|
+ if (!LabelStudio) {
|
|
|
+ setError('编辑器加载失败:LabelStudio 未定义');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setEditorReady(true);
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ if (isCleanedUp) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!editorContainerRef.current) {
|
|
|
+ setError('编辑器容器未找到');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!currentProject || !currentTask) {
|
|
|
+ setError('项目或任务数据未加载');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const annotations = [];
|
|
|
+ const existingResult = loadedAnnotationRef.current;
|
|
|
+
|
|
|
+ if (existingResult && existingResult.result && Array.isArray(existingResult.result)) {
|
|
|
+ annotations.push({
|
|
|
+ id: 'existing',
|
|
|
+ result: existingResult.result,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ lsfInstanceRef.current = new LabelStudio(editorContainerRef.current, {
|
|
|
+ config: currentProject.config,
|
|
|
+ task: {
|
|
|
+ id: Math.abs(currentTask.id.split('').reduce((a, b) => {
|
|
|
+ a = ((a << 5) - a) + b.charCodeAt(0);
|
|
|
+ return a & a;
|
|
|
+ }, 0)),
|
|
|
+ data: currentTask.data,
|
|
|
+ annotations: annotations.length > 0 ? annotations : undefined,
|
|
|
+ },
|
|
|
+ interfaces: [
|
|
|
+ 'panel',
|
|
|
+ 'update',
|
|
|
+ 'submit',
|
|
|
+ 'controls',
|
|
|
+ 'side-column',
|
|
|
+ 'annotations:menu',
|
|
|
+ 'annotations:add-new',
|
|
|
+ 'annotations:delete',
|
|
|
+ 'predictions:menu',
|
|
|
+ ],
|
|
|
+ instanceOptions: {
|
|
|
+ reactVersion: 'v18',
|
|
|
+ },
|
|
|
+ settings: {
|
|
|
+ forceBottomPanel: true,
|
|
|
+ collapsibleBottomPanel: true,
|
|
|
+ defaultCollapsedBottomPanel: false,
|
|
|
+ fullscreen: false,
|
|
|
+ },
|
|
|
+ onStorageInitialized: (LS: any) => {
|
|
|
+ const initAnnotation = () => {
|
|
|
+ if (isCleanedUp) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const as = LS.annotationStore;
|
|
|
+
|
|
|
+ if (as.annotations && as.annotations.length > 0) {
|
|
|
+ as.selectAnnotation(as.annotations[0].id);
|
|
|
+
|
|
|
+ const annotation = as.selected;
|
|
|
+ if (annotation && !isCleanedUp) {
|
|
|
+ snapshotDisposer = onSnapshot(annotation, () => {
|
|
|
+ if (!isCleanedUp && lsfInstanceRef.current) {
|
|
|
+ try {
|
|
|
+ annotationResultRef.current = annotation.serializeAnnotation();
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore errors during cleanup
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ snapshotDisposerRef.current = snapshotDisposer;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const annotation = as.createAnnotation();
|
|
|
+ as.selectAnnotation(annotation.id);
|
|
|
+
|
|
|
+ if (annotation && !isCleanedUp) {
|
|
|
+ snapshotDisposer = onSnapshot(annotation, () => {
|
|
|
+ if (!isCleanedUp && lsfInstanceRef.current) {
|
|
|
+ try {
|
|
|
+ annotationResultRef.current = annotation.serializeAnnotation();
|
|
|
+ } catch (e) {
|
|
|
+ // Ignore errors during cleanup
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ snapshotDisposerRef.current = snapshotDisposer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ setTimeout(initAnnotation, 100);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }, 100);
|
|
|
+ } catch (err: any) {
|
|
|
+ console.error('Error loading LabelStudio:', err);
|
|
|
+ setError(err.message || '初始化编辑器失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ rafIdRef.current = requestAnimationFrame(() => {
|
|
|
+ loadLSF();
|
|
|
+ });
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ cleanup();
|
|
|
+ };
|
|
|
+ }, [currentTask, currentProject, loading, error]);
|
|
|
+
|
|
|
+ const saveAnnotation = async () => {
|
|
|
+ if (!currentTask) return false;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const annotationResult = annotationResultRef.current;
|
|
|
+
|
|
|
+ if (!annotationResult || typeof annotationResult !== 'object') {
|
|
|
+ setError('请完成标注后再保存');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ let resultData: Record<string, any> = {};
|
|
|
+
|
|
|
+ if (annotationResult.result && Array.isArray(annotationResult.result)) {
|
|
|
+ resultData = { result: annotationResult.result };
|
|
|
+ } else if (Array.isArray(annotationResult)) {
|
|
|
+ resultData = { result: annotationResult };
|
|
|
+ } else {
|
|
|
+ resultData = annotationResult;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!resultData.result || (Array.isArray(resultData.result) && resultData.result.length === 0)) {
|
|
|
+ setError('请完成标注后再保存(标注结果为空)');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ const existingAnnotations = await getTaskAnnotations(currentTask.id);
|
|
|
+
|
|
|
+ if (existingAnnotations.length > 0) {
|
|
|
+ const existingAnnotation = existingAnnotations[0];
|
|
|
+ await updateAnnotation(existingAnnotation.id, {
|
|
|
+ result: resultData,
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ await createAnnotation({
|
|
|
+ task_id: currentTask.id,
|
|
|
+ user_id: 'current_user',
|
|
|
+ result: resultData,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ await updateTask(currentTask.id, {
|
|
|
+ status: 'completed',
|
|
|
+ });
|
|
|
+
|
|
|
+ // Update local task status
|
|
|
+ setTasks(prevTasks =>
|
|
|
+ prevTasks.map(task =>
|
|
|
+ task.id === currentTask.id
|
|
|
+ ? { ...task, status: 'completed' as const }
|
|
|
+ : task
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ return true;
|
|
|
+ } catch (err: any) {
|
|
|
+ console.error('Save annotation error:', err);
|
|
|
+ setError(err.message || '保存标注失败');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSave = async () => {
|
|
|
+ setIsSaving(true);
|
|
|
+ setError(null);
|
|
|
+
|
|
|
+ const success = await saveAnnotation();
|
|
|
+
|
|
|
+ setIsSaving(false);
|
|
|
+
|
|
|
+ if (success) {
|
|
|
+ // Stay on current task
|
|
|
+ alert('保存成功!');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSaveAndNext = async () => {
|
|
|
+ setIsSaving(true);
|
|
|
+ setError(null);
|
|
|
+
|
|
|
+ const success = await saveAnnotation();
|
|
|
+
|
|
|
+ setIsSaving(false);
|
|
|
+
|
|
|
+ if (success) {
|
|
|
+ // Move to next task
|
|
|
+ if (currentTaskIndex < tasks.length - 1) {
|
|
|
+ setCurrentTaskIndex(currentTaskIndex + 1);
|
|
|
+ } else {
|
|
|
+ alert('已完成所有任务!');
|
|
|
+ navigate('/projects');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleTaskClick = (index: number) => {
|
|
|
+ setCurrentTaskIndex(index);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 图片缩放控制函数
|
|
|
+ const applyImageScale = (scale: number) => {
|
|
|
+ const imageContainers = document.querySelectorAll('[class*="ImageView_container"]');
|
|
|
+ imageContainers.forEach((container: any) => {
|
|
|
+ container.style.transform = `scale(${scale / 100})`;
|
|
|
+ container.style.transformOrigin = 'center center';
|
|
|
+ });
|
|
|
+
|
|
|
+ const konvaContents = document.querySelectorAll('.konvajs-content');
|
|
|
+ konvaContents.forEach((content: any) => {
|
|
|
+ content.style.transform = `scale(${scale / 100})`;
|
|
|
+ content.style.transformOrigin = 'center center';
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleZoomIn = () => {
|
|
|
+ const newScale = Math.min(imageScale + 10, 200); // 最大 200%
|
|
|
+ setImageScale(newScale);
|
|
|
+ applyImageScale(newScale);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleZoomOut = () => {
|
|
|
+ const newScale = Math.max(imageScale - 10, 50); // 最小 50%
|
|
|
+ setImageScale(newScale);
|
|
|
+ applyImageScale(newScale);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleResetZoom = () => {
|
|
|
+ setImageScale(100);
|
|
|
+ applyImageScale(100);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 键盘快捷键支持
|
|
|
+ useEffect(() => {
|
|
|
+ const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
+ // Ctrl/Cmd + Plus: 放大
|
|
|
+ if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) {
|
|
|
+ e.preventDefault();
|
|
|
+ handleZoomIn();
|
|
|
+ }
|
|
|
+ // Ctrl/Cmd + Minus: 缩小
|
|
|
+ else if ((e.ctrlKey || e.metaKey) && e.key === '-') {
|
|
|
+ e.preventDefault();
|
|
|
+ handleZoomOut();
|
|
|
+ }
|
|
|
+ // Ctrl/Cmd + 0: 重置
|
|
|
+ else if ((e.ctrlKey || e.metaKey) && e.key === '0') {
|
|
|
+ e.preventDefault();
|
|
|
+ handleResetZoom();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ window.addEventListener('keydown', handleKeyDown);
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener('keydown', handleKeyDown);
|
|
|
+ };
|
|
|
+ }, [imageScale]);
|
|
|
+
|
|
|
+ const getTaskIcon = (task: Task) => {
|
|
|
+ if (task.status === 'completed') {
|
|
|
+ return <CheckCircle size={16} className={styles.iconCompleted} />;
|
|
|
+ } else if (task.id === currentTask?.id) {
|
|
|
+ return <PlayCircle size={16} className={styles.iconCurrent} />;
|
|
|
+ } else {
|
|
|
+ return <Circle size={16} className={styles.iconPending} />;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (loading) {
|
|
|
+ return <LoadingSpinner size="large" message="加载项目数据..." fullScreen />;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (error && !currentProject) {
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col gap-comfortable h-full">
|
|
|
+ <div className="flex items-center gap-comfortable pb-comfortable border-b border-neutral-border">
|
|
|
+ <Button
|
|
|
+ variant="neutral"
|
|
|
+ look="string"
|
|
|
+ size="small"
|
|
|
+ onClick={() => navigate('/projects')}
|
|
|
+ leading={<IconArrowLeft className="size-4" />}
|
|
|
+ >
|
|
|
+ 返回
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ <div className="flex-1 flex items-center justify-center">
|
|
|
+ <div className="bg-error-background text-error-foreground p-comfortable rounded-lg border border-error-border max-w-md">
|
|
|
+ <div className="flex flex-col gap-tight">
|
|
|
+ <span className="text-body-medium font-semibold">加载失败</span>
|
|
|
+ <span className="text-body-medium">{error}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!currentProject || tasks.length === 0) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center justify-center h-full">
|
|
|
+ <div className="text-center">
|
|
|
+ <p className="text-body-medium text-secondary-foreground">
|
|
|
+ {!currentProject ? '项目不存在' : '该项目没有任务'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className={styles.root}>
|
|
|
+ {/* Sidebar */}
|
|
|
+ <div className={styles.sidebar}>
|
|
|
+ <div className={styles.sidebarHeader}>
|
|
|
+ <h3 className={styles.sidebarTitle}>任务列表</h3>
|
|
|
+ <p className={styles.sidebarSubtitle}>
|
|
|
+ {tasks.filter(t => t.status === 'completed').length} / {tasks.length} 已完成
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div className={styles.taskList}>
|
|
|
+ {tasks.map((task, index) => (
|
|
|
+ <button
|
|
|
+ key={task.id}
|
|
|
+ className={`${styles.taskItem} ${
|
|
|
+ task.id === currentTask?.id ? styles.taskItemActive : ''
|
|
|
+ }`}
|
|
|
+ onClick={() => handleTaskClick(index)}
|
|
|
+ >
|
|
|
+ <div className={styles.taskIcon}>
|
|
|
+ {getTaskIcon(task)}
|
|
|
+ </div>
|
|
|
+ <div className={styles.taskInfo}>
|
|
|
+ <div className={styles.taskName}>{task.name}</div>
|
|
|
+ <div className={styles.taskStatus}>
|
|
|
+ {task.status === 'completed' ? '已完成' : '待标注'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Main Content */}
|
|
|
+ <div className={styles.main}>
|
|
|
+ {/* Header */}
|
|
|
+ <div className={styles.header}>
|
|
|
+ <div className={styles.headerLeft}>
|
|
|
+ <Button
|
|
|
+ variant="neutral"
|
|
|
+ look="string"
|
|
|
+ size="small"
|
|
|
+ onClick={() => navigate('/projects')}
|
|
|
+ leading={<IconArrowLeft className="size-4" />}
|
|
|
+ >
|
|
|
+ 返回项目列表
|
|
|
+ </Button>
|
|
|
+ <div className={styles.headerDivider} />
|
|
|
+ <div className={styles.taskInfo}>
|
|
|
+ <h1 className={styles.taskName}>
|
|
|
+ {currentTask?.name}
|
|
|
+ </h1>
|
|
|
+ <p className={styles.taskMeta}>
|
|
|
+ {currentProject.name} · 任务 {currentTaskIndex + 1}/{tasks.length}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className={styles.headerCenter}>
|
|
|
+ <div className={styles.zoomControls}>
|
|
|
+ <button
|
|
|
+ className={styles.zoomButton}
|
|
|
+ onClick={handleZoomOut}
|
|
|
+ disabled={imageScale <= 50}
|
|
|
+ title="缩小 (Ctrl + -)"
|
|
|
+ >
|
|
|
+ <ZoomOut size={16} />
|
|
|
+ </button>
|
|
|
+ <div className={styles.zoomDisplay}>
|
|
|
+ {imageScale}%
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ className={styles.zoomButton}
|
|
|
+ onClick={handleZoomIn}
|
|
|
+ disabled={imageScale >= 200}
|
|
|
+ title="放大 (Ctrl + +)"
|
|
|
+ >
|
|
|
+ <ZoomIn size={16} />
|
|
|
+ </button>
|
|
|
+ <div className={styles.zoomDivider} />
|
|
|
+ <button
|
|
|
+ className={styles.zoomButton}
|
|
|
+ onClick={handleResetZoom}
|
|
|
+ title="重置缩放 (Ctrl + 0)"
|
|
|
+ >
|
|
|
+ <Maximize2 size={16} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className={styles.headerRight}>
|
|
|
+ <Button
|
|
|
+ variant="neutral"
|
|
|
+ size="medium"
|
|
|
+ onClick={handleSave}
|
|
|
+ disabled={isSaving}
|
|
|
+ leading={<IconCheck className="size-4" />}
|
|
|
+ >
|
|
|
+ {isSaving ? '保存中...' : '保存'}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="primary"
|
|
|
+ size="medium"
|
|
|
+ onClick={handleSaveAndNext}
|
|
|
+ disabled={isSaving}
|
|
|
+ leading={<IconForward className="size-4" />}
|
|
|
+ >
|
|
|
+ {isSaving ? '保存中...' : '保存并下一个'}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Error Message */}
|
|
|
+ {error && (
|
|
|
+ <div className={styles.errorBanner}>
|
|
|
+ {error}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Editor Container */}
|
|
|
+ <div className={styles.editorContainer}>
|
|
|
+ {editorReady ? (
|
|
|
+ <div
|
|
|
+ ref={editorContainerRef}
|
|
|
+ id="label-studio-editor"
|
|
|
+ className="w-full h-full flex flex-col"
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <div className={styles.loadingContainer}>
|
|
|
+ <LoadingSpinner size="large" message="初始化编辑器..." />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|