Sfoglia il codice sorgente

-dev:修改了导出功能

LuoChinWen 2 settimane fa
parent
commit
1cd7a51d37

+ 237 - 0
.kiro/specs/project-management-enhancement/design.md

@@ -0,0 +1,237 @@
+# Design Document: Project Management Enhancement
+
+## Overview
+
+本设计文档描述了项目管理界面的增强功能架构,包括项目完成状态管理和数据导出功能。设计遵循现有的组件架构和 API 模式。
+
+## Architecture
+
+### Frontend Architecture
+
+```
+ProjectDetailModal
+├── Project Info Section
+├── Action Buttons Section
+│   ├── "标记为已完成" Button (conditional)
+│   └── "导出数据" Button
+├── Tasks Section
+└── Modals
+    ├── ProjectEditModal (existing)
+    ├── ProjectCompletionDialog (new)
+    └── DataExportDialog (new)
+```
+
+### Backend Architecture
+
+```
+API Endpoints
+├── PATCH /api/projects/{project_id}/status (update status)
+├── POST /api/projects/{project_id}/export (initiate export)
+└── GET /api/exports/{export_id}/status (check export status)
+```
+
+## Components and Interfaces
+
+### Frontend Components
+
+#### 1. ProjectDetailModal Enhancement
+- Add "标记为已完成" button (visible when completion_rate === 100%)
+- Add "导出数据" button (always visible)
+- Integrate ProjectCompletionDialog
+- Integrate DataExportDialog
+
+#### 2. ProjectCompletionDialog (New)
+- Confirmation dialog for marking project as completed
+- Shows project name and completion status
+- Buttons: Cancel, Confirm
+- Handles status update API call
+
+#### 3. DataExportDialog (New)
+- Export format selection dropdown
+- Format options: JSON, CSV, COCO, YOLO
+- Format descriptions
+- Export button with loading state
+- Progress indicator during export
+- Success/error messages
+
+### API Interfaces
+
+#### Update Project Status
+```typescript
+PATCH /api/projects/{project_id}/status
+Request: { status: "completed" }
+Response: Project
+```
+
+#### Initiate Export
+```typescript
+POST /api/projects/{project_id}/export
+Request: { format: "json" | "csv" | "coco" | "yolo" }
+Response: { export_id: string, status: string }
+```
+
+#### Check Export Status
+```typescript
+GET /api/exports/{export_id}/status
+Response: { status: string, progress: number, download_url?: string, error?: string }
+```
+
+## Data Models
+
+### Frontend State
+
+```typescript
+interface ProjectCompletionState {
+  isDialogOpen: boolean;
+  isSubmitting: boolean;
+  error: string | null;
+}
+
+interface DataExportState {
+  isDialogOpen: boolean;
+  selectedFormat: ExportFormat | null;
+  isExporting: boolean;
+  exportProgress: number;
+  error: string | null;
+  downloadUrl: string | null;
+}
+
+type ExportFormat = 'json' | 'csv' | 'coco' | 'yolo';
+```
+
+### Backend Models
+
+```python
+class ProjectStatusUpdate(BaseModel):
+    status: str  # "completed"
+
+class ExportRequest(BaseModel):
+    format: str  # "json", "csv", "coco", "yolo"
+
+class ExportResponse(BaseModel):
+    export_id: str
+    status: str
+    progress: float
+    download_url: Optional[str]
+    error: Optional[str]
+```
+
+## Correctness Properties
+
+A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.
+
+### Property 1: Project Completion Status Invariant
+*For any* project with 100% completion rate, when the admin marks it as completed, the project status SHALL be updated to "completed" and remain in that state until explicitly changed.
+
+**Validates: Requirements 1.1, 1.3**
+
+### Property 2: Completion Button Visibility
+*For any* project, the "标记为已完成" button SHALL be visible if and only if the project completion rate is exactly 100%.
+
+**Validates: Requirements 1.2, 2.2**
+
+### Property 3: Export Format Validation
+*For any* export request, the selected format SHALL be one of the valid formats (JSON, CSV, COCO, YOLO), and the export SHALL fail gracefully if an invalid format is provided.
+
+**Validates: Requirements 3.1, 6.1**
+
+### Property 4: Export Status Consistency
+*For any* export job, the status returned by the status check endpoint SHALL be consistent with the actual export state, and progress SHALL be monotonically increasing.
+
+**Validates: Requirements 3.2, 6.2**
+
+### Property 5: Authorization Check
+*For any* project status update or export request, if the user is not an admin, the system SHALL return a 403 Forbidden error.
+
+**Validates: Requirements 5.5, 6.1**
+
+### Property 6: Completion Timestamp Recording
+*For any* project marked as completed, the system SHALL record a completion timestamp that is greater than or equal to the current time.
+
+**Validates: Requirements 1.3**
+
+## Error Handling
+
+### Frontend Error Handling
+
+1. **Status Update Errors**
+   - Display error message in modal
+   - Keep modal open for retry
+   - Show specific error details
+
+2. **Export Errors**
+   - Display error message in export dialog
+   - Allow user to retry with different format
+   - Log error for debugging
+
+3. **Network Errors**
+   - Show connection error message
+   - Provide retry button
+   - Timeout after 30 seconds
+
+### Backend Error Handling
+
+1. **Invalid Status Transition**
+   - Return 400 Bad Request
+   - Include error message explaining valid transitions
+
+2. **Incomplete Project**
+   - Return 400 Bad Request
+   - Include completion rate in response
+
+3. **Export Failures**
+   - Return 500 Internal Server Error
+   - Include error details in response
+   - Log error for debugging
+
+## Testing Strategy
+
+### Unit Tests
+
+1. **ProjectCompletionDialog**
+   - Test button visibility based on completion rate
+   - Test confirmation dialog behavior
+   - Test error handling
+
+2. **DataExportDialog**
+   - Test format selection
+   - Test export initiation
+   - Test progress display
+   - Test error messages
+
+3. **Backend Status Update**
+   - Test valid status transitions
+   - Test authorization checks
+   - Test timestamp recording
+
+### Property-Based Tests
+
+1. **Status Update Property Test**
+   - Generate random projects with various completion rates
+   - Verify status update only succeeds at 100%
+   - Verify status is persisted correctly
+
+2. **Export Format Property Test**
+   - Generate random export requests with valid/invalid formats
+   - Verify only valid formats are accepted
+   - Verify error handling for invalid formats
+
+3. **Authorization Property Test**
+   - Generate requests with different user roles
+   - Verify only admins can update status/export
+   - Verify non-admins get 403 error
+
+### Integration Tests
+
+1. **Complete Project Workflow**
+   - Create project with tasks
+   - Complete all tasks
+   - Mark project as completed
+   - Verify status change
+
+2. **Export Workflow**
+   - Create project with annotations
+   - Initiate export with different formats
+   - Verify export completes successfully
+   - Verify download link is provided
+

+ 91 - 0
.kiro/specs/project-management-enhancement/requirements.md

@@ -0,0 +1,91 @@
+# Requirements Document: Project Management Enhancement
+
+## Introduction
+
+本需求文档定义了项目管理界面的增强功能,包括项目状态管理和数据导出功能。这些功能允许管理员在项目完成后标记为已完成,并支持多种格式的数据导出。
+
+## Glossary
+
+- **Project**: 标注项目,包含多个任务
+- **Task**: 单个标注任务,属于某个项目
+- **Admin**: 管理员用户,拥有项目管理权限
+- **Export Format**: 数据导出格式(JSON、CSV、COCO、YOLO)
+- **Project Status**: 项目状态(草稿、配置中、待分发、进行中、已完成)
+- **Completion Rate**: 项目完成率,已完成任务数 / 总任务数
+
+## Requirements
+
+### Requirement 1: Project Completion Status Update
+
+**User Story:** As an admin, I want to mark a project as completed when all tasks are done, so that I can track project lifecycle and archive completed work.
+
+#### Acceptance Criteria
+
+1. WHEN a project has 100% completion rate AND the admin clicks "标记为已完成" button THEN the system SHALL update the project status to "completed"
+2. WHEN a project status is not 100% complete THEN the system SHALL disable the "标记为已完成" button
+3. WHEN a project is marked as completed THEN the system SHALL record the completion timestamp
+4. WHEN a project status is updated to completed THEN the system SHALL refresh the project list view
+5. IF the status update fails THEN the system SHALL display an error message and keep the previous status
+
+### Requirement 2: Project Edit Modal Enhancement
+
+**User Story:** As an admin, I want to access project completion and data export features from the project detail view, so that I can manage project lifecycle and retrieve results efficiently.
+
+#### Acceptance Criteria
+
+1. WHEN the admin opens the project detail modal THEN the system SHALL display two action buttons: "标记为已完成" and "导出数据"
+2. WHEN the admin clicks "标记为已完成" button AND project is 100% complete THEN the system SHALL update project status to completed
+3. WHEN the admin clicks "导出数据" button THEN the system SHALL display an export dialog with format selection
+4. WHEN the export dialog is open THEN the system SHALL show a dropdown menu with export format options (JSON, CSV, COCO, YOLO)
+5. WHEN the admin selects an export format and clicks export THEN the system SHALL initiate the export process
+
+### Requirement 3: Data Export Functionality
+
+**User Story:** As an admin, I want to export project data in multiple formats, so that I can use the annotated data in different downstream systems.
+
+#### Acceptance Criteria
+
+1. WHEN the admin selects an export format THEN the system SHALL validate the format is one of: JSON, CSV, COCO, YOLO
+2. WHEN the admin initiates an export THEN the system SHALL call the backend export API with the selected format
+3. WHEN the export is in progress THEN the system SHALL display a loading indicator
+4. WHEN the export completes successfully THEN the system SHALL provide a download link or trigger file download
+5. IF the export fails THEN the system SHALL display an error message with details
+6. WHEN the export dialog is open THEN the system SHALL display export format options with descriptions
+
+### Requirement 4: Export Dialog UI
+
+**User Story:** As an admin, I want a clear interface for selecting export format and initiating export, so that I can easily export data without confusion.
+
+#### Acceptance Criteria
+
+1. WHEN the export dialog opens THEN the system SHALL display a dropdown/select menu on the left side for format selection
+2. WHEN the export dialog is displayed THEN the system SHALL show format options: JSON, CSV, COCO, YOLO
+3. WHEN a format is selected THEN the system SHALL display a description of that format
+4. WHEN the admin clicks the export button THEN the system SHALL initiate the export with the selected format
+5. WHEN the export is in progress THEN the system SHALL disable the export button and show loading state
+6. WHEN the export completes THEN the system SHALL close the dialog and show success message
+
+### Requirement 5: Backend API for Project Status Update
+
+**User Story:** As a backend service, I need to support updating project status to completed, so that the frontend can mark projects as finished.
+
+#### Acceptance Criteria
+
+1. WHEN the frontend sends a PATCH request to update project status THEN the system SHALL validate the new status is valid
+2. WHEN the new status is "completed" THEN the system SHALL update the project status and record completion timestamp
+3. WHEN the status update is successful THEN the system SHALL return the updated project object
+4. IF the project is not 100% complete THEN the system SHALL reject the status update with appropriate error
+5. IF the user is not an admin THEN the system SHALL return 403 Forbidden error
+
+### Requirement 6: Export API Integration
+
+**User Story:** As a backend service, I need to provide export functionality that the frontend can call, so that project data can be exported in multiple formats.
+
+#### Acceptance Criteria
+
+1. WHEN the frontend calls the export API with a format parameter THEN the system SHALL create an export job
+2. WHEN the export job is created THEN the system SHALL return a job ID and status
+3. WHEN the export is complete THEN the system SHALL provide a download URL
+4. WHEN the export fails THEN the system SHALL return an error message
+5. WHEN the frontend polls for export status THEN the system SHALL return current progress and status
+

+ 120 - 0
.kiro/specs/project-management-enhancement/tasks.md

@@ -0,0 +1,120 @@
+# Implementation Plan: Project Management Enhancement
+
+## Overview
+
+本实现计划将项目管理增强功能分解为可执行的编码任务,包括项目完成状态管理、数据导出对话框和相关的后端 API 端点。
+
+## Tasks
+
+- [x] 1. 后端 API - 项目状态更新端点
+  - [x] 1.1 在 routers/project.py 中添加 PATCH 端点更新项目状态
+    - 验证项目完成率是否为 100%
+    - 验证用户是否为管理员
+    - 更新项目状态为 "completed"
+    - 记录完成时间戳
+    - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
+  - [x] 1.2 在 schemas/project.py 中添加 ProjectStatusUpdate schema
+    - 定义状态更新请求模型
+    - _Requirements: 5.1_
+
+- [x] 2. 后端 API - 导出端点集成
+  - [x] 2.1 在 routers/project.py 中添加 POST 端点启动导出
+    - 接收导出格式参数
+    - 调用现有的导出服务
+    - 返回导出任务 ID 和状态
+    - _Requirements: 6.1, 6.2_
+  - [x] 2.2 在 routers/project.py 中添加 GET 端点检查导出状态
+    - 返回导出进度和状态
+    - 返回下载 URL(如果完成)
+    - _Requirements: 6.3, 6.4_
+
+- [x] 3. Checkpoint - 验证后端 API
+  - 确保所有测试通过,如有问题请询问用户
+
+- [-] 4. 前端 - 项目完成对话框组件
+  - [x] 4.1 创建 ProjectCompletionDialog 组件
+    - 显示项目名称和完成状态
+    - 取消和确认按钮
+    - 处理状态更新 API 调用
+    - 显示加载和错误状态
+    - _Requirements: 2.2, 2.3_
+  - [x] 4.2 在 ProjectDetailModal 中集成 ProjectCompletionDialog
+    - 添加"标记为已完成"按钮
+    - 根据完成率控制按钮可见性
+    - 处理完成后的刷新
+    - _Requirements: 1.1, 1.2, 2.1_
+
+- [-] 5. 前端 - 数据导出对话框组件
+  - [x] 5.1 创建 DataExportDialog 组件
+    - 左侧导出格式下拉菜单
+    - 格式选项:JSON、CSV、COCO、YOLO
+    - 格式描述显示
+    - 导出按钮和加载状态
+    - 进度指示器
+    - 成功/错误消息
+    - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
+  - [x] 5.2 在 DataExportDialog 中实现导出逻辑
+    - 调用后端导出 API
+    - 轮询导出状态
+    - 处理下载链接
+    - 错误处理
+    - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
+  - [x] 5.3 在 ProjectDetailModal 中集成 DataExportDialog
+    - 添加"导出数据"按钮
+    - 打开导出对话框
+    - 处理导出完成后的刷新
+    - _Requirements: 2.1, 2.3, 2.4, 2.5_
+
+- [x] 6. 前端 - 更新 ProjectDetailModal
+  - [x] 6.1 在 ProjectDetailModal 中添加操作按钮区域
+    - 添加"标记为已完成"按钮(条件显示)
+    - 添加"导出数据"按钮
+    - 按钮样式和布局
+    - _Requirements: 2.1, 2.2, 2.3_
+  - [x] 6.2 更新 ProjectDetailModal 状态管理
+    - 添加完成对话框状态
+    - 添加导出对话框状态
+    - 处理对话框打开/关闭
+    - _Requirements: 2.1_
+
+- [x] 7. 前端 - 集成和测试
+  - [x] 7.1 集成所有组件
+    - 确保按钮正确显示
+    - 确保对话框正确打开/关闭
+    - 确保 API 调用正确
+    - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
+  - [ ]* 7.2 编写单元测试
+    - 测试按钮可见性逻辑
+    - 测试对话框行为
+    - 测试 API 调用
+    - _Requirements: 1.2, 2.2, 3.1_
+  - [ ]* 7.3 编写集成测试
+    - 测试完整的完成工作流
+    - 测试完整的导出工作流
+    - _Requirements: 1.1, 3.1_
+
+- [ ] 8. 后端 - 属性测试
+  - [ ]* 8.1 编写项目完成状态属性测试
+    - 验证状态更新只在 100% 完成时成功
+    - 验证状态持久化
+    - _Requirements: 1.1, 1.3_
+  - [ ]* 8.2 编写导出格式验证属性测试
+    - 验证只接受有效格式
+    - 验证错误处理
+    - _Requirements: 3.1, 6.1_
+  - [ ]* 8.3 编写授权检查属性测试
+    - 验证只有管理员可以更新状态
+    - 验证非管理员获得 403 错误
+    - _Requirements: 5.5, 6.1_
+
+- [ ] 9. Final Checkpoint
+  - 确保所有测试通过,如有问题请询问用户
+
+## Notes
+
+- 任务按依赖顺序排列,先完成后端 API,再实现前端组件
+- 导出功能复用现有的导出服务和 API
+- 所有代码遵循项目现有的编码规范
+- 前端组件应使用 TypeScript 和 React Hooks
+- 后端 API 应遵循 FastAPI 最佳实践
+

+ 19 - 0
backend/EXTERNAL_API_DOCUMENTATION.md

@@ -265,6 +265,7 @@ POST /api/external/projects/{project_id}/export
 | yolo | YOLO目标检测格式 | YOLO模型训练 |
 | coco | COCO数据集格式 | 目标检测/分割模型训练 |
 | alpaca | Alpaca指令微调格式 | LLM指令微调 |
+| pascal_voc | PascalVOC XML格式 | 经典目标检测模型训练 |
 
 **响应**
 
@@ -351,6 +352,23 @@ POST /api/external/projects/{project_id}/export
 ]
 ```
 
+**PascalVOC格式**:
+```json
+[
+  {
+    "image": "https://example.com/img1.jpg",
+    "filename": "img1.jpg",
+    "xml_content": "<?xml version=\"1.0\"?>...",
+    "objects": [
+      {
+        "name": "cat",
+        "bndbox": {"xmin": 100, "ymin": 50, "xmax": 300, "ymax": 250}
+      }
+    ]
+  }
+]
+```
+
 ---
 
 ## 错误响应
@@ -598,5 +616,6 @@ curl -X GET "http://localhost:8003/api/exports/export_xxx/download" \
 
 | 版本 | 日期 | 说明 |
 |------|------|------|
+| 1.2.0 | 2026-02-06 | 添加PascalVOC导出格式支持 |
 | 1.1.0 | 2026-02-05 | 添加tags参数支持、新增polygon任务类型 |
 | 1.0.0 | 2026-02-03 | 初始版本 |

+ 119 - 0
backend/routers/project.py

@@ -509,6 +509,125 @@ async def update_project_status(request: Request, project_id: str, status_update
         )
 
 
+@router.patch("/{project_id}/mark-completed", response_model=ProjectResponseExtended)
+async def mark_project_completed(request: Request, project_id: str):
+    """
+    Mark a project as completed.
+    
+    This endpoint is specifically for marking a project as completed when
+    all tasks are done (100% completion rate). It validates that the project
+    has 100% completion before allowing the status change.
+    
+    Args:
+        request: FastAPI Request object (contains user info)
+        project_id: Project unique identifier
+    
+    Returns:
+        Updated project details with completed status
+    
+    Raises:
+        HTTPException: 404 if project not found
+        HTTPException: 400 if project is not 100% complete
+        HTTPException: 403 if user is not admin
+    """
+    # Check if user has admin role
+    user = request.state.user
+    if user["role"] != "admin":
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="只有管理员可以标记项目为已完成"
+        )
+    
+    with get_db_connection() as conn:
+        cursor = conn.cursor()
+        
+        # Get project with task statistics
+        cursor.execute("""
+            SELECT 
+                p.id,
+                p.name,
+                p.description,
+                p.config,
+                p.task_type,
+                p.status,
+                p.source,
+                p.external_id,
+                p.created_at,
+                p.updated_at,
+                COUNT(t.id) as task_count,
+                SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
+                SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
+            FROM projects p
+            LEFT JOIN tasks t ON p.id = t.project_id
+            WHERE p.id = ?
+            GROUP BY p.id
+        """, (project_id,))
+        
+        row = cursor.fetchone()
+        
+        if not row:
+            raise HTTPException(
+                status_code=status.HTTP_404_NOT_FOUND,
+                detail=f"项目 '{project_id}' 不存在"
+            )
+        
+        task_count = row["task_count"] or 0
+        completed_task_count = row["completed_task_count"] or 0
+        current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
+        
+        # Check if project has tasks
+        if task_count == 0:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="项目没有任务,无法标记为已完成"
+            )
+        
+        # Check if all tasks are completed (100% completion rate)
+        completion_rate = (completed_task_count / task_count) * 100
+        if completion_rate < 100:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"项目未完成,当前完成率: {completion_rate:.1f}%。只有 100% 完成的项目才能标记为已完成"
+            )
+        
+        # Check if project is in a valid state for completion
+        if current_status == ProjectStatus.COMPLETED:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="项目已经是已完成状态"
+            )
+        
+        if current_status not in [ProjectStatus.IN_PROGRESS, ProjectStatus.READY]:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"只有进行中或待分发状态的项目才能标记为已完成,当前状态: {current_status.value}"
+            )
+        
+        # Update status to completed with completion timestamp
+        completed_at = datetime.now()
+        cursor.execute("""
+            UPDATE projects 
+            SET status = ?, updated_at = ?
+            WHERE id = ?
+        """, (ProjectStatus.COMPLETED.value, completed_at, project_id))
+        
+        return ProjectResponseExtended(
+            id=row["id"],
+            name=row["name"],
+            description=row["description"] or "",
+            config=row["config"],
+            task_type=row["task_type"],
+            status=ProjectStatus.COMPLETED,
+            source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
+            external_id=row["external_id"],
+            created_at=row["created_at"],
+            updated_at=completed_at,
+            task_count=task_count,
+            completed_task_count=completed_task_count,
+            assigned_task_count=row["assigned_task_count"] or 0,
+        )
+
+
 @router.put("/{project_id}/config", response_model=ProjectResponseExtended)
 async def update_project_config(request: Request, project_id: str, config_update: ProjectConfigUpdate):
     """

+ 3 - 0
backend/schemas/export.py

@@ -14,6 +14,9 @@ class ExportFormat(str, Enum):
     CSV = "csv"
     COCO = "coco"
     YOLO = "yolo"
+    PASCAL_VOC = "pascal_voc"
+    SHAREGPT = "sharegpt"
+    ALPACA = "alpaca"
 
 
 class ExportStatus(str, Enum):

+ 1 - 0
backend/schemas/external.py

@@ -28,6 +28,7 @@ class ExternalExportFormat(str, Enum):
     YOLO = "yolo"
     COCO = "coco"
     ALPACA = "alpaca"
+    PASCAL_VOC = "pascal_voc"  # PascalVOC XML 格式
 
 
 # ============== 项目初始化相关 ==============

+ 376 - 0
backend/services/export_service.py

@@ -624,6 +624,370 @@ class ExportService:
         
         return file_path, len(tasks), total_annotations
     
+    @classmethod
+    def export_to_pascal_voc(
+        cls,
+        project_id: str,
+        status_filter: str = "all",
+        include_metadata: bool = True
+    ) -> Tuple[str, int, int]:
+        """
+        Export project data to PascalVOC format.
+        
+        PascalVOC format is a classic object detection format using XML.
+        Returns a JSON file containing PascalVOC XML content for each image.
+        
+        Args:
+            project_id: Project ID
+            status_filter: Task status filter
+            include_metadata: Whether to include metadata
+            
+        Returns:
+            Tuple of (file_path, total_tasks, total_annotations)
+        """
+        project = cls.get_project_data(project_id)
+        if not project:
+            raise ValueError(f"Project {project_id} not found")
+        
+        tasks = cls.get_tasks_with_annotations(project_id, status_filter)
+        
+        voc_data = []
+        total_annotations = 0
+        
+        for idx, task in enumerate(tasks):
+            task_data = task["data"]
+            image_url = ""
+            img_width = 0
+            img_height = 0
+            
+            if isinstance(task_data, dict):
+                image_url = task_data.get("image", task_data.get("image_url", ""))
+                img_width = task_data.get("width", 0)
+                img_height = task_data.get("height", 0)
+            
+            # Extract filename from URL
+            image_filename = image_url.split('/')[-1] if image_url else f"image_{idx + 1}.jpg"
+            
+            objects = []
+            
+            # Process annotations
+            for ann in task["annotations"]:
+                result = ann["result"]
+                if isinstance(result, dict):
+                    result = result.get("annotations", result.get("result", []))
+                if not isinstance(result, list):
+                    result = [result] if result else []
+                
+                for item in result:
+                    if not isinstance(item, dict):
+                        continue
+                    
+                    total_annotations += 1
+                    
+                    value = item.get("value", {})
+                    item_type = item.get("type", "")
+                    
+                    if item_type == "rectanglelabels":
+                        labels = value.get("rectanglelabels", [])
+                        for label in labels:
+                            x_pct = value.get("x", 0)
+                            y_pct = value.get("y", 0)
+                            w_pct = value.get("width", 0)
+                            h_pct = value.get("height", 0)
+                            
+                            if img_width > 0 and img_height > 0:
+                                xmin = int(x_pct * img_width / 100)
+                                ymin = int(y_pct * img_height / 100)
+                                xmax = int((x_pct + w_pct) * img_width / 100)
+                                ymax = int((y_pct + h_pct) * img_height / 100)
+                            else:
+                                xmin = x_pct
+                                ymin = y_pct
+                                xmax = x_pct + w_pct
+                                ymax = y_pct + h_pct
+                            
+                            objects.append({
+                                "name": label,
+                                "pose": "Unspecified",
+                                "truncated": 0,
+                                "difficult": 0,
+                                "bndbox": {
+                                    "xmin": xmin,
+                                    "ymin": ymin,
+                                    "xmax": xmax,
+                                    "ymax": ymax
+                                }
+                            })
+                    
+                    elif item_type == "polygonlabels":
+                        labels = value.get("polygonlabels", [])
+                        points = value.get("points", [])
+                        
+                        for label in labels:
+                            if points:
+                                x_coords = [p[0] for p in points]
+                                y_coords = [p[1] for p in points]
+                                
+                                if img_width > 0 and img_height > 0:
+                                    xmin = int(min(x_coords) * img_width / 100)
+                                    ymin = int(min(y_coords) * img_height / 100)
+                                    xmax = int(max(x_coords) * img_width / 100)
+                                    ymax = int(max(y_coords) * img_height / 100)
+                                    polygon_points = [[int(p[0] * img_width / 100), int(p[1] * img_height / 100)] for p in points]
+                                else:
+                                    xmin = min(x_coords)
+                                    ymin = min(y_coords)
+                                    xmax = max(x_coords)
+                                    ymax = max(y_coords)
+                                    polygon_points = points
+                                
+                                objects.append({
+                                    "name": label,
+                                    "pose": "Unspecified",
+                                    "truncated": 0,
+                                    "difficult": 0,
+                                    "bndbox": {
+                                        "xmin": xmin,
+                                        "ymin": ymin,
+                                        "xmax": xmax,
+                                        "ymax": ymax
+                                    },
+                                    "polygon": polygon_points
+                                })
+            
+            # Generate PascalVOC XML
+            xml_content = cls._generate_voc_xml(image_filename, img_width, img_height, objects)
+            
+            voc_data.append({
+                "image": image_url,
+                "filename": image_filename,
+                "xml_content": xml_content,
+                "objects": objects
+            })
+        
+        # Write to file
+        cls.ensure_export_dir()
+        file_name = f"export_{project_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_pascal_voc.json"
+        file_path = os.path.join(cls.EXPORT_DIR, file_name)
+        
+        with open(file_path, 'w', encoding='utf-8') as f:
+            json.dump(voc_data, f, ensure_ascii=False, indent=2)
+        
+        return file_path, len(tasks), total_annotations
+    
+    @staticmethod
+    def _generate_voc_xml(filename: str, width: int, height: int, objects: List[Dict]) -> str:
+        """Generate PascalVOC XML string."""
+        xml_lines = [
+            '<?xml version="1.0" encoding="UTF-8"?>',
+            '<annotation>',
+            f'  <filename>{filename}</filename>',
+            '  <source>',
+            '    <database>Annotation Platform</database>',
+            '  </source>',
+            '  <size>',
+            f'    <width>{width}</width>',
+            f'    <height>{height}</height>',
+            '    <depth>3</depth>',
+            '  </size>',
+            '  <segmented>0</segmented>'
+        ]
+        
+        for obj in objects:
+            xml_lines.append('  <object>')
+            xml_lines.append(f'    <name>{obj["name"]}</name>')
+            xml_lines.append(f'    <pose>{obj.get("pose", "Unspecified")}</pose>')
+            xml_lines.append(f'    <truncated>{obj.get("truncated", 0)}</truncated>')
+            xml_lines.append(f'    <difficult>{obj.get("difficult", 0)}</difficult>')
+            xml_lines.append('    <bndbox>')
+            xml_lines.append(f'      <xmin>{obj["bndbox"]["xmin"]}</xmin>')
+            xml_lines.append(f'      <ymin>{obj["bndbox"]["ymin"]}</ymin>')
+            xml_lines.append(f'      <xmax>{obj["bndbox"]["xmax"]}</xmax>')
+            xml_lines.append(f'      <ymax>{obj["bndbox"]["ymax"]}</ymax>')
+            xml_lines.append('    </bndbox>')
+            
+            if 'polygon' in obj:
+                xml_lines.append('    <polygon>')
+                for point in obj['polygon']:
+                    xml_lines.append(f'      <pt><x>{point[0]}</x><y>{point[1]}</y></pt>')
+                xml_lines.append('    </polygon>')
+            
+            xml_lines.append('  </object>')
+        
+        xml_lines.append('</annotation>')
+        
+        return '\n'.join(xml_lines)
+    
+    @classmethod
+    def export_to_sharegpt(
+        cls,
+        project_id: str,
+        status_filter: str = "all",
+        include_metadata: bool = True
+    ) -> Tuple[str, int, int]:
+        """
+        Export project data to ShareGPT format.
+        
+        ShareGPT format is used for conversation/dialogue model training.
+        
+        Args:
+            project_id: Project ID
+            status_filter: Task status filter
+            include_metadata: Whether to include metadata
+            
+        Returns:
+            Tuple of (file_path, total_tasks, total_annotations)
+        """
+        project = cls.get_project_data(project_id)
+        if not project:
+            raise ValueError(f"Project {project_id} not found")
+        
+        tasks = cls.get_tasks_with_annotations(project_id, status_filter)
+        
+        sharegpt_data = []
+        total_annotations = 0
+        
+        for task in tasks:
+            task_data = task["data"]
+            
+            # Get text content
+            text = ""
+            if isinstance(task_data, dict):
+                text = task_data.get("text", task_data.get("content", ""))
+            elif isinstance(task_data, str):
+                text = task_data
+            
+            # Process annotations
+            for ann in task["annotations"]:
+                total_annotations += 1
+                result = ann["result"]
+                
+                # Extract label/classification result
+                label = ""
+                if isinstance(result, dict):
+                    choices = result.get("choices", result.get("result", []))
+                    if isinstance(choices, list) and choices:
+                        if isinstance(choices[0], dict):
+                            label = choices[0].get("value", {}).get("choices", [""])[0]
+                        else:
+                            label = str(choices[0])
+                    elif isinstance(choices, str):
+                        label = choices
+                elif isinstance(result, list) and result:
+                    first_item = result[0]
+                    if isinstance(first_item, dict):
+                        value = first_item.get("value", {})
+                        choices = value.get("choices", value.get("labels", []))
+                        if choices:
+                            label = choices[0] if isinstance(choices, list) else str(choices)
+                
+                if text and label:
+                    conversation = {
+                        "conversations": [
+                            {"from": "human", "value": text},
+                            {"from": "gpt", "value": label}
+                        ]
+                    }
+                    if include_metadata:
+                        conversation["id"] = task["id"]
+                        conversation["task_name"] = task["name"]
+                    sharegpt_data.append(conversation)
+        
+        # Write to file
+        cls.ensure_export_dir()
+        file_name = f"export_{project_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_sharegpt.json"
+        file_path = os.path.join(cls.EXPORT_DIR, file_name)
+        
+        with open(file_path, 'w', encoding='utf-8') as f:
+            json.dump(sharegpt_data, f, ensure_ascii=False, indent=2)
+        
+        return file_path, len(tasks), total_annotations
+    
+    @classmethod
+    def export_to_alpaca(
+        cls,
+        project_id: str,
+        status_filter: str = "all",
+        include_metadata: bool = True
+    ) -> Tuple[str, int, int]:
+        """
+        Export project data to Alpaca format.
+        
+        Alpaca format is used for instruction fine-tuning of LLMs.
+        Format: {"instruction": "...", "input": "...", "output": "..."}
+        
+        Args:
+            project_id: Project ID
+            status_filter: Task status filter
+            include_metadata: Whether to include metadata
+            
+        Returns:
+            Tuple of (file_path, total_tasks, total_annotations)
+        """
+        project = cls.get_project_data(project_id)
+        if not project:
+            raise ValueError(f"Project {project_id} not found")
+        
+        tasks = cls.get_tasks_with_annotations(project_id, status_filter)
+        
+        alpaca_data = []
+        total_annotations = 0
+        
+        for task in tasks:
+            task_data = task["data"]
+            
+            # Get text content
+            text = ""
+            if isinstance(task_data, dict):
+                text = task_data.get("text", task_data.get("content", ""))
+            elif isinstance(task_data, str):
+                text = task_data
+            
+            # Process annotations
+            for ann in task["annotations"]:
+                total_annotations += 1
+                result = ann["result"]
+                
+                # Extract label/classification result
+                label = ""
+                if isinstance(result, dict):
+                    choices = result.get("choices", result.get("result", []))
+                    if isinstance(choices, list) and choices:
+                        if isinstance(choices[0], dict):
+                            label = choices[0].get("value", {}).get("choices", [""])[0]
+                        else:
+                            label = str(choices[0])
+                    elif isinstance(choices, str):
+                        label = choices
+                elif isinstance(result, list) and result:
+                    first_item = result[0]
+                    if isinstance(first_item, dict):
+                        value = first_item.get("value", {})
+                        choices = value.get("choices", value.get("labels", []))
+                        if choices:
+                            label = choices[0] if isinstance(choices, list) else str(choices)
+                
+                if text and label:
+                    alpaca_item = {
+                        "instruction": "请对以下文本进行分类",
+                        "input": text,
+                        "output": label
+                    }
+                    if include_metadata:
+                        alpaca_item["id"] = task["id"]
+                        alpaca_item["task_name"] = task["name"]
+                    alpaca_data.append(alpaca_item)
+        
+        # Write to file
+        cls.ensure_export_dir()
+        file_name = f"export_{project_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_alpaca.json"
+        file_path = os.path.join(cls.EXPORT_DIR, file_name)
+        
+        with open(file_path, 'w', encoding='utf-8') as f:
+            json.dump(alpaca_data, f, ensure_ascii=False, indent=2)
+        
+        return file_path, len(tasks), total_annotations
+    
     @classmethod
     def execute_export(
         cls,
@@ -667,6 +1031,18 @@ class ExportService:
                 file_path, total_tasks, total_annotations = cls.export_to_yolo(
                     project_id, status_filter, include_metadata
                 )
+            elif format == ExportFormat.PASCAL_VOC.value:
+                file_path, total_tasks, total_annotations = cls.export_to_pascal_voc(
+                    project_id, status_filter, include_metadata
+                )
+            elif format == ExportFormat.SHAREGPT.value:
+                file_path, total_tasks, total_annotations = cls.export_to_sharegpt(
+                    project_id, status_filter, include_metadata
+                )
+            elif format == ExportFormat.ALPACA.value:
+                file_path, total_tasks, total_annotations = cls.export_to_alpaca(
+                    project_id, status_filter, include_metadata
+                )
             else:
                 raise ValueError(f"Unsupported export format: {format}")
             

+ 172 - 0
backend/services/external_service.py

@@ -523,6 +523,8 @@ class ExternalService:
             return ExternalService._export_coco(data, project_id, timestamp)
         elif format == ExternalExportFormat.ALPACA:
             return ExternalService._export_alpaca(data, project_id, timestamp)
+        elif format == ExternalExportFormat.PASCAL_VOC:
+            return ExternalService._export_pascal_voc(data, project_id, timestamp)
         else:
             return ExternalService._export_json(data, project_id, timestamp)
     
@@ -792,3 +794,173 @@ class ExternalService:
         
         content = json.dumps(alpaca_data, ensure_ascii=False, indent=2)
         return file_name, content
+
+    @staticmethod
+    def _export_pascal_voc(data: List[Dict], project_id: str, timestamp: str) -> tuple:
+        """
+        导出PascalVOC XML格式
+        
+        PascalVOC格式是一种常用的目标检测数据集格式,每张图片对应一个XML文件。
+        由于我们需要返回单个文件,这里返回一个包含所有标注的JSON文件,
+        其中每个条目包含对应的PascalVOC XML内容。
+        """
+        file_name = f"export_{project_id}_pascal_voc_{timestamp}.json"
+        
+        voc_data = []
+        
+        for idx, item in enumerate(data):
+            original = item.get('original_data', {})
+            annotations = item.get('annotations', [])
+            
+            image_url = original.get('image', '')
+            # 从URL中提取文件名
+            image_filename = image_url.split('/')[-1] if image_url else f"image_{idx + 1}.jpg"
+            
+            # 获取图像尺寸(如果有的话)
+            img_width = original.get('width', 0)
+            img_height = original.get('height', 0)
+            
+            objects = []
+            
+            # 处理标注
+            for ann in annotations:
+                if isinstance(ann, list):
+                    for a in ann:
+                        ann_type = a.get('type', '')
+                        value = a.get('value', {})
+                        
+                        if ann_type == 'rectanglelabels':
+                            label = value.get('rectanglelabels', [''])[0]
+                            if label:
+                                # 转换百分比坐标为像素坐标
+                                x_pct = value.get('x', 0)
+                                y_pct = value.get('y', 0)
+                                w_pct = value.get('width', 0)
+                                h_pct = value.get('height', 0)
+                                
+                                # 如果有图像尺寸,转换为像素;否则保持百分比
+                                if img_width > 0 and img_height > 0:
+                                    xmin = int(x_pct * img_width / 100)
+                                    ymin = int(y_pct * img_height / 100)
+                                    xmax = int((x_pct + w_pct) * img_width / 100)
+                                    ymax = int((y_pct + h_pct) * img_height / 100)
+                                else:
+                                    xmin = x_pct
+                                    ymin = y_pct
+                                    xmax = x_pct + w_pct
+                                    ymax = y_pct + h_pct
+                                
+                                objects.append({
+                                    "name": label,
+                                    "pose": "Unspecified",
+                                    "truncated": 0,
+                                    "difficult": 0,
+                                    "bndbox": {
+                                        "xmin": xmin,
+                                        "ymin": ymin,
+                                        "xmax": xmax,
+                                        "ymax": ymax
+                                    }
+                                })
+                        
+                        elif ann_type == 'polygonlabels':
+                            label = value.get('polygonlabels', [''])[0]
+                            points = value.get('points', [])
+                            
+                            if label and points:
+                                # 计算边界框
+                                x_coords = [p[0] for p in points]
+                                y_coords = [p[1] for p in points]
+                                
+                                if img_width > 0 and img_height > 0:
+                                    xmin = int(min(x_coords) * img_width / 100)
+                                    ymin = int(min(y_coords) * img_height / 100)
+                                    xmax = int(max(x_coords) * img_width / 100)
+                                    ymax = int(max(y_coords) * img_height / 100)
+                                else:
+                                    xmin = min(x_coords)
+                                    ymin = min(y_coords)
+                                    xmax = max(x_coords)
+                                    ymax = max(y_coords)
+                                
+                                # 转换多边形点坐标
+                                if img_width > 0 and img_height > 0:
+                                    polygon_points = [[int(p[0] * img_width / 100), int(p[1] * img_height / 100)] for p in points]
+                                else:
+                                    polygon_points = points
+                                
+                                objects.append({
+                                    "name": label,
+                                    "pose": "Unspecified",
+                                    "truncated": 0,
+                                    "difficult": 0,
+                                    "bndbox": {
+                                        "xmin": xmin,
+                                        "ymin": ymin,
+                                        "xmax": xmax,
+                                        "ymax": ymax
+                                    },
+                                    "polygon": polygon_points
+                                })
+            
+            # 生成PascalVOC XML内容
+            xml_content = ExternalService._generate_voc_xml(
+                image_filename,
+                img_width or 0,
+                img_height or 0,
+                objects
+            )
+            
+            voc_data.append({
+                "image": image_url,
+                "filename": image_filename,
+                "xml_content": xml_content,
+                "objects": objects
+            })
+        
+        content = json.dumps(voc_data, ensure_ascii=False, indent=2)
+        return file_name, content
+    
+    @staticmethod
+    def _generate_voc_xml(filename: str, width: int, height: int, objects: List[Dict]) -> str:
+        """生成PascalVOC格式的XML字符串"""
+        xml_lines = [
+            '<?xml version="1.0" encoding="UTF-8"?>',
+            '<annotation>',
+            f'  <filename>{filename}</filename>',
+            '  <source>',
+            '    <database>Annotation Platform</database>',
+            '  </source>',
+            '  <size>',
+            f'    <width>{width}</width>',
+            f'    <height>{height}</height>',
+            '    <depth>3</depth>',
+            '  </size>',
+            '  <segmented>0</segmented>'
+        ]
+        
+        for obj in objects:
+            xml_lines.append('  <object>')
+            xml_lines.append(f'    <name>{obj["name"]}</name>')
+            xml_lines.append(f'    <pose>{obj.get("pose", "Unspecified")}</pose>')
+            xml_lines.append(f'    <truncated>{obj.get("truncated", 0)}</truncated>')
+            xml_lines.append(f'    <difficult>{obj.get("difficult", 0)}</difficult>')
+            xml_lines.append('    <bndbox>')
+            xml_lines.append(f'      <xmin>{obj["bndbox"]["xmin"]}</xmin>')
+            xml_lines.append(f'      <ymin>{obj["bndbox"]["ymin"]}</ymin>')
+            xml_lines.append(f'      <xmax>{obj["bndbox"]["xmax"]}</xmax>')
+            xml_lines.append(f'      <ymax>{obj["bndbox"]["ymax"]}</ymax>')
+            xml_lines.append('    </bndbox>')
+            
+            # 如果有多边形数据,也添加进去
+            if 'polygon' in obj:
+                xml_lines.append('    <polygon>')
+                for point in obj['polygon']:
+                    xml_lines.append(f'      <pt><x>{point[0]}</x><y>{point[1]}</y></pt>')
+                xml_lines.append('    </polygon>')
+            
+            xml_lines.append('  </object>')
+        
+        xml_lines.append('</annotation>')
+        
+        return '\n'.join(xml_lines)

BIN
lq_label_dist.tar.gz


+ 16 - 1
web/apps/lq_label/src/atoms/export-atoms.ts

@@ -9,7 +9,7 @@ import { atom } from 'jotai';
 /**
  * Export format type
  */
-export type ExportFormat = 'json' | 'csv' | 'coco' | 'yolo';
+export type ExportFormat = 'json' | 'csv' | 'coco' | 'yolo' | 'pascal_voc' | 'sharegpt' | 'alpaca';
 
 /**
  * Export status type
@@ -189,6 +189,21 @@ export const exportFormatOptions: Array<{
     label: 'YOLO',
     description: 'YOLO 训练格式',
   },
+  {
+    value: 'pascal_voc',
+    label: 'VOC',
+    description: 'PascalVOC XML 格式',
+  },
+  {
+    value: 'sharegpt',
+    label: 'ShareGPT',
+    description: '对话模型训练格式',
+  },
+  {
+    value: 'alpaca',
+    label: 'Alpaca',
+    description: 'LLM 指令微调格式',
+  },
 ];
 
 /**

+ 172 - 119
web/apps/lq_label/src/components/data-export-dialog/data-export-dialog.module.scss

@@ -1,4 +1,8 @@
-// DataExportDialog Styles
+/**
+ * DataExportDialog Styles
+ * 
+ * Styles for the data export dialog component.
+ */
 
 .overlay {
   position: fixed;
@@ -6,44 +10,51 @@
   left: 0;
   right: 0;
   bottom: 0;
-  background: rgba(0, 0, 0, 0.5);
+  background-color: rgba(0, 0, 0, 0.5);
   display: flex;
   align-items: center;
   justify-content: center;
-  z-index: 1000;
+  z-index: 1100;
+  padding: var(--spacing-base);
 }
 
 .dialog {
   background: var(--theme-background);
   border-radius: var(--corner-radius-medium);
-  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
   width: 100%;
-  max-width: 520px;
+  max-width: 480px;
   max-height: 90vh;
+  overflow: hidden;
   display: flex;
   flex-direction: column;
-  overflow: hidden;
 }
 
 .header {
   display: flex;
   align-items: center;
-  justify-content: space-between;
+  gap: var(--spacing-tight);
   padding: var(--spacing-base);
   border-bottom: 1px solid var(--theme-border);
 }
 
-.headerTitle {
+.headerIcon {
   display: flex;
   align-items: center;
-  gap: var(--spacing-tight);
-  color: var(--theme-headline);
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  border-radius: var(--corner-radius-small);
+  background: var(--theme-background-secondary);
+  color: var(--theme-button);
+}
 
-  h2 {
-    font-size: var(--font-size-body-large);
-    font-weight: 600;
-    margin: 0;
-  }
+.title {
+  flex: 1;
+  font-size: var(--font-size-title-large);
+  font-weight: 600;
+  color: var(--theme-headline);
+  margin: 0;
 }
 
 .closeButton {
@@ -54,185 +65,164 @@
   height: 32px;
   border: none;
   background: transparent;
+  border-radius: var(--corner-radius-small);
   color: var(--theme-paragraph-subtle);
   cursor: pointer;
-  border-radius: var(--corner-radius-small);
   transition: all 0.2s;
 
   &:hover {
     background: var(--theme-background-secondary);
     color: var(--theme-headline);
   }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
 }
 
 .content {
-  flex: 1;
-  overflow: auto;
   padding: var(--spacing-base);
+  overflow-y: auto;
+  flex: 1;
 }
 
 .projectInfo {
   display: flex;
+  flex-wrap: wrap;
   align-items: center;
   gap: var(--spacing-tight);
-  padding: var(--spacing-tight) var(--spacing-base);
+  padding: var(--spacing-tight);
   background: var(--theme-background-secondary);
   border-radius: var(--corner-radius-small);
   margin-bottom: var(--spacing-base);
 }
 
-.projectLabel {
+.label {
   font-size: var(--font-size-body-small);
   color: var(--theme-paragraph-subtle);
 }
 
-.projectName {
+.value {
   font-size: var(--font-size-body-medium);
   font-weight: 500;
   color: var(--theme-headline);
 }
 
-.configStep {
-  display: flex;
-  flex-direction: column;
-  gap: var(--spacing-base);
+.taskCount {
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph-subtle);
+  margin-left: auto;
 }
 
 .section {
-  display: flex;
-  flex-direction: column;
-  gap: var(--spacing-tight);
+  margin-bottom: var(--spacing-base);
 }
 
-.sectionTitle {
-  font-size: var(--font-size-body-medium);
-  font-weight: 600;
-  color: var(--theme-headline);
-  margin: 0;
+.sectionLabel {
+  display: block;
+  font-size: var(--font-size-body-small);
+  font-weight: 500;
+  color: var(--theme-paragraph-subtle);
+  margin-bottom: var(--spacing-tight);
 }
 
 .formatGrid {
   display: grid;
-  grid-template-columns: repeat(2, 1fr);
+  grid-template-columns: repeat(4, 1fr);
   gap: var(--spacing-tight);
 }
 
-.formatCard {
+.formatOption {
   display: flex;
-  align-items: flex-start;
-  gap: var(--spacing-tight);
-  padding: var(--spacing-base);
-  border: 2px solid var(--theme-border);
+  flex-direction: column;
+  align-items: center;
+  gap: var(--spacing-tighter);
+  padding: var(--spacing-tight);
+  border: 1px solid var(--theme-border);
   border-radius: var(--corner-radius-small);
+  background: var(--theme-background);
+  color: var(--theme-paragraph);
   cursor: pointer;
   transition: all 0.2s;
 
   &:hover {
     border-color: var(--theme-button);
-    background: rgba(37, 99, 235, 0.05);
+    background: var(--theme-background-secondary);
   }
-}
 
-.formatCardSelected {
-  border-color: var(--theme-button);
-  background: rgba(37, 99, 235, 0.1);
+  &.selected {
+    border-color: var(--theme-button);
+    background: var(--theme-background-secondary);
+    color: var(--theme-button);
+  }
 }
 
 .formatIcon {
   display: flex;
   align-items: center;
   justify-content: center;
-  width: 40px;
-  height: 40px;
-  background: var(--theme-background-secondary);
-  border-radius: var(--corner-radius-small);
-  color: var(--theme-button);
-  flex-shrink: 0;
-}
-
-.formatInfo {
-  display: flex;
-  flex-direction: column;
-  gap: 2px;
+  color: inherit;
 }
 
 .formatLabel {
-  font-size: var(--font-size-body-medium);
-  font-weight: 600;
-  color: var(--theme-headline);
+  font-size: var(--font-size-body-small);
+  font-weight: 500;
 }
 
 .formatDescription {
   font-size: var(--font-size-body-small);
   color: var(--theme-paragraph-subtle);
+  margin-top: var(--spacing-tighter);
 }
 
-.statusFilter {
-  display: flex;
-  gap: var(--spacing-tight);
-  flex-wrap: wrap;
-}
-
-.statusButton {
-  padding: var(--spacing-tight) var(--spacing-base);
+.select {
+  width: 100%;
+  padding: var(--spacing-tight);
   border: 1px solid var(--theme-border);
   border-radius: var(--corner-radius-small);
   background: var(--theme-background);
-  color: var(--theme-paragraph);
-  font-size: var(--font-size-body-small);
+  font-size: var(--font-size-body-medium);
+  color: var(--theme-headline);
   cursor: pointer;
-  transition: all 0.2s;
 
-  &:hover {
+  &:focus {
+    outline: none;
     border-color: var(--theme-button);
-    color: var(--theme-button);
   }
 }
 
-.statusButtonActive {
-  border-color: var(--theme-button);
-  background: var(--theme-button);
-  color: white;
-
-  &:hover {
-    background: var(--theme-button);
-    color: white;
-  }
-}
-
-.checkbox {
+.checkboxLabel {
   display: flex;
   align-items: center;
   gap: var(--spacing-tight);
-  cursor: pointer;
   font-size: var(--font-size-body-medium);
-  color: var(--theme-paragraph);
+  color: var(--theme-headline);
+  cursor: pointer;
 
-  input {
-    width: 18px;
-    height: 18px;
+  input[type="checkbox"] {
+    width: 16px;
+    height: 16px;
     cursor: pointer;
   }
 }
 
-.progressStep,
-.completeStep,
-.errorStep {
+.exportingState,
+.completedState,
+.errorState {
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  padding: var(--spacing-loose) 0;
+  padding: var(--spacing-widest);
   text-align: center;
-  gap: var(--spacing-base);
-}
-
-.progressIcon {
-  color: var(--theme-button);
+  gap: var(--spacing-tight);
+  color: var(--theme-paragraph);
 }
 
 .spinner {
   animation: spin 1s linear infinite;
+  color: var(--theme-button);
 }
 
 @keyframes spin {
@@ -244,61 +234,124 @@
   }
 }
 
-.progressTitle,
-.completeTitle,
-.errorTitle {
-  font-size: var(--font-size-body-large);
-  font-weight: 600;
-  color: var(--theme-headline);
-  margin: 0;
+.progressInfo {
+  width: 100%;
+  max-width: 300px;
 }
 
 .progressBar {
   width: 100%;
-  max-width: 300px;
   height: 8px;
   background: var(--theme-background-secondary);
-  border-radius: 4px;
+  border-radius: var(--corner-radius-small);
   overflow: hidden;
+  margin-bottom: var(--spacing-tighter);
 }
 
 .progressFill {
   height: 100%;
   background: var(--theme-button);
-  border-radius: 4px;
+  border-radius: var(--corner-radius-small);
   transition: width 0.3s ease;
 }
 
 .progressText {
-  font-size: var(--font-size-body-medium);
+  font-size: var(--font-size-body-small);
   color: var(--theme-paragraph-subtle);
 }
 
-.completeIcon {
-  color: var(--theme-success);
+.successIcon {
+  color: var(--theme-success, #22c55e);
 }
 
-.completeDescription {
-  font-size: var(--font-size-body-medium);
-  color: var(--theme-paragraph);
-  margin: 0;
+.exportInfo {
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph-subtle);
 }
 
 .errorIcon {
   color: var(--theme-error);
 }
 
-.errorDescription {
-  font-size: var(--font-size-body-medium);
+.errorMessage {
+  font-size: var(--font-size-body-small);
   color: var(--theme-error);
-  margin: 0;
 }
 
 .footer {
   display: flex;
-  align-items: center;
   justify-content: flex-end;
   gap: var(--spacing-tight);
   padding: var(--spacing-base);
   border-top: 1px solid var(--theme-border);
 }
+
+.cancelButton {
+  padding: var(--spacing-tight) var(--spacing-base);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  background: var(--theme-background);
+  font-size: var(--font-size-body-medium);
+  color: var(--theme-headline);
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-background-secondary);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.exportButton,
+.downloadButton {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-tighter);
+  padding: var(--spacing-tight) var(--spacing-base);
+  border: none;
+  border-radius: var(--corner-radius-small);
+  background: var(--theme-button);
+  font-size: var(--font-size-body-medium);
+  color: var(--theme-button-text);
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-button-hover);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.downloadButton {
+  background: var(--theme-success, #22c55e);
+
+  &:hover:not(:disabled) {
+    opacity: 0.9;
+  }
+}
+
+.retryButton {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-tighter);
+  padding: var(--spacing-tight) var(--spacing-base);
+  border: none;
+  border-radius: var(--corner-radius-small);
+  background: var(--theme-warning, #f59e0b);
+  font-size: var(--font-size-body-medium);
+  color: white;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover:not(:disabled) {
+    opacity: 0.9;
+  }
+}

+ 289 - 250
web/apps/lq_label/src/components/data-export-dialog/data-export-dialog.tsx

@@ -1,201 +1,212 @@
 /**
  * DataExportDialog Component
- *
+ * 
  * Dialog for exporting project data in various formats.
- * Supports format selection, status filtering, and progress display.
- * Requirements: 8.1, 8.2, 8.3, 8.4, 8.6
+ * Supports JSON, CSV, COCO, and YOLO formats.
+ * 
+ * Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6
  */
-import React, { useState, useCallback, useEffect, useRef } from 'react';
-import { useAtom } from 'jotai';
-import {
-  Download,
-  X,
-  FileJson,
-  FileSpreadsheet,
-  Box,
-  Loader2,
-  CheckCircle,
-  AlertCircle,
-  RefreshCw,
-} from 'lucide-react';
-import { Button } from '@humansignal/ui';
-import {
-  exportJobAtom,
-  exportProgressAtom,
-  exportLoadingAtom,
-  exportErrorAtom,
-  exportRequestAtom,
-  exportFormatOptions,
-  statusFilterOptions,
+import React, { useState, useEffect } from 'react';
+import { X, Download, FileJson, FileSpreadsheet, Box, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
+import { 
+  createExport, 
+  getExportStatus, 
+  downloadExport,
   type ExportFormat,
-  type StatusFilter,
-} from '../../atoms/export-atoms';
-import {
-  createExport,
-  getExportStatus,
-  getExportDownloadUrl,
-  type ExportRequest,
+  type ExportStatusFilter,
+  type ExportJob,
+  type ExportProgress,
 } from '../../services/api';
+import type { Project } from '../../atoms/project-atoms';
 import styles from './data-export-dialog.module.scss';
 
-/**
- * Props for DataExportDialog
- */
-interface DataExportDialogProps {
-  /** Project ID to export */
-  projectId: string;
-  /** Project name for display */
-  projectName?: string;
-  /** Whether the dialog is open */
+export interface DataExportDialogProps {
+  project: Project;
   isOpen: boolean;
-  /** Callback when dialog closes */
   onClose: () => void;
-  /** Callback when export completes */
   onExportComplete?: () => void;
 }
 
-/**
- * Format icon mapping
- */
-const formatIcons: Record<ExportFormat, React.ReactNode> = {
-  json: <FileJson size={20} />,
-  csv: <FileSpreadsheet size={20} />,
-  coco: <Box size={20} />,
-  yolo: <Box size={20} />,
-};
+interface FormatOption {
+  value: ExportFormat;
+  label: string;
+  description: string;
+  icon: React.ReactNode;
+}
+
+const FORMAT_OPTIONS: FormatOption[] = [
+  {
+    value: 'json',
+    label: 'JSON',
+    description: '通用 JSON 格式,包含完整的标注数据',
+    icon: <FileJson size={20} />,
+  },
+  {
+    value: 'csv',
+    label: 'CSV',
+    description: '表格格式,适合在 Excel 中查看',
+    icon: <FileSpreadsheet size={20} />,
+  },
+  {
+    value: 'coco',
+    label: 'COCO',
+    description: 'COCO 数据集格式,适用于目标检测任务',
+    icon: <Box size={20} />,
+  },
+  {
+    value: 'yolo',
+    label: 'YOLO',
+    description: 'YOLO 格式,适用于目标检测训练',
+    icon: <Box size={20} />,
+  },
+  {
+    value: 'pascal_voc',
+    label: 'VOC',
+    description: 'PascalVOC XML 格式,经典目标检测格式',
+    icon: <Box size={20} />,
+  },
+  {
+    value: 'sharegpt',
+    label: 'ShareGPT',
+    description: 'ShareGPT 对话格式,适用于对话模型训练',
+    icon: <FileJson size={20} />,
+  },
+  {
+    value: 'alpaca',
+    label: 'Alpaca',
+    description: 'Alpaca 指令微调格式,适用于 LLM 训练',
+    icon: <FileJson size={20} />,
+  },
+];
+
+const STATUS_FILTER_OPTIONS: { value: ExportStatusFilter; label: string }[] = [
+  { value: 'all', label: '全部任务' },
+  { value: 'completed', label: '仅已完成' },
+  { value: 'in_progress', label: '仅进行中' },
+  { value: 'pending', label: '仅待处理' },
+];
+
+type ExportState = 'idle' | 'exporting' | 'completed' | 'error';
 
 export const DataExportDialog: React.FC<DataExportDialogProps> = ({
-  projectId,
-  projectName,
+  project,
   isOpen,
   onClose,
   onExportComplete,
 }) => {
-  // Atoms
-  const [exportJob, setExportJob] = useAtom(exportJobAtom);
-  const [progress, setProgress] = useAtom(exportProgressAtom);
-  const [isLoading, setIsLoading] = useAtom(exportLoadingAtom);
-  const [error, setError] = useAtom(exportErrorAtom);
-  const [request, setRequest] = useAtom(exportRequestAtom);
-
-  // Local state
-  const [step, setStep] = useState<'config' | 'progress' | 'complete' | 'error'>('config');
-
-  // Refs
-  const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
+  const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('json');
+  const [statusFilter, setStatusFilter] = useState<ExportStatusFilter>('completed');
+  const [includeMetadata, setIncludeMetadata] = useState(true);
+  const [exportState, setExportState] = useState<ExportState>('idle');
+  const [exportJob, setExportJob] = useState<ExportJob | null>(null);
+  const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [isDownloading, setIsDownloading] = useState(false);
 
   // Reset state when dialog opens
   useEffect(() => {
     if (isOpen) {
-      setStep('config');
+      setExportState('idle');
       setExportJob(null);
-      setProgress(0);
+      setExportProgress(null);
       setError(null);
-      setRequest({
-        format: 'json',
-        status_filter: 'all',
-        include_metadata: true,
-      });
     }
-    return () => {
-      if (pollIntervalRef.current) {
-        clearInterval(pollIntervalRef.current);
-      }
-    };
   }, [isOpen]);
 
-  // Handle format change
-  const handleFormatChange = useCallback(
-    (format: ExportFormat) => {
-      setRequest((prev) => ({ ...prev, format }));
-    },
-    [setRequest]
-  );
-
-  // Handle status filter change
-  const handleStatusFilterChange = useCallback(
-    (status_filter: StatusFilter) => {
-      setRequest((prev) => ({ ...prev, status_filter }));
-    },
-    [setRequest]
-  );
-
-  // Handle metadata toggle
-  const handleMetadataToggle = useCallback(() => {
-    setRequest((prev) => ({ ...prev, include_metadata: !prev.include_metadata }));
-  }, [setRequest]);
-
-  // Start export
-  const handleStartExport = useCallback(async () => {
-    setIsLoading(true);
-    setError(null);
-    setStep('progress');
-
-    try {
-      const exportRequest: ExportRequest = {
-        format: request.format,
-        status_filter: request.status_filter,
-        include_metadata: request.include_metadata,
-      };
-
-      const job = await createExport(projectId, exportRequest);
-      setExportJob(job);
+  // Poll for export status when exporting
+  useEffect(() => {
+    let pollInterval: NodeJS.Timeout | null = null;
 
-      // Start polling for progress
-      pollIntervalRef.current = setInterval(async () => {
+    if (exportState === 'exporting' && exportJob) {
+      pollInterval = setInterval(async () => {
         try {
-          const status = await getExportStatus(job.id);
-          setProgress(status.progress);
+          const progress = await getExportStatus(exportJob.id);
+          setExportProgress(progress);
 
-          if (status.status === 'completed') {
-            if (pollIntervalRef.current) {
-              clearInterval(pollIntervalRef.current);
-            }
-            setStep('complete');
-            setIsLoading(false);
+          if (progress.status === 'completed') {
+            setExportState('completed');
             onExportComplete?.();
-          } else if (status.status === 'failed') {
-            if (pollIntervalRef.current) {
-              clearInterval(pollIntervalRef.current);
-            }
-            setError(status.error_message || '导出失败');
-            setStep('error');
-            setIsLoading(false);
+            if (pollInterval) clearInterval(pollInterval);
+          } else if (progress.status === 'failed') {
+            setExportState('error');
+            setError(progress.error_message || '导出失败');
+            if (pollInterval) clearInterval(pollInterval);
           }
-        } catch (err) {
-          console.error('Failed to get export status:', err);
+        } catch (err: any) {
+          setExportState('error');
+          setError(err.message || '获取导出状态失败');
+          if (pollInterval) clearInterval(pollInterval);
         }
       }, 1000);
-    } catch (err: unknown) {
-      const errorMessage = err instanceof Error ? err.message : '创建导出任务失败';
-      setError(errorMessage);
-      setStep('error');
-      setIsLoading(false);
     }
-  }, [projectId, request, setExportJob, setProgress, setError, setIsLoading, onExportComplete]);
 
-  // Handle download
-  const handleDownload = useCallback(() => {
-    if (exportJob?.id) {
-      const downloadUrl = getExportDownloadUrl(exportJob.id);
-      window.open(downloadUrl, '_blank');
+    return () => {
+      if (pollInterval) clearInterval(pollInterval);
+    };
+  }, [exportState, exportJob, onExportComplete]);
+
+  const handleExport = async () => {
+    try {
+      setExportState('exporting');
+      setError(null);
+
+      const job = await createExport(project.id, {
+        format: selectedFormat,
+        status_filter: statusFilter,
+        include_metadata: includeMetadata,
+      });
+
+      setExportJob(job);
+
+      // If job is already completed (synchronous export)
+      if (job.status === 'completed') {
+        setExportState('completed');
+        onExportComplete?.();
+      } else if (job.status === 'failed') {
+        setExportState('error');
+        setError(job.error_message || '导出失败');
+      }
+    } catch (err: any) {
+      setExportState('error');
+      setError(err.message || '启动导出失败');
     }
-  }, [exportJob]);
+  };
 
-  // Handle retry
-  const handleRetry = useCallback(() => {
-    setStep('config');
-    setError(null);
-    setProgress(0);
-  }, [setError, setProgress]);
+  const handleDownload = async () => {
+    if (!exportJob) return;
+    
+    try {
+      setIsDownloading(true);
+      const blob = await downloadExport(exportJob.id);
+      
+      // 创建下载链接
+      const url = window.URL.createObjectURL(blob);
+      const link = document.createElement('a');
+      link.href = url;
+      
+      // 根据格式设置文件名
+      const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+      const extension = selectedFormat === 'csv' ? 'csv' : 'json';
+      link.download = `export_${project.name}_${timestamp}.${extension}`;
+      
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      window.URL.revokeObjectURL(url);
+    } catch (err: any) {
+      setError(err.message || '下载文件失败');
+    } finally {
+      setIsDownloading(false);
+    }
+  };
 
-  // Handle close
-  const handleClose = useCallback(() => {
-    if (pollIntervalRef.current) {
-      clearInterval(pollIntervalRef.current);
+  const handleClose = () => {
+    if (exportState !== 'exporting') {
+      onClose();
     }
-    onClose();
-  }, [onClose]);
+  };
+
+  const selectedFormatOption = FORMAT_OPTIONS.find(f => f.value === selectedFormat);
 
   if (!isOpen) return null;
 
@@ -204,149 +215,177 @@ export const DataExportDialog: React.FC<DataExportDialogProps> = ({
       <div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
         {/* Header */}
         <div className={styles.header}>
-          <div className={styles.headerTitle}>
-            <Download size={20} />
-            <h2>导出数据</h2>
+          <div className={styles.headerIcon}>
+            <Download size={24} />
           </div>
-          <button className={styles.closeButton} onClick={handleClose}>
+          <h2 className={styles.title}>导出数据</h2>
+          <button 
+            className={styles.closeButton} 
+            onClick={handleClose}
+            disabled={exportState === 'exporting'}
+          >
             <X size={20} />
           </button>
         </div>
 
         {/* Content */}
         <div className={styles.content}>
-          {projectName && (
-            <div className={styles.projectInfo}>
-              <span className={styles.projectLabel}>项目:</span>
-              <span className={styles.projectName}>{projectName}</span>
-            </div>
-          )}
+          {/* Project Info */}
+          <div className={styles.projectInfo}>
+            <span className={styles.label}>项目</span>
+            <span className={styles.value}>{project.name}</span>
+            <span className={styles.taskCount}>
+              {project.completed_task_count}/{project.task_count} 任务已完成
+            </span>
+          </div>
 
-          {/* Config Step */}
-          {step === 'config' && (
-            <div className={styles.configStep}>
+          {/* Export Options */}
+          {exportState === 'idle' && (
+            <>
               {/* Format Selection */}
               <div className={styles.section}>
-                <h3 className={styles.sectionTitle}>导出格式</h3>
+                <label className={styles.sectionLabel}>导出格式</label>
                 <div className={styles.formatGrid}>
-                  {exportFormatOptions.map((option) => (
-                    <div
+                  {FORMAT_OPTIONS.map((option) => (
+                    <button
                       key={option.value}
-                      className={`${styles.formatCard} ${request.format === option.value ? styles.formatCardSelected : ''}`}
-                      onClick={() => handleFormatChange(option.value)}
+                      className={`${styles.formatOption} ${selectedFormat === option.value ? styles.selected : ''}`}
+                      onClick={() => setSelectedFormat(option.value)}
                     >
-                      <div className={styles.formatIcon}>
-                        {formatIcons[option.value]}
-                      </div>
-                      <div className={styles.formatInfo}>
-                        <span className={styles.formatLabel}>{option.label}</span>
-                        <span className={styles.formatDescription}>
-                          {option.description}
-                        </span>
-                      </div>
-                    </div>
+                      <div className={styles.formatIcon}>{option.icon}</div>
+                      <span className={styles.formatLabel}>{option.label}</span>
+                    </button>
                   ))}
                 </div>
+                {selectedFormatOption && (
+                  <p className={styles.formatDescription}>
+                    {selectedFormatOption.description}
+                  </p>
+                )}
               </div>
 
               {/* Status Filter */}
               <div className={styles.section}>
-                <h3 className={styles.sectionTitle}>任务状态筛选</h3>
-                <div className={styles.statusFilter}>
-                  {statusFilterOptions.map((option) => (
-                    <button
-                      key={option.value}
-                      className={`${styles.statusButton} ${request.status_filter === option.value ? styles.statusButtonActive : ''}`}
-                      onClick={() => handleStatusFilterChange(option.value)}
-                    >
+                <label className={styles.sectionLabel}>任务筛选</label>
+                <select
+                  className={styles.select}
+                  value={statusFilter}
+                  onChange={(e) => setStatusFilter(e.target.value as ExportStatusFilter)}
+                >
+                  {STATUS_FILTER_OPTIONS.map((option) => (
+                    <option key={option.value} value={option.value}>
                       {option.label}
-                    </button>
+                    </option>
                   ))}
-                </div>
+                </select>
               </div>
 
-              {/* Options */}
+              {/* Include Metadata */}
               <div className={styles.section}>
-                <h3 className={styles.sectionTitle}>导出选项</h3>
-                <label className={styles.checkbox}>
+                <label className={styles.checkboxLabel}>
                   <input
                     type="checkbox"
-                    checked={request.include_metadata}
-                    onChange={handleMetadataToggle}
+                    checked={includeMetadata}
+                    onChange={(e) => setIncludeMetadata(e.target.checked)}
                   />
                   <span>包含元数据</span>
                 </label>
               </div>
-            </div>
+            </>
           )}
 
-          {/* Progress Step */}
-          {step === 'progress' && (
-            <div className={styles.progressStep}>
-              <div className={styles.progressIcon}>
-                <Loader2 size={48} className={styles.spinner} />
-              </div>
-              <h3 className={styles.progressTitle}>正在导出...</h3>
-              <div className={styles.progressBar}>
-                <div
-                  className={styles.progressFill}
-                  style={{ width: `${progress}%` }}
-                />
-              </div>
-              <span className={styles.progressText}>{Math.round(progress)}%</span>
+          {/* Exporting State */}
+          {exportState === 'exporting' && (
+            <div className={styles.exportingState}>
+              <Loader2 size={32} className={styles.spinner} />
+              <p>正在导出数据...</p>
+              {exportProgress && (
+                <div className={styles.progressInfo}>
+                  <div className={styles.progressBar}>
+                    <div
+                      className={styles.progressFill}
+                      style={{ width: `${exportProgress.progress * 100}%` }}
+                    />
+                  </div>
+                  <span className={styles.progressText}>
+                    {exportProgress.exported_tasks}/{exportProgress.total_tasks} 任务
+                  </span>
+                </div>
+              )}
             </div>
           )}
 
-          {/* Complete Step */}
-          {step === 'complete' && (
-            <div className={styles.completeStep}>
-              <div className={styles.completeIcon}>
-                <CheckCircle size={48} />
-              </div>
-              <h3 className={styles.completeTitle}>导出完成</h3>
-              <p className={styles.completeDescription}>
-                数据已成功导出为 {request.format.toUpperCase()} 格式
-              </p>
-              <Button variant="primary" onClick={handleDownload}>
-                <Download size={16} />
-                下载文件
-              </Button>
+          {/* Completed State */}
+          {exportState === 'completed' && (
+            <div className={styles.completedState}>
+              <CheckCircle size={32} className={styles.successIcon} />
+              <p>导出完成!</p>
+              {exportJob && (
+                <p className={styles.exportInfo}>
+                  已导出 {exportJob.exported_tasks} 个任务
+                </p>
+              )}
             </div>
           )}
 
-          {/* Error Step */}
-          {step === 'error' && (
-            <div className={styles.errorStep}>
-              <div className={styles.errorIcon}>
-                <AlertCircle size={48} />
-              </div>
-              <h3 className={styles.errorTitle}>导出失败</h3>
-              <p className={styles.errorDescription}>{error}</p>
-              <Button variant="neutral" look="outlined" onClick={handleRetry}>
-                <RefreshCw size={16} />
-                重试
-              </Button>
+          {/* Error State */}
+          {exportState === 'error' && (
+            <div className={styles.errorState}>
+              <AlertCircle size={32} className={styles.errorIcon} />
+              <p>导出失败</p>
+              {error && <p className={styles.errorMessage}>{error}</p>}
             </div>
           )}
         </div>
 
         {/* Footer */}
         <div className={styles.footer}>
-          {step === 'config' && (
+          {exportState === 'idle' && (
             <>
-              <Button variant="neutral" look="outlined" onClick={handleClose}>
+              <button className={styles.cancelButton} onClick={handleClose}>
                 取消
-              </Button>
-              <Button variant="primary" onClick={handleStartExport}>
+              </button>
+              <button className={styles.exportButton} onClick={handleExport}>
                 <Download size={16} />
                 开始导出
-              </Button>
+              </button>
+            </>
+          )}
+
+          {exportState === 'exporting' && (
+            <button className={styles.cancelButton} disabled>
+              导出中...
+            </button>
+          )}
+
+          {exportState === 'completed' && (
+            <>
+              <button className={styles.cancelButton} onClick={handleClose}>
+                关闭
+              </button>
+              <button 
+                className={styles.downloadButton} 
+                onClick={handleDownload}
+                disabled={isDownloading}
+              >
+                <Download size={16} />
+                {isDownloading ? '下载中...' : '下载文件'}
+              </button>
             </>
           )}
-          {(step === 'complete' || step === 'error') && (
-            <Button variant="neutral" look="outlined" onClick={handleClose}>
-              关闭
-            </Button>
+
+          {exportState === 'error' && (
+            <>
+              <button className={styles.cancelButton} onClick={handleClose}>
+                关闭
+              </button>
+              <button 
+                className={styles.retryButton} 
+                onClick={() => setExportState('idle')}
+              >
+                重试
+              </button>
+            </>
           )}
         </div>
       </div>

+ 2 - 2
web/apps/lq_label/src/components/data-export-dialog/index.ts

@@ -1,4 +1,4 @@
 /**
- * DataExportDialog component exports
+ * DataExportDialog Component Export
  */
-export { DataExportDialog } from './data-export-dialog';
+export { DataExportDialog, type DataExportDialogProps } from './data-export-dialog';

+ 2 - 0
web/apps/lq_label/src/components/project-completion-dialog/index.ts

@@ -0,0 +1,2 @@
+export { ProjectCompletionDialog } from './project-completion-dialog';
+export type { ProjectCompletionDialogProps } from './project-completion-dialog';

+ 216 - 0
web/apps/lq_label/src/components/project-completion-dialog/project-completion-dialog.module.scss

@@ -0,0 +1,216 @@
+/**
+ * ProjectCompletionDialog Styles
+ */
+
+.overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.dialog {
+  background: var(--color-primary-background);
+  border-radius: var(--radius-400);
+  box-shadow: var(--shadow-400);
+  width: 100%;
+  max-width: 420px;
+  max-height: 90vh;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.header {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-tight);
+  padding: var(--spacing-normal) var(--spacing-loose);
+  border-bottom: 1px solid var(--color-primary-border);
+}
+
+.headerIcon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--color-success-text);
+}
+
+.title {
+  flex: 1;
+  font-size: var(--font-size-body-large);
+  font-weight: var(--font-weight-semibold);
+  color: var(--color-primary-text);
+  margin: 0;
+}
+
+.closeButton {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border: none;
+  background: transparent;
+  border-radius: var(--radius-200);
+  cursor: pointer;
+  color: var(--color-secondary-text);
+  transition: all 0.2s ease;
+
+  &:hover {
+    background: var(--color-secondary-background);
+    color: var(--color-primary-text);
+  }
+}
+
+.content {
+  padding: var(--spacing-loose);
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-normal);
+}
+
+.projectInfo {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-extra-tight);
+}
+
+.label {
+  font-size: var(--font-size-body-small);
+  color: var(--color-secondary-text);
+}
+
+.value {
+  font-size: var(--font-size-body-medium);
+  font-weight: var(--font-weight-medium);
+  color: var(--color-primary-text);
+}
+
+.progressInfo {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-extra-tight);
+}
+
+.progressBar {
+  height: 8px;
+  background: var(--color-secondary-background);
+  border-radius: var(--radius-100);
+  overflow: hidden;
+}
+
+.progressFill {
+  height: 100%;
+  background: var(--color-success-background);
+  border-radius: var(--radius-100);
+  transition: width 0.3s ease;
+}
+
+.progressText {
+  font-size: var(--font-size-body-small);
+  color: var(--color-secondary-text);
+}
+
+.warningMessage {
+  display: flex;
+  align-items: flex-start;
+  gap: var(--spacing-tight);
+  padding: var(--spacing-tight) var(--spacing-normal);
+  background: var(--color-warning-background);
+  border-radius: var(--radius-200);
+  color: var(--color-warning-text);
+  font-size: var(--font-size-body-small);
+
+  svg {
+    flex-shrink: 0;
+    margin-top: 2px;
+  }
+}
+
+.confirmMessage {
+  text-align: center;
+  padding: var(--spacing-tight) 0;
+
+  p {
+    margin: 0;
+    color: var(--color-primary-text);
+    font-size: var(--font-size-body-medium);
+  }
+
+  .hint {
+    margin-top: var(--spacing-extra-tight);
+    font-size: var(--font-size-body-small);
+    color: var(--color-secondary-text);
+  }
+}
+
+.errorMessage {
+  display: flex;
+  align-items: flex-start;
+  gap: var(--spacing-tight);
+  padding: var(--spacing-tight) var(--spacing-normal);
+  background: var(--color-error-background);
+  border-radius: var(--radius-200);
+  color: var(--color-error-text);
+  font-size: var(--font-size-body-small);
+
+  svg {
+    flex-shrink: 0;
+    margin-top: 2px;
+  }
+}
+
+.footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: var(--spacing-tight);
+  padding: var(--spacing-normal) var(--spacing-loose);
+  border-top: 1px solid var(--color-primary-border);
+}
+
+.cancelButton {
+  padding: var(--spacing-tight) var(--spacing-normal);
+  border: 1px solid var(--color-primary-border);
+  background: var(--color-primary-background);
+  border-radius: var(--radius-200);
+  font-size: var(--font-size-body-medium);
+  color: var(--color-primary-text);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover:not(:disabled) {
+    background: var(--color-secondary-background);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.confirmButton {
+  padding: var(--spacing-tight) var(--spacing-normal);
+  border: none;
+  background: var(--color-success-background);
+  border-radius: var(--radius-200);
+  font-size: var(--font-size-body-medium);
+  color: var(--color-success-text);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover:not(:disabled) {
+    opacity: 0.9;
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}

+ 133 - 0
web/apps/lq_label/src/components/project-completion-dialog/project-completion-dialog.tsx

@@ -0,0 +1,133 @@
+/**
+ * ProjectCompletionDialog Component
+ * 
+ * Confirmation dialog for marking a project as completed.
+ * Only allows completion when project has 100% completion rate.
+ * 
+ * Requirements: 1.1, 1.2, 2.2, 2.3
+ */
+import React, { useState } from 'react';
+import { X, CheckCircle, AlertCircle } from 'lucide-react';
+import { markProjectCompleted } from '../../services/api';
+import type { Project } from '../../atoms/project-atoms';
+import styles from './project-completion-dialog.module.scss';
+
+export interface ProjectCompletionDialogProps {
+  project: Project;
+  isOpen: boolean;
+  onClose: () => void;
+  onCompleted: (project: Project) => void;
+}
+
+export const ProjectCompletionDialog: React.FC<ProjectCompletionDialogProps> = ({
+  project,
+  isOpen,
+  onClose,
+  onCompleted,
+}) => {
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  // Calculate completion rate
+  const completionRate = project.task_count > 0
+    ? Math.round((project.completed_task_count / project.task_count) * 100)
+    : 0;
+
+  const isFullyCompleted = completionRate === 100;
+
+  const handleConfirm = async () => {
+    if (!isFullyCompleted) return;
+
+    try {
+      setIsSubmitting(true);
+      setError(null);
+      
+      const updatedProject = await markProjectCompleted(project.id);
+      onCompleted(updatedProject);
+      onClose();
+    } catch (err: any) {
+      setError(err.message || '标记项目为已完成失败');
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  if (!isOpen) return null;
+
+  return (
+    <div className={styles.overlay} onClick={onClose}>
+      <div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
+        {/* Header */}
+        <div className={styles.header}>
+          <div className={styles.headerIcon}>
+            <CheckCircle size={24} />
+          </div>
+          <h2 className={styles.title}>标记项目为已完成</h2>
+          <button className={styles.closeButton} onClick={onClose}>
+            <X size={20} />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className={styles.content}>
+          <div className={styles.projectInfo}>
+            <span className={styles.label}>项目名称</span>
+            <span className={styles.value}>{project.name}</span>
+          </div>
+
+          <div className={styles.progressInfo}>
+            <span className={styles.label}>完成进度</span>
+            <div className={styles.progressBar}>
+              <div
+                className={styles.progressFill}
+                style={{ width: `${completionRate}%` }}
+              />
+            </div>
+            <span className={styles.progressText}>
+              {project.completed_task_count}/{project.task_count} ({completionRate}%)
+            </span>
+          </div>
+
+          {!isFullyCompleted && (
+            <div className={styles.warningMessage}>
+              <AlertCircle size={16} />
+              <span>项目尚未完成,只有 100% 完成的项目才能标记为已完成</span>
+            </div>
+          )}
+
+          {isFullyCompleted && (
+            <div className={styles.confirmMessage}>
+              <p>确定要将此项目标记为已完成吗?</p>
+              <p className={styles.hint}>标记后项目状态将变为"已完成"</p>
+            </div>
+          )}
+
+          {error && (
+            <div className={styles.errorMessage}>
+              <AlertCircle size={16} />
+              <span>{error}</span>
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className={styles.footer}>
+          <button
+            className={styles.cancelButton}
+            onClick={onClose}
+            disabled={isSubmitting}
+          >
+            取消
+          </button>
+          <button
+            className={styles.confirmButton}
+            onClick={handleConfirm}
+            disabled={!isFullyCompleted || isSubmitting}
+          >
+            {isSubmitting ? '处理中...' : '确认完成'}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 49 - 0
web/apps/lq_label/src/components/project-detail-modal/project-detail-modal.module.scss

@@ -448,3 +448,52 @@
     opacity: 0.9;
   }
 }
+
+// Action buttons section
+.actionButtonsSection {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-base);
+  margin-top: var(--spacing-base);
+  padding-top: var(--spacing-base);
+  border-top: 1px solid var(--theme-border);
+}
+
+.completeButton {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-tighter);
+  padding: var(--spacing-tight) var(--spacing-base);
+  border-radius: var(--corner-radius-small);
+  border: none;
+  background: var(--theme-success, #22c55e);
+  color: white;
+  font-size: var(--font-size-body-small);
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    opacity: 0.9;
+  }
+}
+
+.exportButton {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-tighter);
+  padding: var(--spacing-tight) var(--spacing-base);
+  border-radius: var(--corner-radius-small);
+  border: 1px solid var(--theme-border);
+  background: var(--theme-background);
+  color: var(--theme-headline);
+  font-size: var(--font-size-body-small);
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    background: var(--theme-background-secondary);
+    border-color: var(--theme-button);
+  }
+}

+ 55 - 1
web/apps/lq_label/src/components/project-detail-modal/project-detail-modal.tsx

@@ -6,12 +6,14 @@
  */
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { X, Edit, Plus, Play, Eye, Trash2 } from 'lucide-react';
+import { X, Edit, Plus, Play, Trash2, CheckCircle, Download } from 'lucide-react';
 import { getProject, getProjectTasks, createTask, deleteTask } from '../../services/api';
 import { type Project } from '../../atoms/project-atoms';
 import { type Task } from '../../atoms/task-atoms';
 import { TaskForm, type TaskFormData } from '../task-form';
 import { ProjectEditModal } from '../project-edit-modal';
+import { ProjectCompletionDialog } from '../project-completion-dialog';
+import { DataExportDialog } from '../data-export-dialog';
 import styles from './project-detail-modal.module.scss';
 
 export interface ProjectDetailModalProps {
@@ -37,6 +39,8 @@ export const ProjectDetailModal: React.FC<ProjectDetailModalProps> = ({
   const [isCreateTaskDialogOpen, setIsCreateTaskDialogOpen] = useState(false);
   const [isDeleteTaskDialogOpen, setIsDeleteTaskDialogOpen] = useState(false);
   const [isEditProjectModalOpen, setIsEditProjectModalOpen] = useState(false);
+  const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
+  const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
   const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
   const [isSubmitting, setIsSubmitting] = useState(false);
 
@@ -75,6 +79,17 @@ export const ProjectDetailModal: React.FC<ProjectDetailModalProps> = ({
     onProjectUpdated?.();
   };
 
+  // Can mark as completed when: has tasks, all completed, and not already completed
+  const canMarkAsCompleted = project && 
+    project.task_count > 0 && 
+    project.completed_task_count === project.task_count && 
+    project.status !== 'completed';
+
+  const handleProjectCompleted = (updatedProject: Project) => {
+    setProject(updatedProject);
+    onProjectUpdated?.();
+  };
+
   const handleCreateTask = async (formData: TaskFormData) => {
     try {
       setIsSubmitting(true);
@@ -210,6 +225,26 @@ export const ProjectDetailModal: React.FC<ProjectDetailModalProps> = ({
                     <span className={styles.infoLabel}>标注配置</span>
                     <pre className={styles.configCode}>{project.config}</pre>
                   </div>
+
+                  {/* Action Buttons */}
+                  <div className={styles.actionButtonsSection}>
+                    {canMarkAsCompleted && (
+                      <button
+                        className={styles.completeButton}
+                        onClick={() => setIsCompletionDialogOpen(true)}
+                      >
+                        <CheckCircle size={16} />
+                        <span>标记为已完成</span>
+                      </button>
+                    )}
+                    <button
+                      className={styles.exportButton}
+                      onClick={() => setIsExportDialogOpen(true)}
+                    >
+                      <Download size={16} />
+                      <span>导出数据</span>
+                    </button>
+                  </div>
                 </div>
 
                 {/* Tasks Section */}
@@ -356,6 +391,25 @@ export const ProjectDetailModal: React.FC<ProjectDetailModalProps> = ({
           onProjectUpdated={handleProjectUpdated}
         />
       )}
+
+      {/* Project Completion Dialog */}
+      {project && isCompletionDialogOpen && (
+        <ProjectCompletionDialog
+          project={project}
+          isOpen={isCompletionDialogOpen}
+          onClose={() => setIsCompletionDialogOpen(false)}
+          onCompleted={handleProjectCompleted}
+        />
+      )}
+
+      {/* Data Export Dialog */}
+      {project && isExportDialogOpen && (
+        <DataExportDialog
+          project={project}
+          isOpen={isExportDialogOpen}
+          onClose={() => setIsExportDialogOpen(false)}
+        />
+      )}
     </>
   );
 };

+ 12 - 1
web/apps/lq_label/src/services/api.ts

@@ -1071,7 +1071,7 @@ export async function getOverviewStatistics(): Promise<OverviewStatistics> {
 /**
  * Export format type
  */
-export type ExportFormat = 'json' | 'csv' | 'coco' | 'yolo';
+export type ExportFormat = 'json' | 'csv' | 'coco' | 'yolo' | 'pascal_voc' | 'sharegpt' | 'alpaca';
 
 /**
  * Export status type
@@ -1174,6 +1174,17 @@ export async function downloadExport(exportId: string): Promise<Blob> {
   return response.data;
 }
 
+/**
+ * Mark a project as completed (admin only)
+ * Only works when project has 100% completion rate
+ */
+export async function markProjectCompleted(projectId: string): Promise<Project> {
+  const response = await apiClient.patch<Project>(
+    `/api/projects/${projectId}/mark-completed`
+  );
+  return response.data;
+}
+
 /**
  * Export the configured axios instance for advanced usage
  */