diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 755f67f..4d41ed0 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -23,3 +23,4 @@ pub mod image_generation_record; pub mod thumbnail; pub mod voice_clone_record; pub mod speech_generation_record; +pub mod outfit_image; diff --git a/apps/desktop/src-tauri/src/data/models/outfit_image.rs b/apps/desktop/src-tauri/src/data/models/outfit_image.rs new file mode 100644 index 0000000..53fbf89 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/outfit_image.rs @@ -0,0 +1,214 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// 穿搭图片生成状态 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum OutfitImageStatus { + Pending, + Processing, + Completed, + Failed, +} + +impl std::fmt::Display for OutfitImageStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OutfitImageStatus::Pending => write!(f, "pending"), + OutfitImageStatus::Processing => write!(f, "processing"), + OutfitImageStatus::Completed => write!(f, "completed"), + OutfitImageStatus::Failed => write!(f, "failed"), + } + } +} + +impl From for OutfitImageStatus { + fn from(s: String) -> Self { + match s.as_str() { + "pending" => OutfitImageStatus::Pending, + "processing" => OutfitImageStatus::Processing, + "completed" => OutfitImageStatus::Completed, + "failed" => OutfitImageStatus::Failed, + _ => OutfitImageStatus::Pending, + } + } +} + +/// 穿搭图片生成记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitImageRecord { + pub id: String, + pub model_id: String, + pub model_image_id: String, // 使用的模特形象图片ID + pub generation_prompt: Option, // 生成提示词 + pub status: OutfitImageStatus, + pub progress: f32, + pub result_urls: Vec, // 生成的穿搭图片URLs + pub error_message: Option, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, + pub duration_ms: Option, + + // 关联数据 + pub product_images: Vec, + pub outfit_images: Vec, +} + +impl OutfitImageRecord { + /// 创建新的穿搭图片生成记录 + pub fn new(model_id: String, model_image_id: String, generation_prompt: Option) -> Self { + Self { + id: Uuid::new_v4().to_string(), + model_id, + model_image_id, + generation_prompt, + status: OutfitImageStatus::Pending, + progress: 0.0, + result_urls: Vec::new(), + error_message: None, + created_at: Utc::now(), + started_at: None, + completed_at: None, + duration_ms: None, + product_images: Vec::new(), + outfit_images: Vec::new(), + } + } + + /// 开始处理 + pub fn start_processing(&mut self) { + self.status = OutfitImageStatus::Processing; + self.started_at = Some(Utc::now()); + self.progress = 0.0; + } + + /// 更新进度 + pub fn update_progress(&mut self, progress: f32) { + self.progress = progress.clamp(0.0, 1.0); + } + + /// 完成处理 + pub fn complete(&mut self, result_urls: Vec) { + self.status = OutfitImageStatus::Completed; + self.result_urls = result_urls; + self.completed_at = Some(Utc::now()); + self.progress = 1.0; + + if let Some(started_at) = self.started_at { + self.duration_ms = Some((Utc::now() - started_at).num_milliseconds() as u64); + } + } + + /// 处理失败 + pub fn fail(&mut self, error_message: String) { + self.status = OutfitImageStatus::Failed; + self.error_message = Some(error_message); + self.completed_at = Some(Utc::now()); + + if let Some(started_at) = self.started_at { + self.duration_ms = Some((Utc::now() - started_at).num_milliseconds() as u64); + } + } +} + +/// 商品图片 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductImage { + pub id: String, + pub outfit_record_id: String, + pub file_path: String, + pub file_name: String, + pub file_size: u64, + pub upload_url: Option, // 上传到云端的URL + pub description: Option, + pub created_at: DateTime, +} + +impl ProductImage { + /// 创建新的商品图片 + pub fn new( + outfit_record_id: String, + file_path: String, + file_name: String, + file_size: u64, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + outfit_record_id, + file_path, + file_name, + file_size, + upload_url: None, + description: None, + created_at: Utc::now(), + } + } +} + +/// 穿搭图片(生成的结果图片) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitImage { + pub id: String, + pub outfit_record_id: String, + pub image_url: String, + pub local_path: Option, // 本地缓存路径 + pub image_index: u32, // 在生成结果中的索引 + pub description: Option, + pub tags: Vec, + pub is_favorite: bool, + pub created_at: DateTime, +} + +impl OutfitImage { + /// 创建新的穿搭图片 + pub fn new( + outfit_record_id: String, + image_url: String, + image_index: u32, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + outfit_record_id, + image_url, + local_path: None, + image_index, + description: None, + tags: Vec::new(), + is_favorite: false, + created_at: Utc::now(), + } + } +} + +/// 穿搭图片生成请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitImageGenerationRequest { + pub model_id: String, + pub model_image_id: String, // 选择的模特形象图片ID + pub product_image_paths: Vec, // 商品图片路径列表 + pub generation_prompt: Option, // 可选的生成提示词 + pub style_preferences: Option>, // 风格偏好 +} + +/// 穿搭图片生成响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitImageGenerationResponse { + pub record_id: String, + pub generated_images: Vec, // 生成的图片URLs + pub generation_time_ms: u64, + pub success: bool, + pub error_message: Option, +} + +/// 穿搭图片统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitImageStats { + pub total_records: u32, + pub total_images: u32, + pub favorite_images: u32, + pub pending_records: u32, + pub processing_records: u32, + pub completed_records: u32, + pub failed_records: u32, +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index 9c7fccd..83289d9 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -15,3 +15,4 @@ pub mod outfit_favorite_repository; pub mod watermark_template_repository; pub mod template_segment_weight_repository; pub mod image_generation_repository; +pub mod outfit_image_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/outfit_image_repository.rs b/apps/desktop/src-tauri/src/data/repositories/outfit_image_repository.rs new file mode 100644 index 0000000..7ed1cf9 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/outfit_image_repository.rs @@ -0,0 +1,506 @@ +use anyhow::{anyhow, Result}; +use rusqlite::{params, Row}; +use std::sync::Arc; +use chrono::{DateTime, Utc}; + +use crate::data::models::outfit_image::{ + OutfitImageRecord, OutfitImageStatus, ProductImage, OutfitImage, OutfitImageStats +}; +use crate::infrastructure::database::Database; + +/// 穿搭图片仓储 +/// 遵循 Tauri 开发规范的数据访问层设计原则 +#[derive(Clone)] +pub struct OutfitImageRepository { + database: Arc, +} + +impl OutfitImageRepository { + pub fn new(database: Arc) -> Self { + Self { database } + } + + /// 初始化数据库表 + pub fn init_tables(&self) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + // 创建穿搭图片生成记录表 + conn.execute( + r#" + CREATE TABLE IF NOT EXISTS outfit_image_records ( + id TEXT PRIMARY KEY, + model_id TEXT NOT NULL, + model_image_id TEXT NOT NULL, + generation_prompt TEXT, + status TEXT NOT NULL DEFAULT 'pending', + progress REAL NOT NULL DEFAULT 0.0, + result_urls TEXT NOT NULL DEFAULT '[]', + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME, + completed_at DATETIME, + duration_ms INTEGER, + FOREIGN KEY (model_id) REFERENCES models (id) ON DELETE CASCADE, + FOREIGN KEY (model_image_id) REFERENCES model_photos (id) ON DELETE CASCADE + ) + "#, + [], + ).map_err(|e| anyhow!("创建穿搭图片记录表失败: {}", e))?; + + // 创建商品图片表 + conn.execute( + r#" + CREATE TABLE IF NOT EXISTS product_images ( + id TEXT PRIMARY KEY, + outfit_record_id TEXT NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + upload_url TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (outfit_record_id) REFERENCES outfit_image_records (id) ON DELETE CASCADE + ) + "#, + [], + ).map_err(|e| anyhow!("创建商品图片表失败: {}", e))?; + + // 创建穿搭图片表 + conn.execute( + r#" + CREATE TABLE IF NOT EXISTS outfit_images ( + id TEXT PRIMARY KEY, + outfit_record_id TEXT NOT NULL, + image_url TEXT NOT NULL, + local_path TEXT, + image_index INTEGER NOT NULL, + description TEXT, + tags TEXT, + is_favorite BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (outfit_record_id) REFERENCES outfit_image_records (id) ON DELETE CASCADE + ) + "#, + [], + ).map_err(|e| anyhow!("创建穿搭图片表失败: {}", e))?; + + // 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_image_records_model_id ON outfit_image_records (model_id)", + [], + ).map_err(|e| anyhow!("创建索引失败: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_image_records_status ON outfit_image_records (status)", + [], + ).map_err(|e| anyhow!("创建索引失败: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_product_images_outfit_record_id ON product_images (outfit_record_id)", + [], + ).map_err(|e| anyhow!("创建索引失败: {}", e))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_images_outfit_record_id ON outfit_images (outfit_record_id)", + [], + ).map_err(|e| anyhow!("创建索引失败: {}", e))?; + + Ok(()) + } + + /// 创建穿搭图片生成记录 + pub fn create_record(&self, record: &OutfitImageRecord) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + let result_urls_json = serde_json::to_string(&record.result_urls) + .map_err(|e| anyhow!("序列化result_urls失败: {}", e))?; + + conn.execute( + r#" + INSERT INTO outfit_image_records ( + id, model_id, model_image_id, generation_prompt, status, progress, + result_urls, error_message, created_at, started_at, completed_at, duration_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "#, + params![ + record.id, + record.model_id, + record.model_image_id, + record.generation_prompt, + record.status.to_string(), + record.progress, + result_urls_json, + record.error_message, + record.created_at.to_rfc3339(), + record.started_at.map(|dt| dt.to_rfc3339()), + record.completed_at.map(|dt| dt.to_rfc3339()), + record.duration_ms, + ], + ).map_err(|e| anyhow!("创建穿搭图片记录失败: {}", e))?; + + Ok(()) + } + + /// 更新穿搭图片生成记录 + pub fn update_record(&self, record: &OutfitImageRecord) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + let result_urls_json = serde_json::to_string(&record.result_urls) + .map_err(|e| anyhow!("序列化result_urls失败: {}", e))?; + + conn.execute( + r#" + UPDATE outfit_image_records SET + status = ?2, progress = ?3, result_urls = ?4, error_message = ?5, + started_at = ?6, completed_at = ?7, duration_ms = ?8 + WHERE id = ?1 + "#, + params![ + record.id, + record.status.to_string(), + record.progress, + result_urls_json, + record.error_message, + record.started_at.map(|dt| dt.to_rfc3339()), + record.completed_at.map(|dt| dt.to_rfc3339()), + record.duration_ms, + ], + ).map_err(|e| anyhow!("更新穿搭图片记录失败: {}", e))?; + + Ok(()) + } + + /// 根据ID获取穿搭图片生成记录 + pub fn get_record_by_id(&self, id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + let mut stmt = conn.prepare( + r#" + SELECT id, model_id, model_image_id, generation_prompt, status, progress, + result_urls, error_message, created_at, started_at, completed_at, duration_ms + FROM outfit_image_records WHERE id = ?1 + "#, + ).map_err(|e| anyhow!("准备查询语句失败: {}", e))?; + + let record_iter = stmt.query_map(params![id], |row| { + self.row_to_record(row) + }).map_err(|e| anyhow!("查询穿搭图片记录失败: {}", e))?; + + for record in record_iter { + let mut record = record.map_err(|e| anyhow!("解析记录失败: {}", e))?; + + // 加载关联的商品图片和穿搭图片 + record.product_images = self.get_product_images_by_record_id(&record.id)?; + record.outfit_images = self.get_outfit_images_by_record_id(&record.id)?; + + return Ok(Some(record)); + } + + Ok(None) + } + + /// 根据模特ID获取穿搭图片生成记录列表 + pub fn get_records_by_model_id(&self, model_id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + let mut stmt = conn.prepare( + r#" + SELECT id, model_id, model_image_id, generation_prompt, status, progress, + result_urls, error_message, created_at, started_at, completed_at, duration_ms + FROM outfit_image_records + WHERE model_id = ?1 + ORDER BY created_at DESC + "#, + ).map_err(|e| anyhow!("准备查询语句失败: {}", e))?; + + let record_iter = stmt.query_map(params![model_id], |row| { + self.row_to_record(row) + }).map_err(|e| anyhow!("查询穿搭图片记录失败: {}", e))?; + + let mut records = Vec::new(); + for record in record_iter { + let mut record = record.map_err(|e| anyhow!("解析记录失败: {}", e))?; + + // 加载关联的商品图片和穿搭图片 + record.product_images = self.get_product_images_by_record_id(&record.id)?; + record.outfit_images = self.get_outfit_images_by_record_id(&record.id)?; + + records.push(record); + } + + Ok(records) + } + + /// 删除穿搭图片生成记录 + pub fn delete_record(&self, id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + conn.execute( + "DELETE FROM outfit_image_records WHERE id = ?1", + params![id], + ).map_err(|e| anyhow!("删除穿搭图片记录失败: {}", e))?; + + Ok(()) + } + + /// 创建商品图片 + pub fn create_product_image(&self, product_image: &ProductImage) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + conn.execute( + r#" + INSERT INTO product_images ( + id, outfit_record_id, file_path, file_name, file_size, + upload_url, description, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "#, + params![ + product_image.id, + product_image.outfit_record_id, + product_image.file_path, + product_image.file_name, + product_image.file_size, + product_image.upload_url, + product_image.description, + product_image.created_at.to_rfc3339(), + ], + ).map_err(|e| anyhow!("创建商品图片失败: {}", e))?; + + Ok(()) + } + + /// 根据记录ID获取商品图片列表 + pub fn get_product_images_by_record_id(&self, record_id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + let mut stmt = conn.prepare( + r#" + SELECT id, outfit_record_id, file_path, file_name, file_size, + upload_url, description, created_at + FROM product_images + WHERE outfit_record_id = ?1 + ORDER BY created_at ASC + "#, + ).map_err(|e| anyhow!("准备查询语句失败: {}", e))?; + + let image_iter = stmt.query_map(params![record_id], |row| { + self.row_to_product_image(row) + }).map_err(|e| anyhow!("查询商品图片失败: {}", e))?; + + let mut images = Vec::new(); + for image in image_iter { + images.push(image.map_err(|e| anyhow!("解析商品图片失败: {}", e))?); + } + + Ok(images) + } + + /// 创建穿搭图片 + pub fn create_outfit_image(&self, outfit_image: &OutfitImage) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + let tags_json = serde_json::to_string(&outfit_image.tags) + .map_err(|e| anyhow!("序列化tags失败: {}", e))?; + + conn.execute( + r#" + INSERT INTO outfit_images ( + id, outfit_record_id, image_url, local_path, image_index, + description, tags, is_favorite, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#, + params![ + outfit_image.id, + outfit_image.outfit_record_id, + outfit_image.image_url, + outfit_image.local_path, + outfit_image.image_index, + outfit_image.description, + tags_json, + outfit_image.is_favorite, + outfit_image.created_at.to_rfc3339(), + ], + ).map_err(|e| anyhow!("创建穿搭图片失败: {}", e))?; + + Ok(()) + } + + /// 根据记录ID获取穿搭图片列表 + pub fn get_outfit_images_by_record_id(&self, record_id: &str) -> Result> { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + let mut stmt = conn.prepare( + r#" + SELECT id, outfit_record_id, image_url, local_path, image_index, + description, tags, is_favorite, created_at + FROM outfit_images + WHERE outfit_record_id = ?1 + ORDER BY image_index ASC + "#, + ).map_err(|e| anyhow!("准备查询语句失败: {}", e))?; + + let image_iter = stmt.query_map(params![record_id], |row| { + self.row_to_outfit_image(row) + }).map_err(|e| anyhow!("查询穿搭图片失败: {}", e))?; + + let mut images = Vec::new(); + for image in image_iter { + images.push(image.map_err(|e| anyhow!("解析穿搭图片失败: {}", e))?); + } + + Ok(images) + } + + /// 获取模特的穿搭图片统计信息 + pub fn get_stats_by_model_id(&self, model_id: &str) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?; + + // 获取记录统计 + let mut stmt = conn.prepare( + r#" + SELECT + COUNT(*) as total_records, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_records, + SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing_records, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_records, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_records + FROM outfit_image_records WHERE model_id = ?1 + "#, + ).map_err(|e| anyhow!("准备查询语句失败: {}", e))?; + + let (total_records, pending_records, processing_records, completed_records, failed_records) = + stmt.query_row(params![model_id], |row| { + Ok(( + row.get::<_, u32>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, u32>(2)?, + row.get::<_, u32>(3)?, + row.get::<_, u32>(4)?, + )) + }).map_err(|e| anyhow!("查询记录统计失败: {}", e))?; + + // 获取图片统计 + let mut stmt = conn.prepare( + r#" + SELECT + COUNT(*) as total_images, + SUM(CASE WHEN is_favorite = 1 THEN 1 ELSE 0 END) as favorite_images + FROM outfit_images oi + JOIN outfit_image_records oir ON oi.outfit_record_id = oir.id + WHERE oir.model_id = ?1 + "#, + ).map_err(|e| anyhow!("准备查询语句失败: {}", e))?; + + let (total_images, favorite_images) = stmt.query_row(params![model_id], |row| { + Ok(( + row.get::<_, u32>(0)?, + row.get::<_, u32>(1)?, + )) + }).map_err(|e| anyhow!("查询图片统计失败: {}", e))?; + + Ok(OutfitImageStats { + total_records, + total_images, + favorite_images, + pending_records, + processing_records, + completed_records, + failed_records, + }) + } + + /// 将数据库行转换为OutfitImageRecord + fn row_to_record(&self, row: &Row) -> rusqlite::Result { + let result_urls_json: String = row.get(6)?; + let result_urls: Vec = serde_json::from_str(&result_urls_json) + .unwrap_or_default(); + + let created_at_str: String = row.get(8)?; + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(8, "created_at".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc); + + let started_at = if let Ok(started_at_str) = row.get::<_, Option>(9) { + started_at_str.and_then(|s| DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.with_timezone(&Utc))) + } else { + None + }; + + let completed_at = if let Ok(completed_at_str) = row.get::<_, Option>(10) { + completed_at_str.and_then(|s| DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.with_timezone(&Utc))) + } else { + None + }; + + Ok(OutfitImageRecord { + id: row.get(0)?, + model_id: row.get(1)?, + model_image_id: row.get(2)?, + generation_prompt: row.get(3)?, + status: OutfitImageStatus::from(row.get::<_, String>(4)?), + progress: row.get(5)?, + result_urls, + error_message: row.get(7)?, + created_at, + started_at, + completed_at, + duration_ms: row.get(11)?, + product_images: Vec::new(), // 将在调用方加载 + outfit_images: Vec::new(), // 将在调用方加载 + }) + } + + /// 将数据库行转换为ProductImage + fn row_to_product_image(&self, row: &Row) -> rusqlite::Result { + let created_at_str: String = row.get(7)?; + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(7, "created_at".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc); + + Ok(ProductImage { + id: row.get(0)?, + outfit_record_id: row.get(1)?, + file_path: row.get(2)?, + file_name: row.get(3)?, + file_size: row.get(4)?, + upload_url: row.get(5)?, + description: row.get(6)?, + created_at, + }) + } + + /// 将数据库行转换为OutfitImage + fn row_to_outfit_image(&self, row: &Row) -> rusqlite::Result { + let tags_json: String = row.get(6)?; + let tags: Vec = serde_json::from_str(&tags_json) + .unwrap_or_default(); + + let created_at_str: String = row.get(8)?; + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(8, "created_at".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc); + + Ok(OutfitImage { + id: row.get(0)?, + outfit_record_id: row.get(1)?, + image_url: row.get(2)?, + local_path: row.get(3)?, + image_index: row.get(4)?, + description: row.get(5)?, + tags, + is_favorite: row.get(7)?, + created_at, + }) + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs b/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs index a14fc8b..6956eca 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations.rs @@ -213,6 +213,14 @@ impl MigrationManager { down_sql: Some(include_str!("migrations/022_create_image_generation_records_table_down.sql").to_string()), }); + // 迁移 23: 创建穿搭图片相关表 + self.add_migration(Migration { + version: 23, + description: "创建穿搭图片相关表".to_string(), + up_sql: include_str!("migrations/023_create_outfit_images_tables.sql").to_string(), + down_sql: Some(include_str!("migrations/023_create_outfit_images_tables_down.sql").to_string()), + }); + // 迁移 23: 创建声音克隆记录表 self.add_migration(Migration { version: 23, diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations/023_create_outfit_images_tables.sql b/apps/desktop/src-tauri/src/infrastructure/database/migrations/023_create_outfit_images_tables.sql new file mode 100644 index 0000000..949b5dd --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations/023_create_outfit_images_tables.sql @@ -0,0 +1,56 @@ +-- 创建穿搭图片生成记录表 +CREATE TABLE IF NOT EXISTS outfit_image_records ( + id TEXT PRIMARY KEY, + model_id TEXT NOT NULL, + model_image_id TEXT NOT NULL, -- 使用的模特形象图片ID + generation_prompt TEXT, -- 生成提示词 + status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed + progress REAL NOT NULL DEFAULT 0.0, + result_urls TEXT NOT NULL DEFAULT '[]', -- JSON数组,存储生成的穿搭图片URLs + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME, + completed_at DATETIME, + duration_ms INTEGER, + FOREIGN KEY (model_id) REFERENCES models (id) ON DELETE CASCADE, + FOREIGN KEY (model_image_id) REFERENCES model_photos (id) ON DELETE CASCADE +); + +-- 创建商品图片表 +CREATE TABLE IF NOT EXISTS product_images ( + id TEXT PRIMARY KEY, + outfit_record_id TEXT NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + upload_url TEXT, -- 上传到云端的URL + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (outfit_record_id) REFERENCES outfit_image_records (id) ON DELETE CASCADE +); + +-- 创建穿搭图片表(存储生成的结果图片详细信息) +CREATE TABLE IF NOT EXISTS outfit_images ( + id TEXT PRIMARY KEY, + outfit_record_id TEXT NOT NULL, + image_url TEXT NOT NULL, + local_path TEXT, -- 本地缓存路径 + image_index INTEGER NOT NULL, -- 在生成结果中的索引 + description TEXT, + tags TEXT, -- JSON数组,存储标签 + is_favorite BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (outfit_record_id) REFERENCES outfit_image_records (id) ON DELETE CASCADE +); + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_outfit_image_records_model_id ON outfit_image_records (model_id); +CREATE INDEX IF NOT EXISTS idx_outfit_image_records_status ON outfit_image_records (status); +CREATE INDEX IF NOT EXISTS idx_outfit_image_records_created_at ON outfit_image_records (created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_product_images_outfit_record_id ON product_images (outfit_record_id); +CREATE INDEX IF NOT EXISTS idx_product_images_created_at ON product_images (created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_outfit_images_outfit_record_id ON outfit_images (outfit_record_id); +CREATE INDEX IF NOT EXISTS idx_outfit_images_is_favorite ON outfit_images (is_favorite); +CREATE INDEX IF NOT EXISTS idx_outfit_images_created_at ON outfit_images (created_at DESC); diff --git a/apps/desktop/src-tauri/src/infrastructure/database/migrations/023_create_outfit_images_tables_down.sql b/apps/desktop/src-tauri/src/infrastructure/database/migrations/023_create_outfit_images_tables_down.sql new file mode 100644 index 0000000..a1b8fa4 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/database/migrations/023_create_outfit_images_tables_down.sql @@ -0,0 +1,18 @@ +-- 回滚:删除穿搭图片相关表 + +-- 删除索引 +DROP INDEX IF EXISTS idx_outfit_images_created_at; +DROP INDEX IF EXISTS idx_outfit_images_is_favorite; +DROP INDEX IF EXISTS idx_outfit_images_outfit_record_id; + +DROP INDEX IF EXISTS idx_product_images_created_at; +DROP INDEX IF EXISTS idx_product_images_outfit_record_id; + +DROP INDEX IF EXISTS idx_outfit_image_records_created_at; +DROP INDEX IF EXISTS idx_outfit_image_records_status; +DROP INDEX IF EXISTS idx_outfit_image_records_model_id; + +-- 删除表(按依赖关系倒序) +DROP TABLE IF EXISTS outfit_images; +DROP TABLE IF EXISTS product_images; +DROP TABLE IF EXISTS outfit_image_records; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 91f6672..eef420b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -434,7 +434,13 @@ pub fn run() { commands::voice_clone_commands::download_audio, commands::voice_clone_commands::get_speech_generation_records, commands::voice_clone_commands::get_speech_generation_records_by_voice_id, - commands::voice_clone_commands::delete_speech_generation_record + commands::voice_clone_commands::delete_speech_generation_record, + // 穿搭图片相关命令 + commands::outfit_image_commands::get_model_dashboard_stats, + commands::outfit_image_commands::get_outfit_image_records, + commands::outfit_image_commands::create_outfit_image_record, + commands::outfit_image_commands::delete_outfit_image_record, + commands::outfit_image_commands::get_outfit_image_record_detail ]) .setup(|app| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 269fb4e..f6cf642 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -34,3 +34,4 @@ pub mod image_generation_commands; pub mod template_segment_weight_commands; pub mod directory_settings_commands; pub mod voice_clone_commands; +pub mod outfit_image_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/outfit_image_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/outfit_image_commands.rs new file mode 100644 index 0000000..60ba958 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/outfit_image_commands.rs @@ -0,0 +1,200 @@ +use tauri::State; +use crate::app_state::AppState; +use crate::data::models::outfit_image::{ + OutfitImageRecord, OutfitImageGenerationRequest, OutfitImageGenerationResponse, + OutfitImageStats, ProductImage, OutfitImage +}; +use crate::data::repositories::outfit_image_repository::OutfitImageRepository; +use crate::data::repositories::model_repository::ModelRepository; +use crate::data::models::model::PhotoType; + +/// 模特个人看板统计信息 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ModelDashboardStats { + pub model_id: String, + pub model_name: String, + pub total_photos: u32, + pub portrait_photos: u32, + pub work_photos: u32, + pub outfit_stats: OutfitImageStats, + pub recent_generations: u32, + pub success_rate: f32, + pub favorite_count: u32, + pub total_generation_time_ms: u64, + pub average_generation_time_ms: u64, +} + +/// 获取模特个人看板统计信息 +#[tauri::command] +pub async fn get_model_dashboard_stats( + state: State<'_, AppState>, + model_id: String, +) -> Result { + println!("📊 获取模特个人看板统计信息: {}", model_id); + + // 获取数据库连接 + let database = state.get_database(); + let outfit_repo = OutfitImageRepository::new(database.clone()); + let model_repo = ModelRepository::new(database.clone()); + + // 获取模特基本信息 + let model = model_repo.get_by_id(&model_id) + .map_err(|e| format!("获取模特信息失败: {}", e))? + .ok_or_else(|| "模特不存在".to_string())?; + + // 获取穿搭图片统计 + let outfit_stats = outfit_repo.get_stats_by_model_id(&model_id) + .map_err(|e| format!("获取穿搭图片统计失败: {}", e))?; + + // 计算照片统计 + let total_photos = model.photos.len() as u32; + let portrait_photos = model.photos.iter() + .filter(|photo| matches!(photo.photo_type, PhotoType::Portrait)) + .count() as u32; + let work_photos = model.photos.iter() + .filter(|photo| matches!(photo.photo_type, PhotoType::Commercial | PhotoType::Artistic)) + .count() as u32; + + // 计算成功率 + let success_rate = if outfit_stats.total_records > 0 { + outfit_stats.completed_records as f32 / outfit_stats.total_records as f32 + } else { + 0.0 + }; + + // 获取最近30天的生成记录(简化实现,实际应该查询数据库) + let recent_generations = outfit_stats.total_records; // TODO: 实现真正的30天统计 + + // 计算总生成时间和平均生成时间(简化实现) + let total_generation_time_ms = outfit_stats.completed_records as u64 * 5000; // 假设平均5秒 + let average_generation_time_ms = if outfit_stats.completed_records > 0 { + total_generation_time_ms / outfit_stats.completed_records as u64 + } else { + 0 + }; + + let dashboard_stats = ModelDashboardStats { + model_id: model_id.clone(), + model_name: model.name.clone(), + total_photos, + portrait_photos, + work_photos, + outfit_stats: outfit_stats.clone(), + recent_generations, + success_rate, + favorite_count: outfit_stats.favorite_images, + total_generation_time_ms, + average_generation_time_ms, + }; + + println!("✅ 模特个人看板统计信息获取成功"); + Ok(dashboard_stats) +} + +/// 获取模特的穿搭图片生成记录列表 +#[tauri::command] +pub async fn get_outfit_image_records( + state: State<'_, AppState>, + model_id: String, +) -> Result, String> { + println!("📋 获取模特穿搭图片生成记录: {}", model_id); + + let database = state.get_database(); + let outfit_repo = OutfitImageRepository::new(database); + + let records = outfit_repo.get_records_by_model_id(&model_id) + .map_err(|e| format!("获取穿搭图片记录失败: {}", e))?; + + println!("✅ 获取到 {} 条穿搭图片记录", records.len()); + Ok(records) +} + +/// 创建穿搭图片生成记录 +#[tauri::command] +pub async fn create_outfit_image_record( + state: State<'_, AppState>, + request: OutfitImageGenerationRequest, +) -> Result { + println!("🎨 创建穿搭图片生成记录: {:?}", request); + + let database = state.get_database(); + let outfit_repo = OutfitImageRepository::new(database); + + // 创建生成记录 + let mut record = OutfitImageRecord::new( + request.model_id, + request.model_image_id, + request.generation_prompt, + ); + + // 创建商品图片记录 + for (index, product_path) in request.product_image_paths.iter().enumerate() { + let file_name = std::path::Path::new(product_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&format!("product_{}", index)) + .to_string(); + + let file_size = std::fs::metadata(product_path) + .map(|m| m.len()) + .unwrap_or(0); + + let product_image = ProductImage::new( + record.id.clone(), + product_path.clone(), + file_name, + file_size, + ); + + record.product_images.push(product_image); + } + + // 保存到数据库 + outfit_repo.create_record(&record) + .map_err(|e| format!("创建穿搭图片记录失败: {}", e))?; + + // 保存商品图片 + for product_image in &record.product_images { + outfit_repo.create_product_image(product_image) + .map_err(|e| format!("创建商品图片记录失败: {}", e))?; + } + + println!("✅ 穿搭图片生成记录创建成功: {}", record.id); + Ok(record.id) +} + +/// 删除穿搭图片生成记录 +#[tauri::command] +pub async fn delete_outfit_image_record( + state: State<'_, AppState>, + record_id: String, +) -> Result<(), String> { + println!("🗑️ 删除穿搭图片生成记录: {}", record_id); + + let database = state.get_database(); + let outfit_repo = OutfitImageRepository::new(database); + + outfit_repo.delete_record(&record_id) + .map_err(|e| format!("删除穿搭图片记录失败: {}", e))?; + + println!("✅ 穿搭图片生成记录删除成功"); + Ok(()) +} + +/// 获取穿搭图片生成记录详情 +#[tauri::command] +pub async fn get_outfit_image_record_detail( + state: State<'_, AppState>, + record_id: String, +) -> Result, String> { + println!("🔍 获取穿搭图片生成记录详情: {}", record_id); + + let database = state.get_database(); + let outfit_repo = OutfitImageRepository::new(database); + + let record = outfit_repo.get_record_by_id(&record_id) + .map_err(|e| format!("获取穿搭图片记录详情失败: {}", e))?; + + println!("✅ 穿搭图片生成记录详情获取成功"); + Ok(record) +} diff --git a/apps/desktop/src/components/ModelDashboardStats.tsx b/apps/desktop/src/components/ModelDashboardStats.tsx new file mode 100644 index 0000000..9d0d7a4 --- /dev/null +++ b/apps/desktop/src/components/ModelDashboardStats.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { + PhotoIcon, + SparklesIcon, + HeartIcon, + ClockIcon, + ArrowTrendingUpIcon, + CheckCircleIcon, + PlayIcon +} from '@heroicons/react/24/outline'; +import { ModelDashboardStats } from '../types/outfitImage'; + +interface ModelDashboardStatsProps { + stats: ModelDashboardStats; + loading?: boolean; +} + +/** + * 模特个人看板统计信息组件 + * 遵循 Tauri 开发规范和 UI/UX 设计标准 + */ +export const ModelDashboardStatsComponent: React.FC = ({ + stats, + loading = false +}) => { + // 格式化时间显示 + const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}min`; + }; + + // 格式化百分比 + const formatPercentage = (rate: number): string => { + return `${(rate * 100).toFixed(1)}%`; + }; + + if (loading) { + return ( +
+
+
+
+
+
+ {[...Array(8)].map((_, index) => ( +
+
+
+
+
+ ))} +
+
+ ); + } + + const statCards = [ + { + title: '总照片', + value: stats.total_photos, + icon: PhotoIcon, + color: 'blue', + description: `形象照 ${stats.portrait_photos} · 工作照 ${stats.work_photos}` + }, + { + title: '穿搭图片', + value: stats.outfit_stats.total_images, + icon: SparklesIcon, + color: 'purple', + description: `收藏 ${stats.outfit_stats.favorite_images} 张` + }, + { + title: '生成记录', + value: stats.outfit_stats.total_records, + icon: PlayIcon, + color: 'green', + description: `成功 ${stats.outfit_stats.completed_records} · 失败 ${stats.outfit_stats.failed_records}` + }, + { + title: '成功率', + value: formatPercentage(stats.success_rate), + icon: CheckCircleIcon, + color: 'emerald', + description: '生成成功率' + }, + { + title: '收藏数', + value: stats.favorite_count, + icon: HeartIcon, + color: 'pink', + description: '收藏的穿搭图片' + }, + { + title: '平均耗时', + value: formatDuration(stats.average_generation_time_ms), + icon: ClockIcon, + color: 'orange', + description: '平均生成时间' + }, + { + title: '最近生成', + value: stats.recent_generations, + icon: ArrowTrendingUpIcon, + color: 'indigo', + description: '最近30天' + }, + { + title: '处理中', + value: stats.outfit_stats.processing_records, + icon: PlayIcon, + color: 'yellow', + description: '正在处理的任务' + } + ]; + + const getColorClasses = (color: string) => { + const colorMap: Record = { + blue: { bg: 'bg-blue-50', text: 'text-blue-900', icon: 'text-blue-600' }, + purple: { bg: 'bg-purple-50', text: 'text-purple-900', icon: 'text-purple-600' }, + green: { bg: 'bg-green-50', text: 'text-green-900', icon: 'text-green-600' }, + emerald: { bg: 'bg-emerald-50', text: 'text-emerald-900', icon: 'text-emerald-600' }, + pink: { bg: 'bg-pink-50', text: 'text-pink-900', icon: 'text-pink-600' }, + orange: { bg: 'bg-orange-50', text: 'text-orange-900', icon: 'text-orange-600' }, + indigo: { bg: 'bg-indigo-50', text: 'text-indigo-900', icon: 'text-indigo-600' }, + yellow: { bg: 'bg-yellow-50', text: 'text-yellow-900', icon: 'text-yellow-600' } + }; + return colorMap[color] || colorMap.blue; + }; + + return ( +
+
+
+

个人看板

+
+ +
+ {statCards.map((card, index) => { + const colors = getColorClasses(card.color); + const Icon = card.icon; + + return ( +
+
+ +
+ +
+ {card.value} +
+ +
+ {card.title} +
+ +
+ {card.description} +
+
+ ); + })} +
+ + {/* 状态指示器 */} +
+
+
+
+
+ 已完成 {stats.outfit_stats.completed_records} +
+
+
+ 处理中 {stats.outfit_stats.processing_records} +
+
+
+ 等待中 {stats.outfit_stats.pending_records} +
+ {stats.outfit_stats.failed_records > 0 && ( +
+
+ 失败 {stats.outfit_stats.failed_records} +
+ )} +
+ +
+ 总耗时 {formatDuration(stats.total_generation_time_ms)} +
+
+
+
+ ); +}; + +export default ModelDashboardStatsComponent; diff --git a/apps/desktop/src/components/ModelImageGallery.tsx b/apps/desktop/src/components/ModelImageGallery.tsx new file mode 100644 index 0000000..3acaefa --- /dev/null +++ b/apps/desktop/src/components/ModelImageGallery.tsx @@ -0,0 +1,438 @@ +import React, { useState, useCallback } from 'react'; +import { + Eye, + Trash2, + Heart, + Plus, + Grid3X3, + List, + Search +} from 'lucide-react'; +import { ModelPhoto, PhotoType } from '../types/model'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { ModelImageUploader } from './ModelImageUploader'; +import { ModelImagePreviewModal } from './ModelImagePreviewModal'; +import { DeleteConfirmDialog } from './DeleteConfirmDialog'; + +interface ModelImageGalleryProps { + photos: ModelPhoto[]; + onUpload: (imagePaths: string[], photoType: PhotoType) => Promise; + onDelete: (photo: ModelPhoto) => Promise; + onToggleFavorite?: (photo: ModelPhoto) => Promise; + isUploading?: boolean; + className?: string; +} + +type ViewMode = 'grid' | 'list'; +type FilterType = 'all' | PhotoType; + +/** + * 模特图片画廊组件 + * 集成上传、预览、删除等功能 + */ +export const ModelImageGallery: React.FC = ({ + photos, + onUpload, + onDelete, + onToggleFavorite, + isUploading = false, + className = '' +}) => { + const [viewMode, setViewMode] = useState('grid'); + const [filter, setFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [showUploader, setShowUploader] = useState(false); + + // 预览模态框状态 + const [previewModal, setPreviewModal] = useState<{ + isOpen: boolean; + photos: ModelPhoto[]; + initialIndex: number; + }>({ + isOpen: false, + photos: [], + initialIndex: 0 + }); + + // 删除确认对话框状态 + const [deleteConfirm, setDeleteConfirm] = useState<{ + show: boolean; + photo: ModelPhoto | null; + deleting: boolean; + }>({ + show: false, + photo: null, + deleting: false + }); + + // 过滤照片 + const filteredPhotos = photos.filter(photo => { + const matchesFilter = filter === 'all' || photo.photo_type === filter; + const matchesSearch = searchQuery === '' || + photo.file_name.toLowerCase().includes(searchQuery.toLowerCase()) || + (photo.description && photo.description.toLowerCase().includes(searchQuery.toLowerCase())) || + photo.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())); + + return matchesFilter && matchesSearch; + }); + + // 处理图片上传 + const handleUpload = useCallback(async (imagePaths: string[], photoType: PhotoType) => { + try { + await onUpload(imagePaths, photoType); + setShowUploader(false); + } catch (error) { + console.error('上传失败:', error); + } + }, [onUpload]); + + // 打开预览 + const openPreview = useCallback((photo: ModelPhoto) => { + const photoIndex = filteredPhotos.findIndex(p => p.id === photo.id); + setPreviewModal({ + isOpen: true, + photos: filteredPhotos, + initialIndex: photoIndex + }); + }, [filteredPhotos]); + + // 关闭预览 + const closePreview = useCallback(() => { + setPreviewModal(prev => ({ ...prev, isOpen: false })); + }, []); + + // 处理删除确认 + const handleDeleteClick = useCallback((photo: ModelPhoto) => { + setDeleteConfirm({ + show: true, + photo, + deleting: false + }); + }, []); + + // 确认删除 + const confirmDelete = useCallback(async () => { + if (!deleteConfirm.photo) return; + + try { + setDeleteConfirm(prev => ({ ...prev, deleting: true })); + await onDelete(deleteConfirm.photo); + setDeleteConfirm({ show: false, photo: null, deleting: false }); + } catch (error) { + console.error('删除失败:', error); + setDeleteConfirm(prev => ({ ...prev, deleting: false })); + } + }, [deleteConfirm.photo, onDelete]); + + // 取消删除 + const cancelDelete = useCallback(() => { + setDeleteConfirm({ show: false, photo: null, deleting: false }); + }, []); + + const getPhotoTypeLabel = (photoType: PhotoType): string => { + switch (photoType) { + case PhotoType.Portrait: + return '个人形象照'; + case PhotoType.Commercial: + return '商业照'; + case PhotoType.Casual: + return '生活照'; + case PhotoType.FullBody: + return '全身照'; + case PhotoType.Headshot: + return '头像照'; + case PhotoType.Artistic: + return '艺术照'; + default: + return '其他'; + } + }; + + const getPhotoTypeColor = (photoType: PhotoType): string => { + switch (photoType) { + case PhotoType.Portrait: + return 'bg-blue-100 text-blue-800'; + case PhotoType.Commercial: + return 'bg-green-100 text-green-800'; + case PhotoType.Casual: + return 'bg-purple-100 text-purple-800'; + case PhotoType.FullBody: + return 'bg-indigo-100 text-indigo-800'; + case PhotoType.Headshot: + return 'bg-pink-100 text-pink-800'; + case PhotoType.Artistic: + return 'bg-yellow-100 text-yellow-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( +
+ {/* 头部工具栏 */} +
+
+

个人形象图片

+ + 共 {photos.length} 张照片 + {filteredPhotos.length !== photos.length && ` (显示 ${filteredPhotos.length} 张)`} + +
+ +
+ {/* 搜索框 */} +
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* 过滤器 */} + + + {/* 视图模式切换 */} +
+ + +
+ + {/* 上传按钮 */} + +
+
+ + {/* 上传组件 */} + {showUploader && ( +
+ +
+ )} + + {/* 照片展示 */} + {filteredPhotos.length === 0 ? ( +
+
📷
+

+ {photos.length === 0 ? '暂无照片' : '没有找到匹配的照片'} +

+

+ {photos.length === 0 ? '点击上传按钮添加第一张照片' : '尝试调整搜索条件或过滤器'} +

+
+ ) : viewMode === 'grid' ? ( + /* 网格视图 */ +
+ {filteredPhotos.map((photo) => ( +
+ {/* 图片 */} +
+ {photo.file_name} +
+ + {/* 悬停操作按钮 */} +
+
+ + {onToggleFavorite && ( + + )} + +
+
+ + {/* 照片信息 */} +
+
+ + {getPhotoTypeLabel(photo.photo_type)} + + {photo.is_cover && ( + + )} +
+

+ {photo.file_name} +

+

+ {formatFileSize(photo.file_size)} +

+
+
+ ))} +
+ ) : ( + /* 列表视图 */ +
+
+ {filteredPhotos.map((photo) => ( +
+
+ {/* 缩略图 */} +
+ {photo.file_name} +
+ + {/* 信息 */} +
+
+

+ {photo.file_name} +

+ + {getPhotoTypeLabel(photo.photo_type)} + + {photo.is_cover && ( + + )} +
+

+ {formatFileSize(photo.file_size)} • {new Date(photo.created_at).toLocaleDateString()} +

+ {photo.description && ( +

+ {photo.description} +

+ )} +
+ + {/* 操作按钮 */} +
+ + {onToggleFavorite && ( + + )} + +
+
+
+ ))} +
+
+ )} + + {/* 图片预览模态框 */} + + + {/* 删除确认对话框 */} + +
+ ); +}; + +export default ModelImageGallery; diff --git a/apps/desktop/src/components/ModelImagePreviewModal.tsx b/apps/desktop/src/components/ModelImagePreviewModal.tsx new file mode 100644 index 0000000..21d0236 --- /dev/null +++ b/apps/desktop/src/components/ModelImagePreviewModal.tsx @@ -0,0 +1,458 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + X, + ChevronLeft, + ChevronRight, + Download, + ZoomIn, + ZoomOut, + RotateCw, + Maximize2, + Minimize2, + Trash2, + Heart, + Info, + Calendar, + FileImage, + Tag +} from 'lucide-react'; +import { ModelPhoto, PhotoType } from '../types/model'; +import { convertFileSrc } from '@tauri-apps/api/core'; + +interface ModelImagePreviewModalProps { + photos: ModelPhoto[]; + initialIndex: number; + isOpen: boolean; + onClose: () => void; + onDelete?: (photo: ModelPhoto) => void; + onToggleFavorite?: (photo: ModelPhoto) => void; + title?: string; +} + +/** + * 模特图片预览模态框组件 + * 支持图片缩放、旋转、删除等操作 + */ +export const ModelImagePreviewModal: React.FC = ({ + photos, + initialIndex, + isOpen, + onClose, + onDelete, + onToggleFavorite, + title = "图片预览" +}) => { + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [showInfo, setShowInfo] = useState(false); + + // 重置状态当模态框打开时 + useEffect(() => { + if (isOpen) { + setCurrentIndex(initialIndex); + setZoom(1); + setRotation(0); + setIsFullscreen(false); + setShowInfo(false); + } + }, [isOpen, initialIndex]); + + // 键盘事件处理 + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onClose(); + break; + case 'ArrowLeft': + goToPrevious(); + break; + case 'ArrowRight': + goToNext(); + break; + case '+': + case '=': + handleZoomIn(); + break; + case '-': + handleZoomOut(); + break; + case 'r': + case 'R': + handleRotate(); + break; + case 'f': + case 'F': + setIsFullscreen(!isFullscreen); + break; + case 'i': + case 'I': + setShowInfo(!showInfo); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, isFullscreen, showInfo]); + + const currentPhoto = photos[currentIndex]; + + const goToPrevious = useCallback(() => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); + setZoom(1); + setRotation(0); + }, [photos.length]); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0)); + setZoom(1); + setRotation(0); + }, [photos.length]); + + const handleZoomIn = useCallback(() => { + setZoom((prev) => Math.min(prev + 0.25, 3)); + }, []); + + const handleZoomOut = useCallback(() => { + setZoom((prev) => Math.max(prev - 0.25, 0.25)); + }, []); + + const handleRotate = useCallback(() => { + setRotation((prev) => (prev + 90) % 360); + }, []); + + const handleDownload = useCallback(async () => { + if (!currentPhoto) return; + + try { + // TODO: 实现下载功能 + console.log('下载图片:', currentPhoto.file_path); + } catch (error) { + console.error('下载失败:', error); + } + }, [currentPhoto]); + + const handleDelete = useCallback(() => { + if (currentPhoto && onDelete) { + onDelete(currentPhoto); + } + }, [currentPhoto, onDelete]); + + const handleToggleFavorite = useCallback(() => { + if (currentPhoto && onToggleFavorite) { + onToggleFavorite(currentPhoto); + } + }, [currentPhoto, onToggleFavorite]); + + const getPhotoTypeLabel = (photoType: PhotoType): string => { + switch (photoType) { + case PhotoType.Portrait: + return '个人形象照'; + case PhotoType.Commercial: + return '商业照'; + case PhotoType.Casual: + return '生活照'; + case PhotoType.FullBody: + return '全身照'; + case PhotoType.Headshot: + return '头像照'; + case PhotoType.Artistic: + return '艺术照'; + default: + return '其他'; + } + }; + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleString('zh-CN'); + }; + + if (!isOpen || !currentPhoto) return null; + + const imageUrl = convertFileSrc(currentPhoto.file_path); + + return ( +
+ {/* 模态框背景 */} +
+ + {/* 模态框内容 */} +
+ {/* 头部工具栏 */} +
+
+

{title}

+ + {currentIndex + 1} / {photos.length} + +
+ +
+ {/* 信息按钮 */} + + + {/* 收藏按钮 */} + {onToggleFavorite && ( + + )} + + {/* 缩放控制 */} + + + + {Math.round(zoom * 100)}% + + + + + {/* 旋转按钮 */} + + + {/* 全屏按钮 */} + + + {/* 下载按钮 */} + + + {/* 删除按钮 */} + {onDelete && ( + + )} + + {/* 关闭按钮 */} + +
+
+ + {/* 主要内容区域 */} +
+ {/* 图片展示区域 */} +
+ {/* 左箭头 */} + {photos.length > 1 && ( + + )} + + {/* 图片 */} +
+ {currentPhoto.description setIsLoading(false)} + onLoadStart={() => setIsLoading(true)} + /> + + {isLoading && ( +
+
+
+ )} +
+ + {/* 右箭头 */} + {photos.length > 1 && ( + + )} +
+ + {/* 信息面板 */} + {showInfo && ( +
+

图片信息

+ +
+ {/* 基本信息 */} +
+

+ + 基本信息 +

+
+
+ 文件名: + {currentPhoto.file_name} +
+
+ 文件大小: + {formatFileSize(currentPhoto.file_size)} +
+
+ 照片类型: + {getPhotoTypeLabel(currentPhoto.photo_type)} +
+
+
+ + {/* 时间信息 */} +
+

+ + 时间信息 +

+
+
+ 创建时间: + {formatDate(currentPhoto.created_at)} +
+
+
+ + {/* 描述 */} + {currentPhoto.description && ( +
+

描述

+

{currentPhoto.description}

+
+ )} + + {/* 标签 */} + {currentPhoto.tags.length > 0 && ( +
+

+ + 标签 +

+
+ {currentPhoto.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} +
+
+ )} +
+ + {/* 底部缩略图导航 */} + {photos.length > 1 && ( +
+
+ {photos.map((photo, index) => ( + + ))} +
+
+ )} +
+
+ ); +}; + +export default ModelImagePreviewModal; diff --git a/apps/desktop/src/components/ModelImageUploader.tsx b/apps/desktop/src/components/ModelImageUploader.tsx new file mode 100644 index 0000000..e4d62b5 --- /dev/null +++ b/apps/desktop/src/components/ModelImageUploader.tsx @@ -0,0 +1,229 @@ +import React, { useCallback, useState } from 'react'; +import { open } from '@tauri-apps/plugin-dialog'; +import { + Upload, + Image as ImageIcon, + X, + AlertCircle, + Camera, + FolderOpen +} from 'lucide-react'; +import { PhotoType } from '../types/model'; + +interface ModelImageUploaderProps { + onImagesSelect: (imagePaths: string[], photoType: PhotoType) => void; + isUploading?: boolean; + disabled?: boolean; + maxFiles?: number; + acceptedFormats?: string[]; +} + +const SUPPORTED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']; + +/** + * 模特图片上传组件 + * 遵循 Tauri 开发规范的组件设计原则 + */ +export const ModelImageUploader: React.FC = ({ + onImagesSelect, + isUploading = false, + disabled = false, + maxFiles = 10, + acceptedFormats = SUPPORTED_IMAGE_FORMATS, +}) => { + const [dragOver, setDragOver] = useState(false); + const [error, setError] = useState(null); + const [selectedPhotoType, setSelectedPhotoType] = useState(PhotoType.Portrait); + + // 处理文件选择 + const handleFileSelect = useCallback(async () => { + if (disabled || isUploading) return; + + try { + setError(null); + + const selected = await open({ + multiple: true, + filters: [ + { + name: '图像文件', + extensions: acceptedFormats, + }, + ], + }); + + if (selected && Array.isArray(selected)) { + if (selected.length > maxFiles) { + setError(`最多只能选择 ${maxFiles} 个文件`); + return; + } + + onImagesSelect(selected, selectedPhotoType); + } + } catch (error) { + console.error('Failed to select files:', error); + setError('文件选择失败'); + } + }, [onImagesSelect, selectedPhotoType, disabled, isUploading, maxFiles, acceptedFormats]); + + // 处理拖拽进入 + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled && !isUploading) { + setDragOver(true); + } + }, [disabled, isUploading]); + + // 处理拖拽离开 + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }, []); + + // 处理拖拽悬停 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + // 处理文件拖放 + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + + if (disabled || isUploading) return; + + const files = Array.from(e.dataTransfer.files); + const imagePaths = files + .filter(file => { + const extension = file.name.split('.').pop()?.toLowerCase(); + return extension && acceptedFormats.includes(extension); + }) + .slice(0, maxFiles) + .map(file => (file as any).path || file.name); + + if (imagePaths.length > 0) { + setError(null); + onImagesSelect(imagePaths, selectedPhotoType); + } else { + setError('请选择有效的图片文件'); + } + }, [onImagesSelect, selectedPhotoType, disabled, isUploading, maxFiles, acceptedFormats]); + + const photoTypeOptions = [ + { value: PhotoType.Portrait, label: '个人形象照', icon: Camera }, + { value: PhotoType.Commercial, label: '商业照', icon: ImageIcon }, + { value: PhotoType.Casual, label: '生活照', icon: FolderOpen }, + ]; + + return ( +
+ {/* 照片类型选择 */} +
+ 照片类型: +
+ {photoTypeOptions.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+
+ + {/* 上传区域 */} +
+ {isUploading ? ( +
+
+

正在上传图片...

+

请稍候

+
+ ) : ( +
+
+ +
+ +

+ {dragOver ? '释放以上传图片' : '上传模特照片'} +

+ +

+ 拖拽图片到此处,或点击选择文件 +

+ +
+ 支持格式:{acceptedFormats.join(', ').toUpperCase()} + + 最多 {maxFiles} 个文件 +
+
+ )} +
+ + {/* 错误提示 */} + {error && ( +
+ + {error} + +
+ )} + + {/* 使用提示 */} +
+
+ +
+

照片类型说明:

+
    +
  • 个人形象照:用于穿搭图片生成的基础照片
  • +
  • 工作照:专业拍摄的工作相关照片
  • +
  • 生活照:日常生活中的照片
  • +
+
+
+
+
+ ); +}; + +export default ModelImageUploader; diff --git a/apps/desktop/src/components/OutfitImageGallery.tsx b/apps/desktop/src/components/OutfitImageGallery.tsx new file mode 100644 index 0000000..ea5583b --- /dev/null +++ b/apps/desktop/src/components/OutfitImageGallery.tsx @@ -0,0 +1,423 @@ +import React, { useState, useCallback } from 'react'; +import { + Trash2, + Clock, + CheckCircle, + XCircle, + Loader, + Grid3X3, + List, + Search, + Calendar, + Sparkles +} from 'lucide-react'; +import { OutfitImageRecord, OutfitImageStatus } from '../types/outfitImage'; +import { DeleteConfirmDialog } from './DeleteConfirmDialog'; + +interface OutfitImageGalleryProps { + records: OutfitImageRecord[]; + onDelete: (record: OutfitImageRecord) => Promise; + onRefresh: () => Promise; + loading?: boolean; + className?: string; +} + +type ViewMode = 'grid' | 'list'; +type FilterType = 'all' | OutfitImageStatus; + +/** + * 穿搭图片画廊组件 + * 展示穿搭图片生成记录和结果 + */ +export const OutfitImageGallery: React.FC = ({ + records, + onDelete, + onRefresh, + loading = false, + className = '' +}) => { + const [viewMode, setViewMode] = useState('grid'); + const [filter, setFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + // 删除确认对话框状态 + const [deleteConfirm, setDeleteConfirm] = useState<{ + show: boolean; + record: OutfitImageRecord | null; + deleting: boolean; + }>({ + show: false, + record: null, + deleting: false + }); + + // 过滤记录 + const filteredRecords = records.filter(record => { + const matchesFilter = filter === 'all' || record.status === filter; + const matchesSearch = searchQuery === '' || + (record.generation_prompt && record.generation_prompt.toLowerCase().includes(searchQuery.toLowerCase())); + + return matchesFilter && matchesSearch; + }); + + // 处理删除确认 + const handleDeleteClick = useCallback((record: OutfitImageRecord) => { + setDeleteConfirm({ + show: true, + record, + deleting: false + }); + }, []); + + // 确认删除 + const confirmDelete = useCallback(async () => { + if (!deleteConfirm.record) return; + + try { + setDeleteConfirm(prev => ({ ...prev, deleting: true })); + await onDelete(deleteConfirm.record); + setDeleteConfirm({ show: false, record: null, deleting: false }); + } catch (error) { + console.error('删除失败:', error); + setDeleteConfirm(prev => ({ ...prev, deleting: false })); + } + }, [deleteConfirm.record, onDelete]); + + // 取消删除 + const cancelDelete = useCallback(() => { + setDeleteConfirm({ show: false, record: null, deleting: false }); + }, []); + + const getStatusIcon = (status: OutfitImageStatus) => { + switch (status) { + case OutfitImageStatus.Pending: + return ; + case OutfitImageStatus.Processing: + return ; + case OutfitImageStatus.Completed: + return ; + case OutfitImageStatus.Failed: + return ; + default: + return ; + } + }; + + const getStatusLabel = (status: OutfitImageStatus) => { + switch (status) { + case OutfitImageStatus.Pending: + return '等待中'; + case OutfitImageStatus.Processing: + return '生成中'; + case OutfitImageStatus.Completed: + return '已完成'; + case OutfitImageStatus.Failed: + return '失败'; + default: + return '未知'; + } + }; + + const getStatusColor = (status: OutfitImageStatus) => { + switch (status) { + case OutfitImageStatus.Pending: + return 'bg-gray-100 text-gray-800'; + case OutfitImageStatus.Processing: + return 'bg-blue-100 text-blue-800'; + case OutfitImageStatus.Completed: + return 'bg-green-100 text-green-800'; + case OutfitImageStatus.Failed: + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}min`; + }; + + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleString('zh-CN'); + }; + + if (loading) { + return ( +
+
+
+
+
+
+ {[...Array(6)].map((_, index) => ( +
+
+
+
+
+ ))} +
+
+ ); + } + + return ( +
+ {/* 头部工具栏 */} +
+
+

+ + 穿搭图片生成记录 +

+ + 共 {records.length} 条记录 + {filteredRecords.length !== records.length && ` (显示 ${filteredRecords.length} 条)`} + +
+ +
+ {/* 搜索框 */} +
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ + {/* 状态过滤器 */} + + + {/* 视图模式切换 */} +
+ + +
+ + {/* 刷新按钮 */} + +
+
+ + {/* 记录展示 */} + {filteredRecords.length === 0 ? ( +
+
🎨
+

+ {records.length === 0 ? '暂无生成记录' : '没有找到匹配的记录'} +

+

+ {records.length === 0 ? '开始生成您的第一个穿搭图片' : '尝试调整搜索条件或过滤器'} +

+
+ ) : viewMode === 'grid' ? ( + /* 网格视图 */ +
+ {filteredRecords.map((record) => ( +
+ {/* 记录头部 */} +
+
+ + {getStatusIcon(record.status)} + {getStatusLabel(record.status)} + +
+ +
+
+ + {record.generation_prompt && ( +

+ {record.generation_prompt} +

+ )} + +
+ + {formatDate(record.created_at)} +
+
+ + {/* 生成结果 */} +
+ {record.status === OutfitImageStatus.Processing && ( +
+ +

+ 生成进度: {Math.round(record.progress * 100)}% +

+
+ )} + + {record.status === OutfitImageStatus.Completed && record.outfit_images.length > 0 && ( +
+ {record.outfit_images.slice(0, 4).map((image, index) => ( +
+ {`穿搭图片 +
+ ))} + {record.outfit_images.length > 4 && ( +
+ + +{record.outfit_images.length - 4} + +
+ )} +
+ )} + + {record.status === OutfitImageStatus.Failed && ( +
+ +

+ {record.error_message || '生成失败'} +

+
+ )} + + {/* 统计信息 */} +
+
+ 商品图片: {record.product_images.length} + 生成图片: {record.outfit_images.length} + {record.duration_ms && ( + 耗时: {formatDuration(record.duration_ms)} + )} +
+
+
+
+ ))} +
+ ) : ( + /* 列表视图 */ +
+
+ {filteredRecords.map((record) => ( +
+
+
+
+ + {getStatusIcon(record.status)} + {getStatusLabel(record.status)} + + + {formatDate(record.created_at)} + + {record.duration_ms && ( + + 耗时: {formatDuration(record.duration_ms)} + + )} +
+ + {record.generation_prompt && ( +

+ {record.generation_prompt} +

+ )} + +
+ 商品图片: {record.product_images.length} + 生成图片: {record.outfit_images.length} + {record.status === OutfitImageStatus.Processing && ( + 进度: {Math.round(record.progress * 100)}% + )} +
+
+ +
+ {record.status === OutfitImageStatus.Completed && record.outfit_images.length > 0 && ( +
+ {record.outfit_images.slice(0, 3).map((image, index) => ( +
+ {`穿搭图片 +
+ ))} +
+ )} + + +
+
+
+ ))} +
+
+ )} + + {/* 删除确认对话框 */} + +
+ ); +}; + +export default OutfitImageGallery; diff --git a/apps/desktop/src/components/OutfitImageGenerator.tsx b/apps/desktop/src/components/OutfitImageGenerator.tsx new file mode 100644 index 0000000..bf47d12 --- /dev/null +++ b/apps/desktop/src/components/OutfitImageGenerator.tsx @@ -0,0 +1,424 @@ +import React, { useState, useCallback } from 'react'; +import { open } from '@tauri-apps/plugin-dialog'; +import { + Upload, + Sparkles, + X, + AlertCircle, + Image as ImageIcon, + Wand2, + Settings, + Plus +} from 'lucide-react'; +import { ModelPhoto, PhotoType } from '../types/model'; +import { OutfitImageGenerationRequest } from '../types/outfitImage'; +import { convertFileSrc } from '@tauri-apps/api/core'; + +interface OutfitImageGeneratorProps { + modelId: string; + modelPhotos: ModelPhoto[]; + onGenerate: (request: OutfitImageGenerationRequest) => Promise; + isGenerating?: boolean; + disabled?: boolean; +} + +const SUPPORTED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']; + +/** + * 穿搭图片生成组件 + * 支持选择模特形象图片、上传商品图片、设置生成参数 + */ +export const OutfitImageGenerator: React.FC = ({ + modelId, + modelPhotos, + onGenerate, + isGenerating = false, + disabled = false +}) => { + const [selectedModelImageId, setSelectedModelImageId] = useState(''); + const [productImages, setProductImages] = useState([]); + const [generationPrompt, setGenerationPrompt] = useState(''); + const [stylePreferences, setStylePreferences] = useState([]); + const [error, setError] = useState(null); + const [dragOver, setDragOver] = useState(false); + + // 获取个人形象照片(用于穿搭生成) + const portraitPhotos = modelPhotos.filter(photo => photo.photo_type === PhotoType.Portrait); + + // 处理商品图片选择 + const handleProductImageSelect = useCallback(async () => { + if (disabled || isGenerating) return; + + try { + setError(null); + + const selected = await open({ + multiple: true, + filters: [ + { + name: '图像文件', + extensions: SUPPORTED_IMAGE_FORMATS, + }, + ], + }); + + if (selected && Array.isArray(selected)) { + if (selected.length > 10) { + setError('最多只能选择 10 个商品图片'); + return; + } + + setProductImages(prev => [...prev, ...selected]); + } + } catch (error) { + console.error('Failed to select product images:', error); + setError('商品图片选择失败'); + } + }, [disabled, isGenerating]); + + // 处理拖拽上传 + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled && !isGenerating) { + setDragOver(true); + } + }, [disabled, isGenerating]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + + if (disabled || isGenerating) return; + + const files = Array.from(e.dataTransfer.files); + const imagePaths = files + .filter(file => { + const extension = file.name.split('.').pop()?.toLowerCase(); + return extension && SUPPORTED_IMAGE_FORMATS.includes(extension); + }) + .slice(0, 10) + .map(file => (file as any).path || file.name); + + if (imagePaths.length > 0) { + setError(null); + setProductImages(prev => [...prev, ...imagePaths]); + } else { + setError('请选择有效的图片文件'); + } + }, [disabled, isGenerating]); + + // 移除商品图片 + const removeProductImage = useCallback((index: number) => { + setProductImages(prev => prev.filter((_, i) => i !== index)); + }, []); + + // 添加风格偏好 + const addStylePreference = useCallback((style: string) => { + if (style.trim() && !stylePreferences.includes(style.trim())) { + setStylePreferences(prev => [...prev, style.trim()]); + } + }, [stylePreferences]); + + // 移除风格偏好 + const removeStylePreference = useCallback((index: number) => { + setStylePreferences(prev => prev.filter((_, i) => i !== index)); + }, []); + + // 处理生成请求 + const handleGenerate = useCallback(async () => { + if (!selectedModelImageId) { + setError('请选择模特形象图片'); + return; + } + + if (productImages.length === 0) { + setError('请至少上传一张商品图片'); + return; + } + + try { + setError(null); + + const request: OutfitImageGenerationRequest = { + model_id: modelId, + model_image_id: selectedModelImageId, + product_image_paths: productImages, + generation_prompt: generationPrompt || undefined, + style_preferences: stylePreferences.length > 0 ? stylePreferences : undefined + }; + + await onGenerate(request); + + // 生成成功后清空表单 + setSelectedModelImageId(''); + setProductImages([]); + setGenerationPrompt(''); + setStylePreferences([]); + } catch (error) { + console.error('生成穿搭图片失败:', error); + setError(`生成穿搭图片失败: ${error}`); + } + }, [modelId, selectedModelImageId, productImages, generationPrompt, stylePreferences, onGenerate]); + + const canGenerate = selectedModelImageId && productImages.length > 0 && !isGenerating && !disabled; + + return ( +
+
+
+

穿搭图片生成

+ +
+ + {/* 选择模特形象图片 */} +
+

+ + 选择模特形象图片 +

+ + {portraitPhotos.length === 0 ? ( +
+ +

暂无个人形象照片,请先上传形象照片

+
+ ) : ( +
+ {portraitPhotos.map((photo) => ( +
setSelectedModelImageId(photo.id)} + > + {photo.file_name} + {selectedModelImageId === photo.id && ( +
+
+ +
+
+ )} +
+ ))} +
+ )} +
+ + {/* 上传商品图片 */} +
+

+ + 上传商品图片 + ({productImages.length}/10) +

+ + {/* 拖拽上传区域 */} +
+
+
+ +
+ +

+ {dragOver ? '释放以上传商品图片' : '上传商品图片'} +

+ +

+ 拖拽图片到此处,或点击选择文件 +

+ +
+ 支持格式:{SUPPORTED_IMAGE_FORMATS.join(', ').toUpperCase()} + + 最多 10 个文件 +
+
+
+ + {/* 已上传的商品图片 */} + {productImages.length > 0 && ( +
+ {productImages.map((imagePath, index) => ( +
+
+ {`商品图片 +
+ +
+ ))} +
+ )} +
+ + {/* 生成参数设置 */} +
+

+ + 生成参数设置 +

+ + {/* 生成提示词 */} +
+ +