feat: 实现图片格式转换和实时事件通知功能

- 添加多种图片格式支持(WebP, BMP, TIFF, GIF等)
- 实现自动格式转换功能,将不支持的格式转换为JPG
- 使用Tauri事件系统替代定时轮询,实现任务状态实时更新
- 优化批量处理性能和用户体验
- 修复前端状态不实时更新的问题

主要变更:
1. 后端添加image crate依赖和格式转换逻辑
2. 前端添加事件监听机制,移除定时轮询
3. 实现进度回调和实时状态通知
4. 支持更多图片格式的批量处理
This commit is contained in:
imeepos 2025-07-31 18:02:09 +08:00
parent d9d1c4df52
commit 2f463507b8
4 changed files with 539 additions and 80 deletions

View File

@ -40,6 +40,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] }
tracing-appender = "0.2"
reqwest = { version = "0.11", features = ["json", "multipart"] }
image = { version = "0.24", features = ["jpeg", "png", "webp", "bmp", "tiff", "gif"] }
toml = "0.8"
tree-sitter = "0.20"
tree-sitter-json = "0.20"

View File

@ -6,6 +6,7 @@ use std::fs;
use base64::prelude::*;
use uuid::Uuid;
use tokio::time::sleep;
use image::{ImageFormat, DynamicImage};
use crate::data::models::image_editing::{
ImageEditingConfig, ImageEditingRequest, ImageEditingResponse, ImageEditingTask,
@ -34,6 +35,142 @@ impl ImageEditingService {
}
}
/// 支持的图片格式列表
const SUPPORTED_FORMATS: &'static [&'static str] = &[
"jpg", "jpeg", "png", "webp", "bmp", "tiff", "gif", "ico", "tga"
];
/// 原生支持的格式(无需转换)
const NATIVE_FORMATS: &'static [&'static str] = &["jpg", "jpeg", "png"];
/// 检查文件是否为支持的图片格式
fn is_supported_image_format(file_path: &Path) -> bool {
if let Some(extension) = file_path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
Self::SUPPORTED_FORMATS.contains(&ext.as_str())
} else {
false
}
}
/// 检查文件是否需要格式转换
fn needs_format_conversion(file_path: &Path) -> bool {
if let Some(extension) = file_path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
Self::SUPPORTED_FORMATS.contains(&ext.as_str()) &&
!Self::NATIVE_FORMATS.contains(&ext.as_str())
} else {
false
}
}
/// 获取图片格式
fn get_image_format(file_path: &Path) -> Option<ImageFormat> {
if let Some(extension) = file_path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
"png" => Some(ImageFormat::Png),
"webp" => Some(ImageFormat::WebP),
"bmp" => Some(ImageFormat::Bmp),
"tiff" | "tif" => Some(ImageFormat::Tiff),
"gif" => Some(ImageFormat::Gif),
"ico" => Some(ImageFormat::Ico),
"tga" => Some(ImageFormat::Tga),
_ => None,
}
} else {
None
}
}
/// 确定转换目标格式
fn determine_target_format(source_format: ImageFormat) -> ImageFormat {
match source_format {
// 有损格式转为JPEG
ImageFormat::WebP | ImageFormat::Jpeg => ImageFormat::Jpeg,
// 无损格式转为PNG
ImageFormat::Png | ImageFormat::Bmp | ImageFormat::Tiff |
ImageFormat::Gif | ImageFormat::Ico | ImageFormat::Tga => ImageFormat::Png,
// 其他格式默认转为PNG
_ => ImageFormat::Png,
}
}
/// 获取格式对应的文件扩展名
fn get_format_extension(format: ImageFormat) -> &'static str {
match format {
ImageFormat::Jpeg => "jpg",
ImageFormat::Png => "png",
_ => "png", // 默认使用PNG
}
}
/// 转换图片格式
async fn convert_image_format(
input_path: &Path,
output_dir: &Path,
) -> Result<PathBuf> {
println!("🔄 开始转换图片格式: {}", input_path.display());
// 检查输入文件是否存在
if !input_path.exists() {
return Err(anyhow!("输入文件不存在: {}", input_path.display()));
}
// 获取源格式
let source_format = Self::get_image_format(input_path)
.ok_or_else(|| anyhow!("不支持的图片格式: {}", input_path.display()))?;
// 确定目标格式
let target_format = Self::determine_target_format(source_format);
let target_extension = Self::get_format_extension(target_format);
// 生成输出文件路径
let file_stem = input_path.file_stem()
.ok_or_else(|| anyhow!("无法获取文件名"))?;
let output_filename = format!("{}_converted.{}",
file_stem.to_string_lossy(), target_extension);
let output_path = output_dir.join(output_filename);
// 确保输出目录存在
tokio::fs::create_dir_all(output_dir).await
.map_err(|e| anyhow!("创建输出目录失败: {}", e))?;
// 读取并转换图片
let img = image::open(input_path)
.map_err(|e| anyhow!("读取图片失败: {}", e))?;
// 保存转换后的图片
img.save_with_format(&output_path, target_format)
.map_err(|e| anyhow!("保存转换后的图片失败: {}", e))?;
println!("✅ 图片格式转换完成: {} -> {}",
input_path.display(), output_path.display());
Ok(output_path)
}
/// 获取或创建临时转换目录
async fn get_temp_conversion_dir() -> Result<PathBuf> {
let temp_dir = std::env::temp_dir().join("mixvideo_image_conversion");
tokio::fs::create_dir_all(&temp_dir).await
.map_err(|e| anyhow!("创建临时转换目录失败: {}", e))?;
Ok(temp_dir)
}
/// 清理临时转换文件
async fn cleanup_converted_files(temp_files: &[PathBuf]) -> Result<()> {
for file_path in temp_files {
if file_path.exists() {
if let Err(e) = tokio::fs::remove_file(file_path).await {
println!("⚠️ 清理临时文件失败: {} - {}", file_path.display(), e);
}
}
}
Ok(())
}
/// 使用自定义配置创建图像编辑服务
pub fn with_config(config: ImageEditingConfig) -> Self {
let client = Client::builder()
@ -49,7 +186,7 @@ impl ImageEditingService {
self.config.api_key = api_key;
}
/// 验证图像文件格式
/// 验证图像文件格式用于Base64编码只接受原生支持的格式
fn validate_image_format(file_path: &Path) -> Result<()> {
let extension = file_path
.extension()
@ -59,7 +196,7 @@ impl ImageEditingService {
match extension.as_str() {
"jpg" | "jpeg" | "png" => Ok(()),
_ => Err(anyhow!("不支持的图像格式: {},仅支持 JPEG 和 PNG", extension)),
_ => Err(anyhow!("文件格式错误: {},此时应该已经转换为 JPEG 或 PNG 格式", extension)),
}
}
@ -221,7 +358,7 @@ impl ImageEditingService {
Ok(response)
}
/// 获取文件夹中的所有图像文件
/// 获取文件夹中的所有图像文件(包括需要转换的格式)
fn get_image_files(folder_path: &Path) -> Result<Vec<PathBuf>> {
let mut image_files = Vec::new();
@ -240,13 +377,8 @@ impl ImageEditingService {
let entry = entry.map_err(|e| anyhow!("读取文件夹条目失败: {}", e))?;
let path = entry.path();
if path.is_file() {
if let Some(extension) = path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if matches!(ext.as_str(), "jpg" | "jpeg" | "png") {
image_files.push(path);
}
}
if path.is_file() && Self::is_supported_image_format(&path) {
image_files.push(path);
}
}
@ -255,6 +387,21 @@ impl ImageEditingService {
}
image_files.sort();
// 统计不同格式的文件数量
let mut format_counts = std::collections::HashMap::new();
for file in &image_files {
if let Some(ext) = file.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
*format_counts.entry(ext_str).or_insert(0) += 1;
}
}
println!("📊 发现的图片格式统计:");
for (format, count) in format_counts {
println!(" {} 格式: {} 个文件", format.to_uppercase(), count);
}
Ok(image_files)
}
@ -376,6 +523,149 @@ impl ImageEditingService {
Ok(batch_task)
}
/// 批量编辑图像(带任务状态更新回调)
pub async fn edit_batch_images_with_callback(
&self,
input_folder: &Path,
output_folder: &Path,
prompt: &str,
params: &ImageEditingParams,
task_update_callback: Option<Box<dyn Fn(BatchImageEditingTask) + Send + Sync>>,
) -> Result<BatchImageEditingTask> {
println!("🎨 开始批量编辑图像(带回调)");
println!("输入文件夹: {}", input_folder.display());
println!("输出文件夹: {}", output_folder.display());
println!("提示词: {}", prompt);
// 获取所有图像文件
let image_files = Self::get_image_files(input_folder)?;
println!("找到 {} 个图像文件", image_files.len());
// 创建输出文件夹
tokio::fs::create_dir_all(output_folder).await
.map_err(|e| anyhow!("创建输出文件夹失败: {}", e))?;
// 创建批量任务
let task_id = Uuid::new_v4().to_string();
let mut batch_task = BatchImageEditingTask::new(
task_id,
input_folder.to_string_lossy().to_string(),
output_folder.to_string_lossy().to_string(),
prompt.to_string(),
ImageEditingRequest {
model: self.config.model_id.clone(),
prompt: prompt.to_string(),
image: String::new(), // 每个任务单独设置
response_format: Some(params.response_format.clone()),
size: Some(params.size.clone()),
seed: Some(params.seed),
guidance_scale: Some(params.guidance_scale),
watermark: Some(params.watermark),
},
);
// 为每个图像文件创建单独的任务
for (_index, input_file) in image_files.iter().enumerate() {
let _output_file = Self::generate_output_filename(input_file, output_folder);
let individual_task = ImageEditingTask::new(
Uuid::new_v4().to_string(),
input_file.to_string_lossy().to_string(),
prompt.to_string(),
batch_task.request_params.clone(),
);
batch_task.add_task(individual_task);
}
batch_task.status = ImageEditingTaskStatus::Processing;
// 初始回调,通知任务已开始
if let Some(ref callback) = task_update_callback {
callback(batch_task.clone());
}
// 创建临时转换目录
let temp_conversion_dir = Self::get_temp_conversion_dir().await?;
let mut converted_files = Vec::new();
// 处理每个图像
for (index, input_file) in image_files.iter().enumerate() {
let output_file = Self::generate_output_filename(input_file, output_folder);
// 更新任务状态
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
task.set_processing();
}
// 检查是否需要格式转换
let actual_input_file = if Self::needs_format_conversion(input_file) {
println!("🔄 需要转换格式: {}", input_file.display());
match Self::convert_image_format(input_file, &temp_conversion_dir).await {
Ok(converted_path) => {
converted_files.push(converted_path.clone());
converted_path
}
Err(e) => {
println!("❌ 格式转换失败: {} - {}", input_file.display(), e);
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
task.set_failed(format!("格式转换失败: {}", e));
}
batch_task.update_progress();
if let Some(ref callback) = task_update_callback {
callback(batch_task.clone());
}
continue;
}
}
} else {
input_file.clone()
};
// 处理单个图像
match self.edit_single_image(&actual_input_file, &output_file, prompt, params).await {
Ok(response) => {
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
task.set_completed(output_file.to_string_lossy().to_string(), response);
}
println!("✅ 完成: {}", input_file.file_name().unwrap_or_default().to_string_lossy());
}
Err(e) => {
if let Some(task) = batch_task.individual_tasks.get_mut(index) {
task.set_failed(e.to_string());
}
println!("❌ 失败: {} - {}", input_file.file_name().unwrap_or_default().to_string_lossy(), e);
}
}
// 更新批量任务进度
batch_task.update_progress();
// 每处理完一个图像就回调更新状态
if let Some(ref callback) = task_update_callback {
callback(batch_task.clone());
}
}
// 清理临时转换文件
if !converted_files.is_empty() {
println!("🧹 清理 {} 个临时转换文件", converted_files.len());
if let Err(e) = Self::cleanup_converted_files(&converted_files).await {
println!("⚠️ 清理临时文件时出错: {}", e);
}
}
println!("🎉 批量编辑完成!");
println!("总计: {} 个文件", batch_task.total_images);
println!("成功: {} 个文件", batch_task.successful_images);
println!("失败: {} 个文件", batch_task.failed_images);
// 最终回调
if let Some(ref callback) = task_update_callback {
callback(batch_task.clone());
}
Ok(batch_task)
}
/// 创建编辑任务(用于异步处理)
pub async fn create_edit_task(
&self,

View File

@ -1,4 +1,4 @@
use tauri::State;
use tauri::{State, Manager, AppHandle, Emitter};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
@ -168,6 +168,7 @@ pub async fn get_image_editing_task_status(
/// 批量编辑图像
#[tauri::command]
pub async fn edit_batch_images(
app: AppHandle,
state: State<'_, ImageEditingState>,
input_folder: String,
output_folder: String,
@ -177,25 +178,112 @@ pub async fn edit_batch_images(
let input_folder = Path::new(&input_folder);
let output_folder = Path::new(&output_folder);
// 生成任务ID
let task_id = Uuid::new_v4().to_string();
// 立即创建批量任务并存储到状态中
let mut batch_task = BatchImageEditingTask::new(
task_id.clone(),
input_folder.to_string_lossy().to_string(),
output_folder.to_string_lossy().to_string(),
prompt.clone(),
crate::data::models::image_editing::ImageEditingRequest {
model: "doubao-seededit-3-0-i2i-250628".to_string(),
prompt: prompt.clone(),
image: String::new(),
response_format: Some(params.response_format.clone()),
size: Some(params.size.clone()),
seed: Some(params.seed),
guidance_scale: Some(params.guidance_scale),
watermark: Some(params.watermark),
},
);
// 设置任务状态为处理中
batch_task.status = ImageEditingTaskStatus::Processing;
// 立即存储批量任务,这样前端就能获取到
{
let mut batch_tasks = state.batch_tasks.lock().map_err(|e| format!("获取任务存储失败: {}", e))?;
batch_tasks.insert(task_id.clone(), batch_task.clone());
}
// 克隆服务以避免跨await持有锁
let service = {
let service_guard = state.service.lock().map_err(|e| format!("获取服务失败: {}", e))?;
service_guard.clone()
};
match service.edit_batch_images(input_folder, output_folder, &prompt, &params, None).await {
Ok(batch_task) => {
let task_id = batch_task.id.clone();
// 克隆必要的数据用于异步任务
let batch_tasks_clone = state.batch_tasks.clone();
let task_id_clone = task_id.clone();
// 存储批量任务
if let Ok(mut batch_tasks) = state.batch_tasks.lock() {
batch_tasks.insert(task_id.clone(), batch_task);
}
Ok(task_id)
// 创建任务状态更新回调
let app_clone = app.clone();
let update_callback = Box::new(move |updated_task: BatchImageEditingTask| {
if let Ok(mut batch_tasks) = batch_tasks_clone.lock() {
batch_tasks.insert(task_id_clone.clone(), updated_task.clone());
}
Err(e) => Err(format!("批量编辑失败: {}", e)),
}
// 发送Tauri事件通知前端
if let Err(e) = app_clone.emit("batch-task-updated", &updated_task) {
println!("发送任务更新事件失败: {}", e);
}
});
// 异步执行批量处理
let service_clone = service.clone();
let input_folder_clone = input_folder.to_path_buf();
let output_folder_clone = output_folder.to_path_buf();
let prompt_clone = prompt.clone();
let params_clone = params.clone();
let task_id_for_spawn = task_id.clone();
let batch_tasks_for_spawn = state.batch_tasks.clone();
let app_for_spawn = app.clone();
tokio::spawn(async move {
match service_clone.edit_batch_images_with_callback(
&input_folder_clone,
&output_folder_clone,
&prompt_clone,
&params_clone,
Some(update_callback),
).await {
Ok(final_task) => {
// 存储最终任务状态
if let Ok(mut batch_tasks) = batch_tasks_for_spawn.lock() {
batch_tasks.insert(task_id_for_spawn.clone(), final_task.clone());
}
// 发送最终完成事件
if let Err(e) = app_for_spawn.emit("batch-task-completed", &final_task) {
println!("发送任务完成事件失败: {}", e);
}
}
Err(e) => {
// 处理失败,更新任务状态
let mut failed_task = None;
if let Ok(mut batch_tasks) = batch_tasks_for_spawn.lock() {
if let Some(task) = batch_tasks.get_mut(&task_id_for_spawn) {
task.status = ImageEditingTaskStatus::Failed;
task.updated_at = chrono::Utc::now();
failed_task = Some(task.clone());
}
}
// 发送失败事件
if let Some(task) = failed_task {
if let Err(e) = app_for_spawn.emit("batch-task-failed", &task) {
println!("发送任务失败事件失败: {}", e);
}
}
println!("批量编辑失败: {}", e);
}
}
});
Ok(task_id)
}
/// 创建批量编辑任务

View File

@ -15,6 +15,7 @@ import {
Save
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import {
ImageEditingTask,
@ -24,7 +25,6 @@ import {
ImageEditingTaskStatus,
DEFAULT_IMAGE_EDITING_PARAMS,
DEFAULT_IMAGE_EDITING_CONFIG,
PRESET_PROMPTS,
TASK_STATUS_CONFIG,
IMAGE_FILE_CONFIG,
} from '../../types/imageEditing';
@ -56,14 +56,73 @@ const ImageEditingTool: React.FC = () => {
const [tasks, setTasks] = useState<ImageEditingTask[]>([]);
const [batchTasks, setBatchTasks] = useState<BatchImageEditingTask[]>([]);
const [showTasks, setShowTasks] = useState<boolean>(false);
// 配置状态
const [showConfig, setShowConfig] = useState<boolean>(false);
const [apiKeyInput, setApiKeyInput] = useState<string>('');
// 注意:现在使用事件驱动更新,不再需要定时刷新状态
// 初始化
useEffect(() => {
loadTasks();
// 设置Tauri事件监听
const setupEventListeners = async () => {
try {
// 监听批量任务更新事件
const unlistenUpdate = await listen<BatchImageEditingTask>('batch-task-updated', (event) => {
console.log('收到任务更新事件:', event.payload);
setBatchTasks(prev => {
const updated = [...prev];
const index = updated.findIndex(task => task.id === event.payload.id);
if (index >= 0) {
updated[index] = event.payload;
} else {
updated.push(event.payload);
}
return updated;
});
});
// 监听批量任务完成事件
const unlistenComplete = await listen<BatchImageEditingTask>('batch-task-completed', (event) => {
console.log('收到任务完成事件:', event.payload);
setBatchTasks(prev => {
const updated = [...prev];
const index = updated.findIndex(task => task.id === event.payload.id);
if (index >= 0) {
updated[index] = event.payload;
}
return updated;
});
});
// 监听批量任务失败事件
const unlistenFailed = await listen<BatchImageEditingTask>('batch-task-failed', (event) => {
console.log('收到任务失败事件:', event.payload);
setBatchTasks(prev => {
const updated = [...prev];
const index = updated.findIndex(task => task.id === event.payload.id);
if (index >= 0) {
updated[index] = event.payload;
}
return updated;
});
});
// 返回清理函数
return () => {
unlistenUpdate();
unlistenComplete();
unlistenFailed();
};
} catch (error) {
console.error('设置事件监听失败:', error);
}
};
setupEventListeners();
}, []);
// 加载任务列表
@ -80,6 +139,8 @@ const ImageEditingTool: React.FC = () => {
}
}, []);
// 注意:现在使用事件驱动的实时更新,不再需要定时刷新和状态检查
// 设置API密钥
const handleSetApiKey = useCallback(async () => {
if (!apiKeyInput.trim()) {
@ -236,37 +297,12 @@ const ImageEditingTool: React.FC = () => {
params: params,
});
// 创建任务对象用于UI显示
const newTask: BatchImageEditingTask = {
id: taskId,
input_folder_path: inputFolder,
output_folder_path: outputFolder,
prompt: batchPrompt,
total_images: 0, // 将由后端更新
processed_images: 0,
successful_images: 0,
failed_images: 0,
status: ImageEditingTaskStatus.Processing,
progress: 0,
individual_tasks: [],
request_params: {
model: 'doubao-seededit-3-0-i2i-250628',
prompt: batchPrompt,
image: '',
response_format: 'url',
size: 'adaptive',
seed: params.seed,
guidance_scale: params.guidance_scale,
watermark: params.watermark,
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
setBatchTask(newTask);
console.log('批量处理任务已创建任务ID:', taskId);
setIsProcessing(false);
alert('批量处理任务已启动!');
loadTasks(); // 刷新任务列表
// 立即刷新任务列表以显示新创建的任务
loadTasks();
} catch (error) {
console.error('批量编辑失败:', error);
@ -275,14 +311,7 @@ const ImageEditingTool: React.FC = () => {
}
}, [inputFolder, outputFolder, batchPrompt, params, config.api_key, loadTasks]);
// 应用预设提示词
const handleApplyPresetPrompt = useCallback((presetPrompt: string) => {
if (activeTab === 'single') {
setPrompt(presetPrompt);
} else {
setBatchPrompt(presetPrompt);
}
}, [activeTab]);
// 获取状态图标
const getStatusIcon = (status: ImageEditingTaskStatus) => {
@ -578,31 +607,80 @@ const ImageEditingTool: React.FC = () => {
)}
</div>
{/* 右侧:预设提示词和参数配置 */}
{/* 右侧:参数配置和任务列表 */}
<div className="space-y-6">
{/* 预设提示词 */}
{/* 任务列表 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-4">
{PRESET_PROMPTS.map((category) => (
<div key={category.category}>
<h4 className="text-sm font-medium text-gray-700 mb-2">
{category.category}
</h4>
<div className="space-y-1">
{category.prompts.map((presetPrompt) => (
<button
key={presetPrompt}
onClick={() => handleApplyPresetPrompt(presetPrompt)}
className="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 rounded-lg transition-colors"
>
{presetPrompt}
</button>
))}
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-3 max-h-64 overflow-y-auto">
{/* 显示最近的单个任务 */}
{tasks.slice(0, 3).map((task) => (
<div key={task.id} className="p-3 border border-gray-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
{getStatusIcon(task.status)}
<span className="text-sm font-medium text-gray-900">
{TASK_STATUS_CONFIG[task.status].label}
</span>
</div>
<div className="text-xs text-gray-600 truncate">
{task.prompt.substring(0, 40)}...
</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(task.created_at).toLocaleString()}
</div>
{task.progress > 0 && (
<div className="mt-2 w-full bg-gray-200 rounded-full h-1">
<div
className="bg-blue-500 h-1 rounded-full transition-all duration-300"
style={{ width: `${task.progress * 100}%` }}
/>
</div>
)}
</div>
))}
{/* 显示最近的批量任务 */}
{batchTasks.slice(0, 2).map((task) => (
<div key={task.id} className="p-3 border border-gray-200 rounded-lg bg-blue-50">
<div className="flex items-center gap-2 mb-1">
{getStatusIcon(task.status)}
<span className="text-sm font-medium text-blue-900">
- {TASK_STATUS_CONFIG[task.status].label}
</span>
</div>
<div className="text-xs text-blue-700 truncate">
{task.prompt.substring(0, 40)}...
</div>
<div className="text-xs text-blue-600 mt-1">
{task.processed_images} / {task.total_images}
</div>
{task.progress > 0 && (
<div className="mt-2 w-full bg-blue-200 rounded-full h-1">
<div
className="bg-blue-500 h-1 rounded-full transition-all duration-300"
style={{ width: `${task.progress * 100}%` }}
/>
</div>
)}
</div>
))}
{tasks.length === 0 && batchTasks.length === 0 && (
<div className="text-center py-4 text-gray-500 text-sm">
</div>
)}
</div>
{(tasks.length > 3 || batchTasks.length > 2) && (
<button
onClick={() => setShowTasks(true)}
className="w-full mt-3 px-3 py-2 text-sm text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 transition-colors"
>
</button>
)}
</div>
{/* 参数配置 */}
@ -669,6 +747,8 @@ const ImageEditingTool: React.FC = () => {
</div>
</div>
</div>
{/* 将任务管理里的任务列表放到这里 */}
</div>
</div>