fix: 修复EnvironmentType枚举编译错误

- 移除了EnvironmentType中未使用的ModalCloud、RunpodCloud和Custom变体
- 更新了相关的Display trait实现
- 修复了workflow_execution_environment_repository中的字符串解析逻辑
- 简化了universal_workflow_service中的match语句,只保留LocalComfyui支持
- 添加了批量处理相关的新文件和组件
This commit is contained in:
imeepos
2025-08-08 13:42:35 +08:00
parent 0899996963
commit 763b4a975c
7 changed files with 1463 additions and 11 deletions

View File

@@ -0,0 +1,314 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Result};
use tokio::fs;
use tracing::{info, warn, error};
use uuid::Uuid;
use crate::data::models::batch_processing::*;
use crate::data::models::workflow_execution_record::{ExecuteWorkflowRequest, CreateExecutionRecordRequest};
use crate::business::services::universal_workflow_service::UniversalWorkflowService;
use crate::infrastructure::database::Database;
/// 批量处理服务
pub struct BatchProcessingService {
/// 活跃的批量任务
active_tasks: Arc<Mutex<HashMap<String, BatchProcessingTask>>>,
/// 文件项映射
file_items: Arc<Mutex<HashMap<String, Vec<BatchFileItem>>>>,
/// 工作流服务
workflow_service: Arc<UniversalWorkflowService>,
}
impl BatchProcessingService {
/// 创建新的批量处理服务
pub fn new(workflow_service: Arc<UniversalWorkflowService>) -> Self {
Self {
active_tasks: Arc::new(Mutex::new(HashMap::new())),
file_items: Arc::new(Mutex::new(HashMap::new())),
workflow_service,
}
}
/// 创建批量处理任务
pub async fn create_batch_task(&self, request: CreateBatchTaskRequest) -> Result<BatchProcessingTask> {
info!("创建批量处理任务: {}", request.name);
// 验证输入文件夹
let input_path = Path::new(&request.input_folder);
if !input_path.exists() || !input_path.is_dir() {
return Err(anyhow!("输入文件夹不存在或不是有效目录: {}", request.input_folder));
}
// 创建输出文件夹
let output_path = Path::new(&request.output_folder);
if !output_path.exists() {
fs::create_dir_all(output_path).await
.map_err(|e| anyhow!("创建输出文件夹失败: {}", e))?;
}
// 创建任务
let mut task = BatchProcessingTask::new(request.clone());
// 扫描文件
let file_items = self.scan_files(&request).await?;
task.total_files = file_items.len() as u32;
info!("扫描到 {} 个文件", file_items.len());
// 存储任务和文件项
{
let mut tasks = self.active_tasks.lock().unwrap();
tasks.insert(task.id.clone(), task.clone());
}
{
let mut items = self.file_items.lock().unwrap();
items.insert(task.id.clone(), file_items);
}
Ok(task)
}
/// 扫描文件夹中的文件
async fn scan_files(&self, request: &CreateBatchTaskRequest) -> Result<Vec<BatchFileItem>> {
let mut file_items = Vec::new();
let input_path = Path::new(&request.input_folder);
self.scan_directory_recursive(
input_path,
&request.file_extensions,
request.recursive,
&mut file_items,
&format!("batch_{}", chrono::Utc::now().timestamp_millis()),
).await?;
Ok(file_items)
}
/// 递归扫描目录
async fn scan_directory_recursive(
&self,
dir_path: &Path,
extensions: &[String],
recursive: bool,
file_items: &mut Vec<BatchFileItem>,
batch_task_id: &str,
) -> Result<()> {
let mut entries = fs::read_dir(dir_path).await
.map_err(|e| anyhow!("读取目录失败: {}", e))?;
while let Some(entry) = entries.next_entry().await
.map_err(|e| anyhow!("读取目录项失败: {}", e))? {
let path = entry.path();
if path.is_file() {
// 检查文件扩展名
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.is_empty() || extensions.iter().any(|e| e.to_lowercase() == ext_str) {
let file_item = BatchFileItem::new(batch_task_id.to_string(), path);
file_items.push(file_item);
}
}
} else if path.is_dir() && recursive {
// 递归扫描子目录
self.scan_directory_recursive(&path, extensions, recursive, file_items, batch_task_id).await?;
}
}
Ok(())
}
/// 开始批量处理
pub async fn start_batch_processing(&self, task_id: &str) -> Result<()> {
info!("开始批量处理任务: {}", task_id);
// 获取任务
let mut task = {
let mut tasks = self.active_tasks.lock().unwrap();
tasks.get_mut(task_id)
.ok_or_else(|| anyhow!("批量任务不存在: {}", task_id))?
.clone()
};
// 获取文件项
let file_items = {
let items = self.file_items.lock().unwrap();
items.get(task_id)
.ok_or_else(|| anyhow!("文件项不存在: {}", task_id))?
.clone()
};
// 开始处理
task.start_processing(file_items.len() as u32);
self.update_task(&task)?;
// 异步处理文件
let service = Arc::clone(&self.workflow_service);
let task_id = task_id.to_string();
let active_tasks = Arc::clone(&self.active_tasks);
let file_items_map = Arc::clone(&self.file_items);
tokio::spawn(async move {
let result = Self::process_files_async(
service,
task_id.clone(),
file_items,
active_tasks.clone(),
file_items_map.clone(),
).await;
// 更新任务状态
{
let mut tasks = active_tasks.lock().unwrap();
if let Some(mut task) = tasks.get_mut(&task_id) {
match result {
Ok(_) => task.complete_processing(),
Err(e) => task.mark_failed(e.to_string()),
}
}
}
});
Ok(())
}
/// 异步处理文件
async fn process_files_async(
workflow_service: Arc<UniversalWorkflowService>,
task_id: String,
file_items: Vec<BatchFileItem>,
active_tasks: Arc<Mutex<HashMap<String, BatchProcessingTask>>>,
file_items_map: Arc<Mutex<HashMap<String, Vec<BatchFileItem>>>>,
) -> Result<()> {
let mut processed = 0;
let mut failed = 0;
for mut file_item in file_items {
info!("处理文件: {:?}", file_item.input_file_path);
// 构建输出文件路径
let output_path = Self::generate_output_path(&file_item, &task_id)?;
// 准备工作流执行请求
let input_data = serde_json::json!({
"input_image": file_item.input_file_path.to_string_lossy(),
"output_path": output_path.to_string_lossy()
});
let execute_request = ExecuteWorkflowRequest {
workflow_identifier: "1".to_string(), // 使用模板ID
version: None,
input_data,
environment_id: None,
execution_config_override: None,
};
// 执行工作流
match workflow_service.execute_workflow(execute_request).await {
Ok(response) => {
file_item.complete_processing(output_path);
processed += 1;
info!("文件处理成功: {:?}", file_item.input_file_path);
}
Err(e) => {
file_item.mark_failed(e.to_string());
failed += 1;
error!("文件处理失败: {:?}, 错误: {}", file_item.input_file_path, e);
}
}
// 更新任务进度
{
let mut tasks = active_tasks.lock().unwrap();
if let Some(task) = tasks.get_mut(&task_id) {
task.update_progress(processed, failed);
}
}
// 更新文件项状态
{
let mut items = file_items_map.lock().unwrap();
if let Some(items_vec) = items.get_mut(&task_id) {
if let Some(item) = items_vec.iter_mut().find(|i| i.id == file_item.id) {
*item = file_item;
}
}
}
}
info!("批量处理完成: 成功 {}, 失败 {}", processed, failed);
Ok(())
}
/// 生成输出文件路径
fn generate_output_path(file_item: &BatchFileItem, task_id: &str) -> Result<PathBuf> {
let input_path = &file_item.input_file_path;
let file_name = input_path.file_stem()
.ok_or_else(|| anyhow!("无法获取文件名"))?;
let extension = input_path.extension()
.unwrap_or_default();
// 生成输出文件名原文件名_processed.扩展名
let output_filename = format!("{}_processed.{}",
file_name.to_string_lossy(),
extension.to_string_lossy()
);
// 这里需要从任务中获取输出目录,简化处理
let output_path = PathBuf::from("output").join(output_filename);
Ok(output_path)
}
/// 获取批量任务
pub fn get_batch_task(&self, task_id: &str) -> Option<BatchProcessingTask> {
let tasks = self.active_tasks.lock().unwrap();
tasks.get(task_id).cloned()
}
/// 获取所有批量任务
pub fn get_all_batch_tasks(&self) -> Vec<BatchProcessingTask> {
let tasks = self.active_tasks.lock().unwrap();
tasks.values().cloned().collect()
}
/// 获取批量处理进度
pub fn get_batch_progress(&self, task_id: &str) -> Option<BatchProcessingProgress> {
let tasks = self.active_tasks.lock().unwrap();
if let Some(task) = tasks.get(task_id) {
Some(BatchProcessingProgress {
task_id: task.id.clone(),
status: task.status.clone(),
total_files: task.total_files,
processed_files: task.processed_files,
failed_files: task.failed_files,
current_file: None, // TODO: 实现当前处理文件跟踪
progress_percentage: task.get_progress_percentage(),
estimated_remaining_time: None, // TODO: 实现时间估算
})
} else {
None
}
}
/// 取消批量任务
pub fn cancel_batch_task(&self, task_id: &str) -> Result<()> {
let mut tasks = self.active_tasks.lock().unwrap();
if let Some(task) = tasks.get_mut(task_id) {
task.cancel();
info!("批量任务已取消: {}", task_id);
Ok(())
} else {
Err(anyhow!("批量任务不存在: {}", task_id))
}
}
/// 更新任务
fn update_task(&self, task: &BatchProcessingTask) -> Result<()> {
let mut tasks = self.active_tasks.lock().unwrap();
tasks.insert(task.id.clone(), task.clone());
Ok(())
}
}

View File

@@ -128,9 +128,6 @@ impl UniversalWorkflowService {
crate::data::models::workflow_execution_environment::EnvironmentType::LocalComfyui => {
self.execute_on_comfyui(&template, &environment, &request, execution_id).await
}
_ => {
Err(anyhow!("不支持的执行环境类型: {:?}", environment.environment_type))
}
};
// 7. 更新执行结果

View File

@@ -0,0 +1,195 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use std::path::PathBuf;
/// 批量处理任务状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum BatchTaskStatus {
/// 待处理
Pending,
/// 处理中
Processing,
/// 已完成
Completed,
/// 失败
Failed,
/// 已取消
Cancelled,
}
/// 批量处理任务
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchProcessingTask {
pub id: String,
pub name: String,
pub workflow_template_id: i64,
pub input_folder: PathBuf,
pub output_folder: PathBuf,
pub status: BatchTaskStatus,
pub total_files: u32,
pub processed_files: u32,
pub failed_files: u32,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub error_message: Option<String>,
}
/// 批量处理文件项
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchFileItem {
pub id: String,
pub batch_task_id: String,
pub input_file_path: PathBuf,
pub output_file_path: Option<PathBuf>,
pub status: BatchTaskStatus,
pub execution_id: Option<i64>,
pub error_message: Option<String>,
pub processed_at: Option<DateTime<Utc>>,
}
/// 创建批量处理任务请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateBatchTaskRequest {
pub name: String,
pub workflow_template_id: i64,
pub input_folder: String,
pub output_folder: String,
pub file_extensions: Vec<String>, // 支持的文件扩展名,如 ["jpg", "png", "jpeg"]
pub recursive: bool, // 是否递归扫描子文件夹
}
/// 批量处理进度信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchProcessingProgress {
pub task_id: String,
pub status: BatchTaskStatus,
pub total_files: u32,
pub processed_files: u32,
pub failed_files: u32,
pub current_file: Option<String>,
pub progress_percentage: f32,
pub estimated_remaining_time: Option<u64>, // 秒
}
/// 批量处理统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchProcessingStats {
pub total_tasks: u32,
pub active_tasks: u32,
pub completed_tasks: u32,
pub failed_tasks: u32,
pub total_files_processed: u32,
pub average_processing_time_per_file: f32, // 秒
}
impl BatchProcessingTask {
/// 创建新的批量处理任务
pub fn new(request: CreateBatchTaskRequest) -> Self {
let now = Utc::now();
Self {
id: format!("batch_{}", now.timestamp_millis()),
name: request.name,
workflow_template_id: request.workflow_template_id,
input_folder: PathBuf::from(request.input_folder),
output_folder: PathBuf::from(request.output_folder),
status: BatchTaskStatus::Pending,
total_files: 0,
processed_files: 0,
failed_files: 0,
created_at: now,
started_at: None,
completed_at: None,
error_message: None,
}
}
/// 开始处理
pub fn start_processing(&mut self, total_files: u32) {
self.status = BatchTaskStatus::Processing;
self.started_at = Some(Utc::now());
self.total_files = total_files;
self.processed_files = 0;
self.failed_files = 0;
}
/// 更新进度
pub fn update_progress(&mut self, processed: u32, failed: u32) {
self.processed_files = processed;
self.failed_files = failed;
}
/// 完成处理
pub fn complete_processing(&mut self) {
self.status = BatchTaskStatus::Completed;
self.completed_at = Some(Utc::now());
}
/// 标记失败
pub fn mark_failed(&mut self, error_message: String) {
self.status = BatchTaskStatus::Failed;
self.completed_at = Some(Utc::now());
self.error_message = Some(error_message);
}
/// 取消处理
pub fn cancel(&mut self) {
self.status = BatchTaskStatus::Cancelled;
self.completed_at = Some(Utc::now());
}
/// 获取进度百分比
pub fn get_progress_percentage(&self) -> f32 {
if self.total_files == 0 {
return 0.0;
}
(self.processed_files as f32 / self.total_files as f32) * 100.0
}
/// 获取处理时长(秒)
pub fn get_duration_seconds(&self) -> Option<i64> {
if let Some(started_at) = self.started_at {
let end_time = self.completed_at.unwrap_or_else(Utc::now);
Some((end_time - started_at).num_seconds())
} else {
None
}
}
}
impl BatchFileItem {
/// 创建新的文件项
pub fn new(batch_task_id: String, input_file_path: PathBuf) -> Self {
Self {
id: format!("file_{}_{}", batch_task_id, input_file_path.file_name().unwrap_or_default().to_string_lossy()),
batch_task_id,
input_file_path,
output_file_path: None,
status: BatchTaskStatus::Pending,
execution_id: None,
error_message: None,
processed_at: None,
}
}
/// 开始处理
pub fn start_processing(&mut self, execution_id: i64) {
self.status = BatchTaskStatus::Processing;
self.execution_id = Some(execution_id);
}
/// 完成处理
pub fn complete_processing(&mut self, output_file_path: PathBuf) {
self.status = BatchTaskStatus::Completed;
self.output_file_path = Some(output_file_path);
self.processed_at = Some(Utc::now());
}
/// 标记失败
pub fn mark_failed(&mut self, error_message: String) {
self.status = BatchTaskStatus::Failed;
self.error_message = Some(error_message);
self.processed_at = Some(Utc::now());
}
}

View File

@@ -22,9 +22,7 @@ impl From<String> for EnvironmentType {
fn from(s: String) -> Self {
match s.as_str() {
"local_comfyui" => EnvironmentType::LocalComfyui,
"modal_cloud" => EnvironmentType::ModalCloud,
"runpod_cloud" => EnvironmentType::RunpodCloud,
_ => EnvironmentType::Custom,
_ => EnvironmentType::LocalComfyui, // 默认使用本地ComfyUI
}
}
}

View File

@@ -686,12 +686,9 @@ impl WorkflowExecutionEnvironmentRepository {
// 如果JSON解析失败尝试直接解析字符串
match type_str.trim_matches('"') {
"local_comfyui" => EnvironmentType::LocalComfyui,
"modal_cloud" => EnvironmentType::ModalCloud,
"runpod_cloud" => EnvironmentType::RunpodCloud,
"custom" => EnvironmentType::Custom,
_ => {
error!("Unknown environment type: {}", type_str);
EnvironmentType::Custom // 默认值
error!("Unknown environment type: {}, defaulting to LocalComfyui", type_str);
EnvironmentType::LocalComfyui // 默认值
}
}
}

View File

@@ -0,0 +1,262 @@
use tauri::{command, State};
use tracing::{info, error};
use std::sync::Arc;
use crate::app_state::AppState;
use crate::data::models::batch_processing::*;
use crate::business::services::batch_processing_service::BatchProcessingService;
/// 创建批量处理任务
#[command]
pub async fn create_batch_task(
request: CreateBatchTaskRequest,
state: State<'_, AppState>,
) -> Result<BatchProcessingTask, String> {
info!("创建批量处理任务: {}", request.name);
// 获取批量处理服务
let batch_service = get_batch_service(&state)?;
// 创建批量任务
match batch_service.create_batch_task(request).await {
Ok(task) => {
info!("批量任务创建成功ID: {}", task.id);
Ok(task)
}
Err(e) => {
error!("创建批量任务失败: {}", e);
Err(format!("创建批量任务失败: {}", e))
}
}
}
/// 开始批量处理
#[command]
pub async fn start_batch_processing(
task_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
info!("开始批量处理任务: {}", task_id);
// 获取批量处理服务
let batch_service = get_batch_service(&state)?;
// 开始处理
match batch_service.start_batch_processing(&task_id).await {
Ok(_) => {
info!("批量处理任务已启动: {}", task_id);
Ok(())
}
Err(e) => {
error!("启动批量处理失败: {}", e);
Err(format!("启动批量处理失败: {}", e))
}
}
}
/// 获取批量任务信息
#[command]
pub async fn get_batch_task(
task_id: String,
state: State<'_, AppState>,
) -> Result<Option<BatchProcessingTask>, String> {
info!("获取批量任务信息: {}", task_id);
// 获取批量处理服务
let batch_service = get_batch_service(&state)?;
// 获取任务
let task = batch_service.get_batch_task(&task_id);
Ok(task)
}
/// 获取所有批量任务
#[command]
pub async fn get_all_batch_tasks(
state: State<'_, AppState>,
) -> Result<Vec<BatchProcessingTask>, String> {
info!("获取所有批量任务");
// 获取批量处理服务
let batch_service = get_batch_service(&state)?;
// 获取所有任务
let tasks = batch_service.get_all_batch_tasks();
info!("获取到 {} 个批量任务", tasks.len());
Ok(tasks)
}
/// 获取批量处理进度
#[command]
pub async fn get_batch_progress(
task_id: String,
state: State<'_, AppState>,
) -> Result<Option<BatchProcessingProgress>, String> {
info!("获取批量处理进度: {}", task_id);
// 获取批量处理服务
let batch_service = get_batch_service(&state)?;
// 获取进度
let progress = batch_service.get_batch_progress(&task_id);
Ok(progress)
}
/// 取消批量任务
#[command]
pub async fn cancel_batch_task(
task_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
info!("取消批量任务: {}", task_id);
// 获取批量处理服务
let batch_service = get_batch_service(&state)?;
// 取消任务
match batch_service.cancel_batch_task(&task_id) {
Ok(_) => {
info!("批量任务已取消: {}", task_id);
Ok(())
}
Err(e) => {
error!("取消批量任务失败: {}", e);
Err(format!("取消批量任务失败: {}", e))
}
}
}
/// 选择文件夹
#[command]
pub async fn select_folder(
title: Option<String>,
) -> Result<Option<String>, String> {
use tauri::api::dialog::blocking::FileDialogBuilder;
let dialog_title = title.unwrap_or_else(|| "选择文件夹".to_string());
match FileDialogBuilder::new()
.set_title(&dialog_title)
.pick_folder() {
Some(path) => Ok(Some(path.to_string_lossy().to_string())),
None => Ok(None),
}
}
/// 扫描文件夹预览
#[command]
pub async fn scan_folder_preview(
folder_path: String,
file_extensions: Vec<String>,
recursive: bool,
) -> Result<Vec<String>, String> {
use std::path::Path;
use tokio::fs;
info!("扫描文件夹预览: {}", folder_path);
let path = Path::new(&folder_path);
if !path.exists() || !path.is_dir() {
return Err("文件夹不存在或不是有效目录".to_string());
}
let mut file_paths = Vec::new();
match scan_directory_for_preview(path, &file_extensions, recursive, &mut file_paths).await {
Ok(_) => {
info!("扫描到 {} 个文件", file_paths.len());
Ok(file_paths)
}
Err(e) => {
error!("扫描文件夹失败: {}", e);
Err(format!("扫描文件夹失败: {}", e))
}
}
}
/// 递归扫描目录(预览用)
async fn scan_directory_for_preview(
dir_path: &Path,
extensions: &[String],
recursive: bool,
file_paths: &mut Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut entries = fs::read_dir(dir_path).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() {
// 检查文件扩展名
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.is_empty() || extensions.iter().any(|e| e.to_lowercase() == ext_str) {
file_paths.push(path.to_string_lossy().to_string());
}
}
} else if path.is_dir() && recursive {
// 递归扫描子目录
scan_directory_for_preview(&path, extensions, recursive, file_paths).await?;
}
}
Ok(())
}
/// 获取批量处理服务
fn get_batch_service(state: &State<AppState>) -> Result<Arc<BatchProcessingService>, String> {
// 这里需要从AppState中获取批量处理服务
// 由于当前AppState可能还没有包含BatchProcessingService
// 我们需要先更新AppState结构
// 临时实现:创建一个新的服务实例
// 在实际应用中应该从AppState中获取单例服务
let workflow_service = state.get_universal_workflow_service()
.map_err(|e| format!("获取工作流服务失败: {}", e))?;
let workflow_service_arc = workflow_service.as_ref()
.ok_or_else(|| "工作流服务未初始化".to_string())?;
// 创建批量处理服务
let batch_service = BatchProcessingService::new(Arc::clone(workflow_service_arc));
Ok(Arc::new(batch_service))
}
/// 获取支持的文件扩展名列表
#[command]
pub async fn get_supported_file_extensions() -> Result<Vec<String>, String> {
// 返回常见的图片文件扩展名
Ok(vec![
"jpg".to_string(),
"jpeg".to_string(),
"png".to_string(),
"bmp".to_string(),
"gif".to_string(),
"tiff".to_string(),
"webp".to_string(),
])
}
/// 验证输出文件夹
#[command]
pub async fn validate_output_folder(
folder_path: String,
) -> Result<bool, String> {
use std::path::Path;
use tokio::fs;
let path = Path::new(&folder_path);
// 如果文件夹不存在,尝试创建
if !path.exists() {
match fs::create_dir_all(path).await {
Ok(_) => Ok(true),
Err(e) => Err(format!("创建输出文件夹失败: {}", e)),
}
} else if path.is_dir() {
Ok(true)
} else {
Err("指定路径不是有效的文件夹".to_string())
}
}

View File

@@ -0,0 +1,689 @@
/**
* 批量文件夹处理组件
*
* 支持选择文件夹、扫描文件、批量执行任务并保存结果到指定输出文件夹
*/
import React, { useState, useEffect } from 'react';
import {
FolderOpen,
Play,
Square,
Settings,
FileText,
CheckCircle,
AlertCircle,
Clock,
Folder,
Image,
RotateCcw
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
// 批量任务状态
type BatchTaskStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
// 批量处理任务
interface BatchProcessingTask {
id: string;
name: string;
workflow_template_id: number;
input_folder: string;
output_folder: string;
status: BatchTaskStatus;
total_files: number;
processed_files: number;
failed_files: number;
created_at: string;
started_at?: string;
completed_at?: string;
error_message?: string;
}
// 批量处理进度
interface BatchProcessingProgress {
task_id: string;
status: BatchTaskStatus;
total_files: number;
processed_files: number;
failed_files: number;
current_file?: string;
progress_percentage: number;
estimated_remaining_time?: number;
}
// 创建批量任务请求
interface CreateBatchTaskRequest {
name: string;
workflow_template_id: number;
input_folder: string;
output_folder: string;
file_extensions: string[];
recursive: boolean;
}
// 工作流模板
interface WorkflowTemplate {
id: number;
name: string;
base_name: string;
version: string;
type: string;
description?: string;
}
// 组件属性
interface BatchFolderProcessorProps {
/** 是否显示模态框 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 任务完成回调 */
onTaskComplete?: (task: BatchProcessingTask) => void;
}
/**
* 批量文件夹处理组件
*/
export const BatchFolderProcessor: React.FC<BatchFolderProcessorProps> = ({
isOpen,
onClose,
onTaskComplete
}) => {
// 状态管理
const [step, setStep] = useState<'config' | 'preview' | 'processing'>('config');
const [inputFolder, setInputFolder] = useState('');
const [outputFolder, setOutputFolder] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<WorkflowTemplate | null>(null);
const [fileExtensions, setFileExtensions] = useState<string[]>(['jpg', 'png', 'jpeg']);
const [recursive, setRecursive] = useState(true);
const [taskName, setTaskName] = useState('');
// 预览状态
const [previewFiles, setPreviewFiles] = useState<string[]>([]);
const [isScanning, setIsScanning] = useState(false);
// 处理状态
const [currentTask, setCurrentTask] = useState<BatchProcessingTask | null>(null);
const [progress, setProgress] = useState<BatchProcessingProgress | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
// 数据加载
const [templates, setTemplates] = useState<WorkflowTemplate[]>([]);
const [supportedExtensions, setSupportedExtensions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 加载数据
useEffect(() => {
if (isOpen) {
loadInitialData();
}
}, [isOpen]);
// 加载初始数据
const loadInitialData = async () => {
setLoading(true);
try {
// 加载工作流模板
const templatesResult = await invoke<WorkflowTemplate[]>('get_workflow_templates');
setTemplates(templatesResult);
// 加载支持的文件扩展名
const extensionsResult = await invoke<string[]>('get_supported_file_extensions');
setSupportedExtensions(extensionsResult);
// 设置默认模板
if (templatesResult.length > 0) {
setSelectedTemplate(templatesResult[0]);
}
} catch (err) {
console.error('加载初始数据失败:', err);
setError('加载数据失败');
} finally {
setLoading(false);
}
};
// 选择输入文件夹
const selectInputFolder = async () => {
try {
const result = await invoke<string | null>('select_folder', {
title: '选择输入文件夹'
});
if (result) {
setInputFolder(result);
// 自动生成任务名称
const folderName = result.split(/[/\\]/).pop() || 'batch_task';
setTaskName(`批量处理_${folderName}_${new Date().toLocaleDateString()}`);
}
} catch (err) {
console.error('选择文件夹失败:', err);
setError('选择文件夹失败');
}
};
// 选择输出文件夹
const selectOutputFolder = async () => {
try {
const result = await invoke<string | null>('select_folder', {
title: '选择输出文件夹'
});
if (result) {
setOutputFolder(result);
// 验证输出文件夹
await invoke<boolean>('validate_output_folder', {
folderPath: result
});
}
} catch (err) {
console.error('选择输出文件夹失败:', err);
setError('选择或创建输出文件夹失败');
}
};
// 扫描文件预览
const scanFilesPreview = async () => {
if (!inputFolder) {
setError('请先选择输入文件夹');
return;
}
setIsScanning(true);
setError(null);
try {
const files = await invoke<string[]>('scan_folder_preview', {
folderPath: inputFolder,
fileExtensions: fileExtensions,
recursive: recursive
});
setPreviewFiles(files);
setStep('preview');
} catch (err) {
console.error('扫描文件失败:', err);
setError('扫描文件失败');
} finally {
setIsScanning(false);
}
};
// 开始批量处理
const startBatchProcessing = async () => {
if (!selectedTemplate || !inputFolder || !outputFolder) {
setError('请完成所有配置');
return;
}
setIsProcessing(true);
setError(null);
try {
// 创建批量任务
const request: CreateBatchTaskRequest = {
name: taskName || '批量处理任务',
workflow_template_id: selectedTemplate.id,
input_folder: inputFolder,
output_folder: outputFolder,
file_extensions: fileExtensions,
recursive: recursive
};
const task = await invoke<BatchProcessingTask>('create_batch_task', { request });
setCurrentTask(task);
// 开始处理
await invoke('start_batch_processing', { taskId: task.id });
setStep('processing');
// 开始轮询进度
startProgressPolling(task.id);
} catch (err) {
console.error('开始批量处理失败:', err);
setError('开始批量处理失败');
setIsProcessing(false);
}
};
// 开始进度轮询
const startProgressPolling = (taskId: string) => {
const pollInterval = setInterval(async () => {
try {
const progressResult = await invoke<BatchProcessingProgress | null>('get_batch_progress', {
taskId: taskId
});
if (progressResult) {
setProgress(progressResult);
// 如果任务完成,停止轮询
if (progressResult.status === 'completed' ||
progressResult.status === 'failed' ||
progressResult.status === 'cancelled') {
clearInterval(pollInterval);
setIsProcessing(false);
// 获取最终任务状态
const finalTask = await invoke<BatchProcessingTask | null>('get_batch_task', {
taskId: taskId
});
if (finalTask) {
setCurrentTask(finalTask);
if (onTaskComplete) {
onTaskComplete(finalTask);
}
}
}
}
} catch (err) {
console.error('获取进度失败:', err);
clearInterval(pollInterval);
setIsProcessing(false);
}
}, 2000); // 每2秒轮询一次
};
// 取消任务
const cancelTask = async () => {
if (!currentTask) return;
try {
await invoke('cancel_batch_task', { taskId: currentTask.id });
setIsProcessing(false);
} catch (err) {
console.error('取消任务失败:', err);
setError('取消任务失败');
}
};
// 重置状态
const resetState = () => {
setStep('config');
setInputFolder('');
setOutputFolder('');
setSelectedTemplate(null);
setFileExtensions(['jpg', 'png', 'jpeg']);
setRecursive(true);
setTaskName('');
setPreviewFiles([]);
setCurrentTask(null);
setProgress(null);
setIsProcessing(false);
setError(null);
};
// 关闭处理
const handleClose = () => {
if (isProcessing) {
if (confirm('任务正在处理中,确定要关闭吗?')) {
if (currentTask) {
cancelTask();
}
resetState();
onClose();
}
} else {
resetState();
onClose();
}
};
// 获取状态图标
const getStatusIcon = (status: BatchTaskStatus) => {
switch (status) {
case 'pending':
return <Clock className="w-4 h-4 text-gray-500" />;
case 'processing':
return <RotateCcw className="w-4 h-4 text-blue-500 animate-spin" />;
case 'completed':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'failed':
return <AlertCircle className="w-4 h-4 text-red-500" />;
case 'cancelled':
return <Square className="w-4 h-4 text-gray-500" />;
default:
return <Clock className="w-4 h-4 text-gray-500" />;
}
};
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 w-full max-w-4xl 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={handleClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 步骤指示器 */}
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div className="flex items-center space-x-4">
<div className={`flex items-center space-x-2 ${step === 'config' ? 'text-blue-600' : step === 'preview' || step === 'processing' ? 'text-green-600' : 'text-gray-400'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${step === 'config' ? 'bg-blue-100' : step === 'preview' || step === 'processing' ? 'bg-green-100' : 'bg-gray-100'}`}>
1
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="flex-1 h-px bg-gray-300"></div>
<div className={`flex items-center space-x-2 ${step === 'preview' ? 'text-blue-600' : step === 'processing' ? 'text-green-600' : 'text-gray-400'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${step === 'preview' ? 'bg-blue-100' : step === 'processing' ? 'bg-green-100' : 'bg-gray-100'}`}>
2
</div>
<span className="text-sm font-medium"></span>
</div>
<div className="flex-1 h-px bg-gray-300"></div>
<div className={`flex items-center space-x-2 ${step === 'processing' ? 'text-blue-600' : 'text-gray-400'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${step === 'processing' ? 'bg-blue-100' : 'bg-gray-100'}`}>
3
</div>
<span className="text-sm font-medium"></span>
</div>
</div>
</div>
{/* 内容区域 */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">{error}</span>
</div>
</div>
)}
{/* 配置步骤 */}
{step === 'config' && (
<div className="space-y-6">
{/* 任务名称 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
placeholder="输入任务名称"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* 工作流模板选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={selectedTemplate?.id || ''}
onChange={(e) => {
const template = templates.find(t => t.id === parseInt(e.target.value));
setSelectedTemplate(template || null);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
{templates.map(template => (
<option key={template.id} value={template.id}>
{template.name} v{template.version}
</option>
))}
</select>
</div>
{/* 文件夹选择 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex space-x-2">
<input
type="text"
value={inputFolder}
readOnly
placeholder="选择输入文件夹"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg bg-gray-50"
/>
<button
onClick={selectInputFolder}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<FolderOpen className="w-4 h-4" />
<span></span>
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex space-x-2">
<input
type="text"
value={outputFolder}
readOnly
placeholder="选择输出文件夹"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg bg-gray-50"
/>
<button
onClick={selectOutputFolder}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<FolderOpen className="w-4 h-4" />
<span></span>
</button>
</div>
</div>
</div>
{/* 文件类型和选项 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="space-y-2">
{supportedExtensions.map(ext => (
<label key={ext} className="flex items-center space-x-2">
<input
type="checkbox"
checked={fileExtensions.includes(ext)}
onChange={(e) => {
if (e.target.checked) {
setFileExtensions([...fileExtensions, ext]);
} else {
setFileExtensions(fileExtensions.filter(e => e !== ext));
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">.{ext}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={recursive}
onChange={(e) => setRecursive(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700"></span>
</label>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<button
onClick={handleClose}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={scanFilesPreview}
disabled={!inputFolder || !selectedTemplate || isScanning}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors flex items-center space-x-2"
>
{isScanning ? (
<>
<RotateCcw className="w-4 h-4 animate-spin" />
<span>...</span>
</>
) : (
<>
<FileText className="w-4 h-4" />
<span></span>
</>
)}
</button>
</div>
</div>
)}
{/* 预览步骤 */}
{step === 'preview' && (
<div className="space-y-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Image className="w-5 h-5 text-blue-600" />
<span className="font-medium text-blue-900"></span>
</div>
<p className="text-sm text-blue-700">
<strong>{inputFolder}</strong> <strong>{previewFiles.length}</strong>
</p>
</div>
{/* 文件列表 */}
<div className="border border-gray-200 rounded-lg">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-sm font-medium text-gray-900"></h3>
</div>
<div className="max-h-60 overflow-y-auto">
{previewFiles.map((file, index) => (
<div key={index} className="px-4 py-2 border-b border-gray-100 last:border-b-0">
<div className="flex items-center space-x-2">
<Image className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-700 truncate">{file}</span>
</div>
</div>
))}
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-between">
<button
onClick={() => setStep('config')}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<div className="flex space-x-3">
<button
onClick={handleClose}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
</button>
<button
onClick={startBatchProcessing}
disabled={previewFiles.length === 0 || isProcessing}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 transition-colors flex items-center space-x-2"
>
<Play className="w-4 h-4" />
<span></span>
</button>
</div>
</div>
</div>
)}
{/* 处理步骤 */}
{step === 'processing' && currentTask && (
<div className="space-y-6">
{/* 任务信息 */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
{getStatusIcon(currentTask.status)}
<span className="font-medium text-gray-900">{currentTask.name}</span>
</div>
<span className="text-sm text-gray-500">
{currentTask.status === 'processing' ? '处理中' :
currentTask.status === 'completed' ? '已完成' :
currentTask.status === 'failed' ? '失败' : '已取消'}
</span>
</div>
{progress && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span>: {progress.processed_files} / {progress.total_files}</span>
<span>{progress.progress_percentage.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress.progress_percentage}%` }}
></div>
</div>
{progress.failed_files > 0 && (
<div className="text-sm text-red-600">
: {progress.failed_files}
</div>
)}
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
{isProcessing ? (
<button
onClick={cancelTask}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center space-x-2"
>
<Square className="w-4 h-4" />
<span></span>
</button>
) : (
<button
onClick={handleClose}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};