|
@@ -1,201 +1,212 @@
|
|
|
/**
|
|
/**
|
|
|
* DataExportDialog Component
|
|
* DataExportDialog Component
|
|
|
- *
|
|
|
|
|
|
|
+ *
|
|
|
* Dialog for exporting project data in various formats.
|
|
* 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 ExportFormat,
|
|
|
- type StatusFilter,
|
|
|
|
|
-} from '../../atoms/export-atoms';
|
|
|
|
|
-import {
|
|
|
|
|
- createExport,
|
|
|
|
|
- getExportStatus,
|
|
|
|
|
- getExportDownloadUrl,
|
|
|
|
|
- type ExportRequest,
|
|
|
|
|
|
|
+ type ExportStatusFilter,
|
|
|
|
|
+ type ExportJob,
|
|
|
|
|
+ type ExportProgress,
|
|
|
} from '../../services/api';
|
|
} from '../../services/api';
|
|
|
|
|
+import type { Project } from '../../atoms/project-atoms';
|
|
|
import styles from './data-export-dialog.module.scss';
|
|
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;
|
|
isOpen: boolean;
|
|
|
- /** Callback when dialog closes */
|
|
|
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
|
- /** Callback when export completes */
|
|
|
|
|
onExportComplete?: () => void;
|
|
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> = ({
|
|
export const DataExportDialog: React.FC<DataExportDialogProps> = ({
|
|
|
- projectId,
|
|
|
|
|
- projectName,
|
|
|
|
|
|
|
+ project,
|
|
|
isOpen,
|
|
isOpen,
|
|
|
onClose,
|
|
onClose,
|
|
|
onExportComplete,
|
|
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
|
|
// Reset state when dialog opens
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (isOpen) {
|
|
if (isOpen) {
|
|
|
- setStep('config');
|
|
|
|
|
|
|
+ setExportState('idle');
|
|
|
setExportJob(null);
|
|
setExportJob(null);
|
|
|
- setProgress(0);
|
|
|
|
|
|
|
+ setExportProgress(null);
|
|
|
setError(null);
|
|
setError(null);
|
|
|
- setRequest({
|
|
|
|
|
- format: 'json',
|
|
|
|
|
- status_filter: 'all',
|
|
|
|
|
- include_metadata: true,
|
|
|
|
|
- });
|
|
|
|
|
}
|
|
}
|
|
|
- return () => {
|
|
|
|
|
- if (pollIntervalRef.current) {
|
|
|
|
|
- clearInterval(pollIntervalRef.current);
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
}, [isOpen]);
|
|
}, [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 {
|
|
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?.();
|
|
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);
|
|
}, 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;
|
|
if (!isOpen) return null;
|
|
|
|
|
|
|
@@ -204,149 +215,177 @@ export const DataExportDialog: React.FC<DataExportDialogProps> = ({
|
|
|
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
|
|
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
|
|
|
{/* Header */}
|
|
{/* Header */}
|
|
|
<div className={styles.header}>
|
|
<div className={styles.header}>
|
|
|
- <div className={styles.headerTitle}>
|
|
|
|
|
- <Download size={20} />
|
|
|
|
|
- <h2>导出数据</h2>
|
|
|
|
|
|
|
+ <div className={styles.headerIcon}>
|
|
|
|
|
+ <Download size={24} />
|
|
|
</div>
|
|
</div>
|
|
|
- <button className={styles.closeButton} onClick={handleClose}>
|
|
|
|
|
|
|
+ <h2 className={styles.title}>导出数据</h2>
|
|
|
|
|
+ <button
|
|
|
|
|
+ className={styles.closeButton}
|
|
|
|
|
+ onClick={handleClose}
|
|
|
|
|
+ disabled={exportState === 'exporting'}
|
|
|
|
|
+ >
|
|
|
<X size={20} />
|
|
<X size={20} />
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Content */}
|
|
{/* Content */}
|
|
|
<div className={styles.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 */}
|
|
{/* Format Selection */}
|
|
|
<div className={styles.section}>
|
|
<div className={styles.section}>
|
|
|
- <h3 className={styles.sectionTitle}>导出格式</h3>
|
|
|
|
|
|
|
+ <label className={styles.sectionLabel}>导出格式</label>
|
|
|
<div className={styles.formatGrid}>
|
|
<div className={styles.formatGrid}>
|
|
|
- {exportFormatOptions.map((option) => (
|
|
|
|
|
- <div
|
|
|
|
|
|
|
+ {FORMAT_OPTIONS.map((option) => (
|
|
|
|
|
+ <button
|
|
|
key={option.value}
|
|
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>
|
|
</div>
|
|
|
|
|
+ {selectedFormatOption && (
|
|
|
|
|
+ <p className={styles.formatDescription}>
|
|
|
|
|
+ {selectedFormatOption.description}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Status Filter */}
|
|
{/* Status Filter */}
|
|
|
<div className={styles.section}>
|
|
<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}
|
|
{option.label}
|
|
|
- </button>
|
|
|
|
|
|
|
+ </option>
|
|
|
))}
|
|
))}
|
|
|
- </div>
|
|
|
|
|
|
|
+ </select>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Options */}
|
|
|
|
|
|
|
+ {/* Include Metadata */}
|
|
|
<div className={styles.section}>
|
|
<div className={styles.section}>
|
|
|
- <h3 className={styles.sectionTitle}>导出选项</h3>
|
|
|
|
|
- <label className={styles.checkbox}>
|
|
|
|
|
|
|
+ <label className={styles.checkboxLabel}>
|
|
|
<input
|
|
<input
|
|
|
type="checkbox"
|
|
type="checkbox"
|
|
|
- checked={request.include_metadata}
|
|
|
|
|
- onChange={handleMetadataToggle}
|
|
|
|
|
|
|
+ checked={includeMetadata}
|
|
|
|
|
+ onChange={(e) => setIncludeMetadata(e.target.checked)}
|
|
|
/>
|
|
/>
|
|
|
<span>包含元数据</span>
|
|
<span>包含元数据</span>
|
|
|
</label>
|
|
</label>
|
|
|
</div>
|
|
</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>
|
|
</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>
|
|
</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>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
{/* Footer */}
|
|
|
<div className={styles.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} />
|
|
<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>
|
|
|
</div>
|
|
</div>
|