mixvideo-v2/apps/desktop/src/components/MaterialImportDialog.tsx

665 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { X, Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
import { listen } from '@tauri-apps/api/event';
import { useMaterialStore } from '../store/materialStore';
import { CreateMaterialRequest, MaterialImportResult } from '../types/material';
import { Model } from '../types/model';
import { MaterialModelBindingService } from '../services/materialModelBindingService';
import { CustomSelect } from './CustomSelect';
interface MaterialImportDialogProps {
isOpen: boolean;
projectId: string;
onClose: () => void;
onImportComplete: (result: MaterialImportResult) => void;
}
/**
* 素材导入对话框组件
* 遵循 Tauri 开发规范的组件设计模式
*/
export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
isOpen,
projectId,
onClose,
onImportComplete
}) => {
const {
isImporting,
importProgress,
error,
importMaterialsAsync,
updateImportProgress,
selectMaterialFiles,
selectMaterialFolders,
scanFolderMaterials,
validateMaterialFiles,
checkFFmpegAvailable,
getSupportedExtensions,
clearError
} = useMaterialStore();
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [autoProcess, setAutoProcess] = useState(true);
const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟
const [ffmpegAvailable, setFFmpegAvailable] = useState(false);
const [step, setStep] = useState<'select' | 'batch' | 'configure' | 'importing' | 'complete'>('select');
const [importMode, setImportMode] = useState<'files' | 'folders'>('files');
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
const [recursiveScan, setRecursiveScan] = useState(true);
const [selectedFileTypes, setSelectedFileTypes] = useState<string[]>([]);
// 模特绑定相关状态
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [availableModels, setAvailableModels] = useState<Model[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
// 检查 FFmpeg 可用性
useEffect(() => {
if (isOpen) {
checkFFmpegAvailable().then(setFFmpegAvailable);
}
}, [isOpen, checkFFmpegAvailable]);
// 重置状态
useEffect(() => {
if (isOpen) {
setSelectedFiles([]);
setStep('select');
setSelectedModelId('');
clearError();
}
}, [isOpen, clearError]);
// 加载可用模特
useEffect(() => {
if (isOpen) {
loadAvailableModels();
}
}, [isOpen]);
const loadAvailableModels = async () => {
setLoadingModels(true);
try {
const models = await MaterialModelBindingService.getAllModels();
setAvailableModels(models.filter(model => model.is_active));
} catch (error) {
console.error('加载模特列表失败:', error);
} finally {
setLoadingModels(false);
}
};
// 设置事件监听器用于接收导入进度更新
useEffect(() => {
if (!isOpen) return;
let unlistenProgress: (() => void) | null = null;
let unlistenCompleted: (() => void) | null = null;
let unlistenFailed: (() => void) | null = null;
const setupEventListeners = async () => {
try {
// 监听导入进度事件
unlistenProgress = await listen('material_import_progress', (event: any) => {
console.log('收到进度事件:', event.payload);
const progressData = event.payload;
updateImportProgress({
current_file: progressData.current_file,
processed_count: progressData.processed_count,
total_count: progressData.total_count,
current_status: progressData.current_status,
progress_percentage: progressData.progress_percentage,
});
});
// 监听导入完成事件
unlistenCompleted = await listen('material_import_completed', (event: any) => {
console.log('收到完成事件:', event.payload);
const result = event.payload;
setStep('complete');
onImportComplete(result);
});
// 监听导入失败事件
unlistenFailed = await listen('material_import_failed', (event: any) => {
console.log('收到失败事件:', event.payload);
const errorMessage = event.payload;
console.error('导入失败:', errorMessage);
setStep('configure'); // 返回配置步骤
});
} catch (error) {
console.error('设置事件监听器失败:', error);
}
};
setupEventListeners();
// 清理函数
return () => {
if (unlistenProgress) unlistenProgress();
if (unlistenCompleted) unlistenCompleted();
if (unlistenFailed) unlistenFailed();
};
}, [isOpen, updateImportProgress, onImportComplete]);
// 选择文件
const handleSelectFiles = async () => {
try {
const filePaths = await selectMaterialFiles();
if (filePaths.length > 0) {
const validFiles = await validateMaterialFiles(filePaths);
setSelectedFiles(validFiles);
if (validFiles.length > 0) {
setImportMode('files');
setStep('configure');
}
}
} catch (error) {
console.error('选择文件失败:', error);
}
};
// 选择文件夹
const handleSelectFolders = async () => {
try {
const folderPaths = await selectMaterialFolders();
if (folderPaths.length > 0) {
setSelectedFolders(folderPaths);
setImportMode('folders');
setStep('batch');
}
} catch (error) {
console.error('选择文件夹失败:', error);
}
};
// 扫描文件夹并进入配置步骤
const handleScanFolders = async () => {
try {
const supportedTypes = await getSupportedExtensions();
const fileTypes = selectedFileTypes.length > 0 ? selectedFileTypes : supportedTypes;
const scannedFiles = await scanFolderMaterials(selectedFolders, recursiveScan, fileTypes);
if (scannedFiles.length > 0) {
const validFiles = await validateMaterialFiles(scannedFiles);
setSelectedFiles(validFiles);
if (validFiles.length > 0) {
setStep('configure');
}
} else {
console.warn('未找到任何支持的文件');
}
} catch (error) {
console.error('扫描文件夹失败:', error);
}
};
// 开始导入(使用异步版本)
const handleStartImport = async () => {
if (selectedFiles.length === 0) return;
setStep('importing');
try {
const request: CreateMaterialRequest = {
project_id: projectId,
file_paths: selectedFiles,
auto_process: autoProcess,
max_segment_duration: maxSegmentDuration,
model_id: selectedModelId || undefined,
};
console.log('开始异步导入:', request);
// 使用异步导入,进度更新和完成状态完全通过事件监听器处理
await importMaterialsAsync(request);
// 注意:不在这里设置完成状态,完全依赖事件监听器
console.log('异步导入命令执行完成,等待事件通知');
} catch (error) {
console.error('导入失败:', error);
setStep('configure'); // 返回配置步骤
}
};
// 格式化文件大小
// const formatFileSize = (bytes: number) => {
// const sizes = ['B', 'KB', 'MB', 'GB'];
// if (bytes === 0) return '0 B';
// const i = Math.floor(Math.log(bytes) / Math.log(1024));
// return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
// };
// 获取文件名
const getFileName = (path: string) => {
return path.split(/[/\\]/).pop() || path;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 内容 */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{/* FFmpeg 状态检查 */}
{!ffmpegAvailable && (
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center">
<AlertCircle className="w-5 h-5 text-yellow-600 mr-2" />
<span className="text-yellow-800">
FFmpeg FFmpeg
</span>
</div>
</div>
)}
{/* 错误信息 */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<AlertCircle className="w-5 h-5 text-red-600 mr-2" />
<span className="text-red-800">{error}</span>
</div>
</div>
)}
{/* 步骤 1: 选择导入方式 */}
{step === 'select' && (
<div className="space-y-6">
<div className="text-center">
<Upload className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-6">
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={handleSelectFiles}
className="p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
>
<FileText className="w-12 h-12 text-gray-400 group-hover:text-blue-500 mx-auto mb-3" />
<h4 className="text-lg font-medium text-gray-900 mb-2"></h4>
<p className="text-sm text-gray-600">
</p>
</button>
<button
onClick={handleSelectFolders}
className="p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
>
<Upload className="w-12 h-12 text-gray-400 group-hover:text-blue-500 mx-auto mb-3" />
<h4 className="text-lg font-medium text-gray-900 mb-2"></h4>
<p className="text-sm text-gray-600">
</p>
</button>
</div>
</div>
</div>
)}
{/* 步骤 2: 批量导入配置 */}
{step === 'batch' && (
<div className="space-y-6">
<div className="text-center">
<Upload className="w-16 h-16 text-blue-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-6">
{selectedFolders.length}
</p>
</div>
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 mb-2">:</h4>
<div className="space-y-2">
{selectedFolders.map((folder, index) => (
<div key={index} className="text-sm text-gray-600 bg-white p-2 rounded border">
{folder}
</div>
))}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center">
<input
type="checkbox"
id="recursive"
checked={recursiveScan}
onChange={(e) => setRecursiveScan(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="recursive" className="ml-2 text-sm text-gray-700">
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<input
type="text"
placeholder="例如: mp4,avi,mov (用逗号分隔)"
value={selectedFileTypes.join(',')}
onChange={(e) => setSelectedFileTypes(
e.target.value.split(',').map(t => t.trim()).filter(t => t)
)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
{/* 步骤 3: 配置导入选项 */}
{step === 'configure' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4"></h3>
<div className="max-h-40 overflow-y-auto border border-gray-200 rounded-lg">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center p-3 border-b border-gray-100 last:border-b-0">
<FileText className="w-5 h-5 text-gray-400 mr-3" />
<span className="text-sm text-gray-900 truncate">{getFileName(file)}</span>
</div>
))}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900"></h3>
<div className="flex items-center">
<input
type="checkbox"
id="autoProcess"
checked={autoProcess}
onChange={(e) => setAutoProcess(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="autoProcess" className="ml-2 text-sm text-gray-900">
</label>
</div>
{autoProcess && (
<div>
<label htmlFor="maxDuration" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="number"
id="maxDuration"
value={maxSegmentDuration}
onChange={(e) => setMaxSegmentDuration(Number(e.target.value))}
min="60"
max="3600"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
)}
{/* 模特绑定选择 */}
<div>
<label htmlFor="modelSelect" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
value={selectedModelId}
onChange={setSelectedModelId}
options={[
{ value: '', label: '不绑定模特' },
...availableModels.map(model => ({
value: model.id,
label: model.stage_name || model.name,
})),
]}
placeholder={loadingModels ? '加载中...' : '选择模特'}
disabled={loadingModels}
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
</div>
</div>
)}
{/* 步骤 3: 导入进度 */}
{step === 'importing' && (
<div className="space-y-6">
<div className="text-center">
{/* 主要加载动画 */}
<div className="relative">
<div className="w-20 h-20 mx-auto mb-6">
<div className="absolute inset-0 border-4 border-blue-200 rounded-full"></div>
<div className="absolute inset-2 flex items-center justify-center">
<Upload className="w-8 h-8 text-blue-600" />
</div>
</div>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
{importProgress ? (
<div className="space-y-6">
{/* 状态描述 */}
<div className="flex items-center justify-center space-x-2">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></div>
<div className="text-sm text-gray-600 font-medium">
{importProgress.current_status}
</div>
</div>
{/* 进度条 */}
<div className="space-y-3">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-600"></span>
<span className="font-semibold text-blue-600">
{Math.round((importProgress.processed_count / importProgress.total_count) * 100)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-blue-600 h-3 rounded-full transition-all duration-500 ease-out relative"
style={{
width: `${(importProgress.processed_count / importProgress.total_count) * 100}%`
}}
>
<div className="absolute inset-0 bg-white opacity-30 animate-pulse"></div>
</div>
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>{importProgress.processed_count} / {importProgress.total_count} </span>
<span> {importProgress.total_count - importProgress.processed_count} </span>
</div>
</div>
{/* 当前处理文件 */}
{importProgress.current_file && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-blue-900"></p>
<p className="text-sm text-blue-700 truncate" title={importProgress.current_file}>
{getFileName(importProgress.current_file)}
</p>
</div>
<div className="flex-shrink-0">
<Loader2 className="w-4 h-4 text-blue-600 animate-spin" />
</div>
</div>
</div>
)}
{/* 处理阶段指示器 */}
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex flex-col items-center space-y-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
importProgress.processed_count > 0 ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
}`}>
<CheckCircle className="w-4 h-4" />
</div>
<span className="text-gray-600"></span>
</div>
<div className="flex flex-col items-center space-y-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
importProgress.processed_count > 0 ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-400'
}`}>
<Loader2 className={`w-4 h-4 ${importProgress.processed_count > 0 ? 'animate-spin' : ''}`} />
</div>
<span className="text-gray-600"></span>
</div>
<div className="flex flex-col items-center space-y-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
importProgress.processed_count === importProgress.total_count ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
}`}>
<Upload className="w-4 h-4" />
</div>
<span className="text-gray-600"></span>
</div>
</div>
{/* 错误信息 */}
{importProgress.errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-red-800 mb-2">
({importProgress.errors.length})
</h4>
<div className="max-h-24 overflow-y-auto text-xs text-red-700 space-y-1">
{importProgress.errors.map((error, index) => (
<div key={index} className="bg-red-100 p-2 rounded border-l-2 border-red-400">
{error}
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
) : (
/* 初始化状态 */
<div className="space-y-4">
<div className="text-sm text-gray-600">...</div>
<div className="flex justify-center space-x-1">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
</div>
</div>
)}
</div>
</div>
)}
{/* 步骤 4: 完成 */}
{step === 'complete' && (
<div className="space-y-6">
<div className="text-center">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600">
</p>
</div>
</div>
)}
</div>
{/* 底部按钮 */}
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200">
{step === 'batch' && (
<>
<button
onClick={() => setStep('select')}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={handleScanFolders}
disabled={selectedFolders.length === 0 || isImporting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isImporting ? (
<div className="flex items-center space-x-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span>...</span>
</div>
) : (
'扫描文件夹'
)}
</button>
</>
)}
{step === 'configure' && (
<>
<button
onClick={() => setStep(importMode === 'folders' ? 'batch' : 'select')}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={handleStartImport}
disabled={selectedFiles.length === 0 || isImporting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isImporting ? (
<div className="flex items-center space-x-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span>...</span>
</div>
) : (
'开始导入'
)}
</button>
</>
)}
{(step === 'select' || step === 'complete') && (
<button
onClick={onClose}
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
{step === 'complete' ? '完成' : '取消'}
</button>
)}
</div>
</div>
</div>
);
};