feat: 为MaterialCard添加缩略图功能并优化UI展示
- 为Material数据模型添加thumbnail_path字段 - 实现get_material_thumbnail_base64 API命令支持Material缩略图生成 - 创建MaterialThumbnail组件,支持懒加载和缓存机制 - 重新设计MaterialCard布局,使用缩略图替换文件类型图标 - 精简MaterialCard信息展示,将详细信息移到可折叠区域 - 优化按钮布局,使界面更加紧凑 - 简化切分片段显示方式,提升用户体验 - 修复数据库DateTime解析问题,支持SQLite和RFC3339两种格式 - 添加数据库迁移支持thumbnail_path字段 - 遵循promptx/tauri-desktop-app-expert开发规范
This commit is contained in:
@@ -114,6 +114,7 @@ pub struct Material {
|
|||||||
pub metadata: MaterialMetadata,
|
pub metadata: MaterialMetadata,
|
||||||
pub scene_detection: Option<SceneDetection>,
|
pub scene_detection: Option<SceneDetection>,
|
||||||
pub segments: Vec<MaterialSegment>,
|
pub segments: Vec<MaterialSegment>,
|
||||||
|
pub thumbnail_path: Option<String>, // 素材缩略图路径
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub processed_at: Option<DateTime<Utc>>,
|
pub processed_at: Option<DateTime<Utc>>,
|
||||||
@@ -236,6 +237,7 @@ impl Material {
|
|||||||
metadata: MaterialMetadata::None,
|
metadata: MaterialMetadata::None,
|
||||||
scene_detection: None,
|
scene_detection: None,
|
||||||
segments: Vec::new(),
|
segments: Vec::new(),
|
||||||
|
thumbnail_path: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
processed_at: None,
|
processed_at: None,
|
||||||
@@ -267,6 +269,7 @@ impl Material {
|
|||||||
metadata: MaterialMetadata::None,
|
metadata: MaterialMetadata::None,
|
||||||
scene_detection: None,
|
scene_detection: None,
|
||||||
segments: Vec::new(),
|
segments: Vec::new(),
|
||||||
|
thumbnail_path: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
processed_at: None,
|
processed_at: None,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ impl MaterialRepository {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||||
material_type, processing_status, metadata, scene_detection,
|
material_type, processing_status, metadata, scene_detection,
|
||||||
created_at, updated_at, processed_at, error_message
|
thumbnail_path, created_at, updated_at, processed_at, error_message
|
||||||
FROM materials WHERE id = ?1"
|
FROM materials WHERE id = ?1"
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ impl MaterialRepository {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||||
material_type, processing_status, metadata, scene_detection,
|
material_type, processing_status, metadata, scene_detection,
|
||||||
created_at, updated_at, processed_at, error_message
|
thumbnail_path, created_at, updated_at, processed_at, error_message
|
||||||
FROM materials WHERE project_id = ?1 ORDER BY created_at DESC"
|
FROM materials WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ impl MaterialRepository {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||||
material_type, processing_status, metadata, scene_detection,
|
material_type, processing_status, metadata, scene_detection,
|
||||||
created_at, updated_at, processed_at, error_message
|
thumbnail_path, created_at, updated_at, processed_at, error_message
|
||||||
FROM materials ORDER BY created_at DESC"
|
FROM materials ORDER BY created_at DESC"
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -371,27 +371,53 @@ impl MaterialRepository {
|
|||||||
metadata,
|
metadata,
|
||||||
scene_detection,
|
scene_detection,
|
||||||
segments: Vec::new(), // 需要单独查询
|
segments: Vec::new(), // 需要单独查询
|
||||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?)
|
thumbnail_path: row.get("thumbnail_path")?,
|
||||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
created_at: {
|
||||||
row.as_ref().column_index("created_at").unwrap(),
|
let created_at_str = row.get::<_, String>("created_at")?;
|
||||||
format!("DateTime parse error: {}", e),
|
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
|
||||||
rusqlite::types::Type::Text
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") {
|
||||||
))?.with_timezone(&chrono::Utc),
|
dt.and_utc()
|
||||||
updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("updated_at")?)
|
} else {
|
||||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
// 回退到RFC3339格式
|
||||||
row.as_ref().column_index("updated_at").unwrap(),
|
chrono::DateTime::parse_from_rfc3339(&created_at_str)
|
||||||
format!("DateTime parse error: {}", e),
|
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||||
rusqlite::types::Type::Text
|
row.as_ref().column_index("created_at").unwrap(),
|
||||||
))?.with_timezone(&chrono::Utc),
|
format!("DateTime parse error: {}", e),
|
||||||
|
rusqlite::types::Type::Text
|
||||||
|
))?.with_timezone(&chrono::Utc)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
let updated_at_str = row.get::<_, String>("updated_at")?;
|
||||||
|
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
|
||||||
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&updated_at_str, "%Y-%m-%d %H:%M:%S") {
|
||||||
|
dt.and_utc()
|
||||||
|
} else {
|
||||||
|
// 回退到RFC3339格式
|
||||||
|
chrono::DateTime::parse_from_rfc3339(&updated_at_str)
|
||||||
|
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||||
|
row.as_ref().column_index("updated_at").unwrap(),
|
||||||
|
format!("DateTime parse error: {}", e),
|
||||||
|
rusqlite::types::Type::Text
|
||||||
|
))?.with_timezone(&chrono::Utc)
|
||||||
|
}
|
||||||
|
},
|
||||||
processed_at: row.get::<_, Option<String>>("processed_at")?
|
processed_at: row.get::<_, Option<String>>("processed_at")?
|
||||||
.map(|s| chrono::DateTime::parse_from_rfc3339(&s))
|
.map(|s| {
|
||||||
|
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
|
||||||
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") {
|
||||||
|
Ok(dt.and_utc())
|
||||||
|
} else {
|
||||||
|
// 回退到RFC3339格式
|
||||||
|
chrono::DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&chrono::Utc))
|
||||||
|
}
|
||||||
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||||
row.as_ref().column_index("processed_at").unwrap(),
|
row.as_ref().column_index("processed_at").unwrap(),
|
||||||
format!("DateTime parse error: {}", e),
|
format!("DateTime parse error: {}", e),
|
||||||
rusqlite::types::Type::Text
|
rusqlite::types::Type::Text
|
||||||
))?
|
))?,
|
||||||
.map(|dt| dt.with_timezone(&chrono::Utc)),
|
|
||||||
error_message: row.get("error_message")?,
|
error_message: row.get("error_message")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -408,12 +434,21 @@ impl MaterialRepository {
|
|||||||
file_path: row.get("file_path")?,
|
file_path: row.get("file_path")?,
|
||||||
file_size: row.get::<_, i64>("file_size")? as u64,
|
file_size: row.get::<_, i64>("file_size")? as u64,
|
||||||
thumbnail_path: row.get("thumbnail_path")?,
|
thumbnail_path: row.get("thumbnail_path")?,
|
||||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>("created_at")?)
|
created_at: {
|
||||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
let created_at_str = row.get::<_, String>("created_at")?;
|
||||||
row.as_ref().column_index("created_at").unwrap(),
|
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
|
||||||
format!("DateTime parse error: {}", e),
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") {
|
||||||
rusqlite::types::Type::Text
|
dt.and_utc()
|
||||||
))?.with_timezone(&chrono::Utc),
|
} else {
|
||||||
|
// 回退到RFC3339格式
|
||||||
|
chrono::DateTime::parse_from_rfc3339(&created_at_str)
|
||||||
|
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||||
|
row.as_ref().column_index("created_at").unwrap(),
|
||||||
|
format!("DateTime parse error: {}", e),
|
||||||
|
rusqlite::types::Type::Text
|
||||||
|
))?.with_timezone(&chrono::Utc)
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +486,7 @@ impl MaterialRepository {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||||
material_type, processing_status, metadata, scene_detection,
|
material_type, processing_status, metadata, scene_detection,
|
||||||
created_at, updated_at, processed_at, error_message
|
thumbnail_path, created_at, updated_at, processed_at, error_message
|
||||||
FROM materials WHERE model_id = ?1 ORDER BY created_at DESC"
|
FROM materials WHERE model_id = ?1 ORDER BY created_at DESC"
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -476,7 +511,7 @@ impl MaterialRepository {
|
|||||||
(
|
(
|
||||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||||
material_type, processing_status, metadata, scene_detection,
|
material_type, processing_status, metadata, scene_detection,
|
||||||
created_at, updated_at, processed_at, error_message
|
thumbnail_path, created_at, updated_at, processed_at, error_message
|
||||||
FROM materials WHERE model_id IS NULL AND project_id = ?1 ORDER BY created_at DESC".to_string(),
|
FROM materials WHERE model_id IS NULL AND project_id = ?1 ORDER BY created_at DESC".to_string(),
|
||||||
vec![pid]
|
vec![pid]
|
||||||
)
|
)
|
||||||
@@ -484,7 +519,7 @@ impl MaterialRepository {
|
|||||||
(
|
(
|
||||||
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
"SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||||
material_type, processing_status, metadata, scene_detection,
|
material_type, processing_status, metadata, scene_detection,
|
||||||
created_at, updated_at, processed_at, error_message
|
thumbnail_path, created_at, updated_at, processed_at, error_message
|
||||||
FROM materials WHERE model_id IS NULL ORDER BY created_at DESC".to_string(),
|
FROM materials WHERE model_id IS NULL ORDER BY created_at DESC".to_string(),
|
||||||
vec![]
|
vec![]
|
||||||
)
|
)
|
||||||
@@ -595,7 +630,18 @@ impl MaterialRepository {
|
|||||||
file_path: row.get(6)?,
|
file_path: row.get(6)?,
|
||||||
file_size: row.get(7)?,
|
file_size: row.get(7)?,
|
||||||
thumbnail_path: row.get(8)?,
|
thumbnail_path: row.get(8)?,
|
||||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?).map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&chrono::Utc),
|
created_at: {
|
||||||
|
let created_at_str = row.get::<_, String>(9)?;
|
||||||
|
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
|
||||||
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") {
|
||||||
|
dt.and_utc()
|
||||||
|
} else {
|
||||||
|
// 回退到RFC3339格式
|
||||||
|
chrono::DateTime::parse_from_rfc3339(&created_at_str)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&chrono::Utc)
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -627,7 +673,18 @@ impl MaterialRepository {
|
|||||||
file_path: row.get(6)?,
|
file_path: row.get(6)?,
|
||||||
file_size: row.get(7)?,
|
file_size: row.get(7)?,
|
||||||
thumbnail_path: row.get(8)?,
|
thumbnail_path: row.get(8)?,
|
||||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?).map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&chrono::Utc),
|
created_at: {
|
||||||
|
let created_at_str = row.get::<_, String>(9)?;
|
||||||
|
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
|
||||||
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") {
|
||||||
|
dt.and_utc()
|
||||||
|
} else {
|
||||||
|
// 回退到RFC3339格式
|
||||||
|
chrono::DateTime::parse_from_rfc3339(&created_at_str)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(9, "created_at".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&chrono::Utc)
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -773,6 +830,19 @@ impl MaterialRepository {
|
|||||||
|
|
||||||
Ok(count as u32)
|
Ok(count as u32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新素材的缩略图路径
|
||||||
|
pub fn update_material_thumbnail(&self, material_id: &str, thumbnail_path: &str) -> Result<()> {
|
||||||
|
let conn = self.database.get_connection();
|
||||||
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE materials SET thumbnail_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
[thumbnail_path, material_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模特素材统计信息
|
/// 模特素材统计信息
|
||||||
|
|||||||
@@ -1236,6 +1236,17 @@ impl Database {
|
|||||||
println!("Added thumbnail_path column to material_segments table");
|
println!("Added thumbnail_path column to material_segments table");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加缩略图路径字段到素材表
|
||||||
|
let has_material_thumbnail_path_column = conn.prepare("SELECT thumbnail_path FROM materials LIMIT 1").is_ok();
|
||||||
|
if !has_material_thumbnail_path_column {
|
||||||
|
println!("Adding thumbnail_path column to materials table");
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE materials ADD COLUMN thumbnail_path TEXT",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
println!("Added thumbnail_path column to materials table");
|
||||||
|
}
|
||||||
|
|
||||||
// 暂时禁用自动清理,避免启动时卡住
|
// 暂时禁用自动清理,避免启动时卡住
|
||||||
// self.cleanup_invalid_projects()?;
|
// self.cleanup_invalid_projects()?;
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pub fn run() {
|
|||||||
commands::material_commands::generate_video_thumbnail,
|
commands::material_commands::generate_video_thumbnail,
|
||||||
commands::material_commands::generate_and_save_segment_thumbnail,
|
commands::material_commands::generate_and_save_segment_thumbnail,
|
||||||
commands::material_commands::read_thumbnail_as_data_url,
|
commands::material_commands::read_thumbnail_as_data_url,
|
||||||
|
commands::material_commands::get_material_thumbnail_base64,
|
||||||
commands::material_commands::get_segment_thumbnail_base64,
|
commands::material_commands::get_segment_thumbnail_base64,
|
||||||
commands::material_commands::test_scene_detection,
|
commands::material_commands::test_scene_detection,
|
||||||
commands::material_commands::get_material_segments,
|
commands::material_commands::get_material_segments,
|
||||||
|
|||||||
@@ -156,3 +156,5 @@ pub fn debug_database_data(state: State<AppState>, project_id: String) -> Result
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -904,6 +904,177 @@ pub async fn read_thumbnail_as_data_url(file_path: String) -> Result<String, Str
|
|||||||
Ok(format!("data:image/jpeg;base64,{}", base64_data))
|
Ok(format!("data:image/jpeg;base64,{}", base64_data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据materialId获取缩略图base64数据URL
|
||||||
|
#[command]
|
||||||
|
pub async fn get_material_thumbnail_base64(
|
||||||
|
material_id: String,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use crate::infrastructure::ffmpeg::FFmpegService;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::fs;
|
||||||
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
|
|
||||||
|
// 获取素材信息
|
||||||
|
let material = {
|
||||||
|
let material_repository_guard = app_state.material_repository.lock().unwrap();
|
||||||
|
let material_repository = material_repository_guard.as_ref()
|
||||||
|
.ok_or("MaterialRepository未初始化")?;
|
||||||
|
|
||||||
|
material_repository.get_by_id(&material_id)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
|
let material = match material {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return Err("素材不存在".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查数据库中是否已有缩略图路径
|
||||||
|
if let Some(ref thumbnail_path) = material.thumbnail_path {
|
||||||
|
// 去掉Windows长路径前缀
|
||||||
|
let clean_path = if thumbnail_path.starts_with("\\\\?\\") {
|
||||||
|
&thumbnail_path[4..]
|
||||||
|
} else {
|
||||||
|
thumbnail_path
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if Path::new(clean_path).exists() {
|
||||||
|
// 文件存在,直接读取并返回
|
||||||
|
let file_data = fs::read(clean_path)
|
||||||
|
.map_err(|e| format!("读取缩略图文件失败: {}", e))?;
|
||||||
|
|
||||||
|
let base64_data = general_purpose::STANDARD.encode(&file_data);
|
||||||
|
return Ok(format!("data:image/jpeg;base64,{}", base64_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩略图不存在或文件已丢失,需要重新生成
|
||||||
|
let video_path = &material.original_path;
|
||||||
|
|
||||||
|
// 去掉Windows长路径前缀
|
||||||
|
let clean_video_path = if video_path.starts_with("\\\\?\\") {
|
||||||
|
&video_path[4..]
|
||||||
|
} else {
|
||||||
|
video_path
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查视频文件是否存在,不存在则报错
|
||||||
|
if !Path::new(clean_video_path).exists() {
|
||||||
|
let error_msg = format!("视频文件不存在,无法生成缩略图: {}", clean_video_path);
|
||||||
|
tracing::error!(
|
||||||
|
material_id = %material_id,
|
||||||
|
video_path = %clean_video_path,
|
||||||
|
"视频文件不存在"
|
||||||
|
);
|
||||||
|
return Err(error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成缩略图路径
|
||||||
|
let thumbnail_filename = format!("{}_material_thumbnail.jpg", material.id);
|
||||||
|
let video_dir = Path::new(clean_video_path).parent()
|
||||||
|
.ok_or("无法获取视频文件目录")?;
|
||||||
|
let thumbnail_path = video_dir.join(thumbnail_filename);
|
||||||
|
let thumbnail_path_str = thumbnail_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 获取视频信息来确定合适的缩略图尺寸
|
||||||
|
let video_info = FFmpegService::get_video_info(clean_video_path)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 计算缩略图尺寸,保持宽高比,最大宽度160
|
||||||
|
let max_width = 160;
|
||||||
|
let (thumb_width, thumb_height) = if video_info.width > 0 && video_info.height > 0 {
|
||||||
|
let aspect_ratio = video_info.width as f64 / video_info.height as f64;
|
||||||
|
if video_info.width > max_width {
|
||||||
|
let new_width = max_width;
|
||||||
|
let new_height = (new_width as f64 / aspect_ratio).round() as u32;
|
||||||
|
(new_width, new_height)
|
||||||
|
} else {
|
||||||
|
(video_info.width, video_info.height)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果无法获取视频尺寸,使用默认值
|
||||||
|
(160, 120)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成缩略图(使用首帧,带重试机制)
|
||||||
|
let timestamp = 0.0; // 使用视频开始时间
|
||||||
|
|
||||||
|
// 先进行预检查
|
||||||
|
if let Err(e) = FFmpegService::validate_thumbnail_generation(
|
||||||
|
clean_video_path,
|
||||||
|
&thumbnail_path_str,
|
||||||
|
timestamp
|
||||||
|
) {
|
||||||
|
let error_msg = format!(
|
||||||
|
"缩略图生成预检查失败: {} (视频: {})",
|
||||||
|
e, clean_video_path
|
||||||
|
);
|
||||||
|
tracing::error!(
|
||||||
|
material_id = %material_id,
|
||||||
|
video_path = %clean_video_path,
|
||||||
|
error = %e,
|
||||||
|
"缩略图生成预检查失败"
|
||||||
|
);
|
||||||
|
return Err(error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用带重试机制的缩略图生成
|
||||||
|
if let Err(e) = FFmpegService::generate_thumbnail_with_retry(
|
||||||
|
clean_video_path,
|
||||||
|
&thumbnail_path_str,
|
||||||
|
timestamp,
|
||||||
|
thumb_width,
|
||||||
|
thumb_height
|
||||||
|
) {
|
||||||
|
let error_msg = format!(
|
||||||
|
"FFmpeg生成缩略图失败(已重试): {} (视频: {}, 输出: {}, 时间戳: {}s)",
|
||||||
|
e, clean_video_path, thumbnail_path_str, timestamp
|
||||||
|
);
|
||||||
|
tracing::error!(
|
||||||
|
material_id = %material_id,
|
||||||
|
video_path = %clean_video_path,
|
||||||
|
thumbnail_path = %thumbnail_path_str,
|
||||||
|
timestamp = timestamp,
|
||||||
|
error = %e,
|
||||||
|
"FFmpeg缩略图生成失败(已重试)"
|
||||||
|
);
|
||||||
|
return Err(error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证缩略图文件是否生成成功
|
||||||
|
if !Path::new(&thumbnail_path_str).exists() {
|
||||||
|
let error_msg = format!(
|
||||||
|
"缩略图文件生成失败,文件不存在: {}",
|
||||||
|
thumbnail_path_str
|
||||||
|
);
|
||||||
|
tracing::error!(
|
||||||
|
material_id = %material_id,
|
||||||
|
thumbnail_path = %thumbnail_path_str,
|
||||||
|
"缩略图文件生成失败"
|
||||||
|
);
|
||||||
|
return Err(error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存缩略图路径到数据库
|
||||||
|
{
|
||||||
|
let material_repository_guard = app_state.material_repository.lock().unwrap();
|
||||||
|
let material_repository = material_repository_guard.as_ref()
|
||||||
|
.ok_or("MaterialRepository未初始化")?;
|
||||||
|
|
||||||
|
material_repository.update_material_thumbnail(&material_id, &thumbnail_path_str)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取生成的缩略图文件并返回base64
|
||||||
|
let file_data = fs::read(&thumbnail_path_str)
|
||||||
|
.map_err(|e| format!("读取生成的缩略图失败: {}", e))?;
|
||||||
|
|
||||||
|
let base64_data = general_purpose::STANDARD.encode(&file_data);
|
||||||
|
Ok(format!("data:image/jpeg;base64,{}", base64_data))
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据segmentId获取缩略图base64数据URL
|
/// 根据segmentId获取缩略图base64数据URL
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn get_segment_thumbnail_base64(
|
pub async fn get_segment_thumbnail_base64(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
FileVideo, FileAudio, FileImage, File, Clock, ExternalLink, ChevronDown, ChevronUp,
|
FileVideo, FileAudio, FileImage, Clock, ExternalLink, ChevronDown, ChevronUp,
|
||||||
Monitor, Volume2, Palette, Calendar, Hash, Zap, HardDrive, Film, Eye, Brain, Loader2, User, Edit2, Trash2, RefreshCw
|
Monitor, Volume2, Palette, Calendar, Hash, Zap, HardDrive, Film, Eye, Brain, Loader2, User, Edit2, Trash2, RefreshCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Material, MaterialSegment } from '../types/material';
|
import { Material, MaterialSegment } from '../types/material';
|
||||||
@@ -9,6 +9,7 @@ import { useVideoClassificationStore } from '../store/videoClassificationStore';
|
|||||||
import { Model } from '../types/model';
|
import { Model } from '../types/model';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
||||||
|
import { MaterialThumbnail } from './MaterialThumbnail';
|
||||||
|
|
||||||
interface MaterialCardProps {
|
interface MaterialCardProps {
|
||||||
material: Material;
|
material: Material;
|
||||||
@@ -73,22 +74,12 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
|||||||
const [associatedModel, setAssociatedModel] = useState<Model | null>(null);
|
const [associatedModel, setAssociatedModel] = useState<Model | null>(null);
|
||||||
const [loadingModel, setLoadingModel] = useState(false);
|
const [loadingModel, setLoadingModel] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [thumbnailCache, setThumbnailCache] = useState<Map<string, string>>(new Map());
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isReprocessing, setIsReprocessing] = useState(false);
|
const [isReprocessing, setIsReprocessing] = useState(false);
|
||||||
|
|
||||||
// 获取素材类型图标
|
|
||||||
const getTypeIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'Video':
|
|
||||||
return <FileVideo className="w-4 h-4" />;
|
|
||||||
case 'Audio':
|
|
||||||
return <FileAudio className="w-4 h-4" />;
|
|
||||||
case 'Image':
|
|
||||||
return <FileImage className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <File className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取状态颜色
|
// 获取状态颜色
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@@ -272,72 +263,124 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
|
<div className="border border-gray-200 rounded-lg p-3 hover:shadow-md transition-shadow">
|
||||||
{/* 素材基本信息 */}
|
{/* 素材基本信息 */}
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
{/* 缩略图 */}
|
||||||
{getTypeIcon(material.material_type)}
|
<MaterialThumbnail
|
||||||
<h4 className="font-medium text-gray-900 truncate">{material.name}</h4>
|
material={material}
|
||||||
</div>
|
size="medium"
|
||||||
<div className="flex items-center space-x-2">
|
thumbnailCache={thumbnailCache}
|
||||||
{/* 重新处理按钮 - 仅在状态为 Pending 时显示 */}
|
setThumbnailCache={setThumbnailCache}
|
||||||
{material.processing_status === 'Pending' && onReprocess && (
|
/>
|
||||||
<button
|
|
||||||
onClick={handleReprocessClick}
|
{/* 素材信息 */}
|
||||||
disabled={isReprocessing}
|
<div className="flex-1 min-w-0">
|
||||||
className="text-gray-400 hover:text-green-500 transition-colors disabled:opacity-50"
|
<div className="flex items-start justify-between">
|
||||||
title="重新处理"
|
<div className="flex-1 min-w-0">
|
||||||
>
|
<h4 className="font-medium text-gray-900 truncate text-sm">{material.name}</h4>
|
||||||
{isReprocessing ? (
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<span className="flex items-center gap-1">
|
||||||
) : (
|
<HardDrive className="w-3 h-3" />
|
||||||
<RefreshCw className="w-4 h-4" />
|
{formatFileSize(material.file_size)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{formatDate(material.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center space-x-1 ml-2">
|
||||||
|
{/* 重新处理按钮 - 仅在状态为 Pending 时显示 */}
|
||||||
|
{material.processing_status === 'Pending' && onReprocess && (
|
||||||
|
<button
|
||||||
|
onClick={handleReprocessClick}
|
||||||
|
disabled={isReprocessing}
|
||||||
|
className="text-gray-400 hover:text-green-500 transition-colors disabled:opacity-50 p-1"
|
||||||
|
title="重新处理"
|
||||||
|
>
|
||||||
|
{isReprocessing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(material)}
|
onClick={() => onEdit(material)}
|
||||||
className="text-gray-400 hover:text-blue-500 transition-colors"
|
className="text-gray-400 hover:text-blue-500 transition-colors p-1"
|
||||||
title="编辑素材"
|
title="编辑素材"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
className="text-gray-400 hover:text-red-500 transition-colors p-1"
|
||||||
title="删除素材"
|
title="删除素材"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${getStatusColor(material.processing_status)}`}>
|
<span className={`px-2 py-1 text-xs rounded-full ${getStatusColor(material.processing_status)}`}>
|
||||||
{material.processing_status}
|
{material.processing_status}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 素材详细信息 */}
|
{/* 快速操作区域 */}
|
||||||
<div className="space-y-3">
|
<div className="flex items-center justify-between pt-2">
|
||||||
{/* 基本信息 */}
|
<div className="flex items-center space-x-2">
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
{/* AI分类按钮 */}
|
||||||
<div className="flex items-center space-x-1 text-gray-600">
|
{material.material_type === 'Video' && material.processing_status === 'Completed' && (
|
||||||
<HardDrive className="w-3 h-3" />
|
<button
|
||||||
<span>{formatFileSize(material.file_size)}</span>
|
onClick={handleStartClassification}
|
||||||
</div>
|
disabled={isClassifying || classificationLoading}
|
||||||
<div className="flex items-center space-x-1 text-gray-600">
|
className="text-xs px-2 py-1 bg-blue-50 text-blue-600 rounded hover:bg-blue-100 disabled:opacity-50 flex items-center gap-1"
|
||||||
<Calendar className="w-3 h-3" />
|
>
|
||||||
<span>{formatDate(material.created_at)}</span>
|
{isClassifying ? (
|
||||||
</div>
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Brain className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
AI分类
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 关联模特信息 */}
|
{/* 展开/折叠详细信息按钮 */}
|
||||||
{material.model_id && (
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors text-xs flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{showDetails ? (
|
||||||
|
<>
|
||||||
|
<span>收起</span>
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>详情</span>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 素材详细信息 - 可折叠 */}
|
||||||
|
{showDetails && (
|
||||||
|
<div className="space-y-3 pt-3 border-t border-gray-100">
|
||||||
|
{/* 关联模特信息 */}
|
||||||
|
{material.model_id && (
|
||||||
<div className="bg-purple-50 rounded-lg p-3">
|
<div className="bg-purple-50 rounded-lg p-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<User className="w-4 h-4 text-purple-600" />
|
<User className="w-4 h-4 text-purple-600" />
|
||||||
@@ -490,7 +533,8 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 切分片段控制 */}
|
{/* 切分片段控制 */}
|
||||||
{material.material_type === 'Video' && material.processing_status === 'Completed' && (
|
{material.material_type === 'Video' && material.processing_status === 'Completed' && (
|
||||||
@@ -511,34 +555,21 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
|||||||
<span>{loadingSegments ? '加载中...' : showSegments ? '隐藏片段' : '查看切分片段'}</span>
|
<span>{loadingSegments ? '加载中...' : showSegments ? '隐藏片段' : '查看切分片段'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* AI智能分类按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={handleStartClassification}
|
|
||||||
disabled={isClassifying || classificationLoading}
|
|
||||||
className="flex items-center space-x-1 px-3 py-1.5 text-xs font-medium text-white bg-gradient-to-r from-purple-500 to-pink-500 rounded-md hover:from-purple-600 hover:to-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md"
|
|
||||||
title="使用AI自动分类视频片段"
|
|
||||||
>
|
|
||||||
{isClassifying ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Brain className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
<span>{isClassifying ? '分类中...' : 'AI分类'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 切分片段列表 */}
|
{/* 切分片段列表 */}
|
||||||
{showSegments && segments.length > 0 && (
|
{showSegments && segments.length > 0 && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3">
|
||||||
<h5 className="text-sm font-medium text-gray-900">切分片段 ({segments.length})</h5>
|
<div className="text-xs text-gray-500 mb-2">
|
||||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
共 {segments.length} 个片段,总时长 {formatTime(segments.reduce((total, seg) => total + seg.duration, 0))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto">
|
||||||
{segments.map((segment) => (
|
{segments.map((segment) => (
|
||||||
<div key={segment.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
|
<div key={segment.id} className="flex items-center justify-between p-1.5 bg-gray-50 rounded text-xs">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-1">
|
||||||
<span className="font-medium">#{segment.segment_index + 1}</span>
|
<span className="font-medium">#{segment.segment_index + 1}</span>
|
||||||
<Clock className="w-3 h-3" />
|
<span className="text-gray-500">{formatTime(segment.duration)}</span>
|
||||||
<span>{formatTime(segment.start_time)} - {formatTime(segment.end_time)}</span>
|
|
||||||
<span className="text-gray-500">({formatTime(segment.duration)})</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openFileLocation(segment.file_path)}
|
onClick={() => openFileLocation(segment.file_path)}
|
||||||
|
|||||||
135
apps/desktop/src/components/MaterialThumbnail.tsx
Normal file
135
apps/desktop/src/components/MaterialThumbnail.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { FileVideo, FileAudio, FileImage, File, Loader2 } from 'lucide-react';
|
||||||
|
import { Material } from '../types/material';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useLazyLoad } from '../hooks/useLazyLoad';
|
||||||
|
|
||||||
|
interface MaterialThumbnailProps {
|
||||||
|
material: Material;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
className?: string;
|
||||||
|
thumbnailCache?: Map<string, string>;
|
||||||
|
setThumbnailCache?: (cache: Map<string, string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material缩略图组件
|
||||||
|
* 遵循Tauri开发规范的组件设计模式
|
||||||
|
* 支持懒加载、缓存机制、错误处理
|
||||||
|
*/
|
||||||
|
export const MaterialThumbnail: React.FC<MaterialThumbnailProps> = ({
|
||||||
|
material,
|
||||||
|
size = 'medium',
|
||||||
|
className = '',
|
||||||
|
thumbnailCache = new Map(),
|
||||||
|
setThumbnailCache = () => {},
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
// 使用懒加载Hook,当缩略图容器可见时才开始加载
|
||||||
|
const { isVisible, elementRef } = useLazyLoad(0.1, '100px');
|
||||||
|
|
||||||
|
// 根据size确定尺寸
|
||||||
|
const getSizeClasses = () => {
|
||||||
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
return 'w-12 h-12';
|
||||||
|
case 'medium':
|
||||||
|
return 'w-16 h-16';
|
||||||
|
case 'large':
|
||||||
|
return 'w-24 h-24';
|
||||||
|
default:
|
||||||
|
return 'w-16 h-16';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取文件类型图标
|
||||||
|
const getTypeIcon = () => {
|
||||||
|
const iconSize = size === 'small' ? 'w-6 h-6' : size === 'large' ? 'w-12 h-12' : 'w-8 h-8';
|
||||||
|
|
||||||
|
switch (material.material_type) {
|
||||||
|
case 'Video':
|
||||||
|
return <FileVideo className={`${iconSize} text-blue-500`} />;
|
||||||
|
case 'Audio':
|
||||||
|
return <FileAudio className={`${iconSize} text-green-500`} />;
|
||||||
|
case 'Image':
|
||||||
|
return <FileImage className={`${iconSize} text-purple-500`} />;
|
||||||
|
default:
|
||||||
|
return <File className={`${iconSize} text-gray-500`} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 只有当元素可见时才加载缩略图
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
// 只为视频类型生成缩略图
|
||||||
|
if (material.material_type !== 'Video') return;
|
||||||
|
|
||||||
|
const loadThumbnail = async () => {
|
||||||
|
const materialId = material.id;
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (thumbnailCache.has(materialId)) {
|
||||||
|
const cachedUrl = thumbnailCache.get(materialId);
|
||||||
|
setThumbnailUrl(cachedUrl || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载缩略图
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('获取素材缩略图:', materialId);
|
||||||
|
const dataUrl = await invoke<string>('get_material_thumbnail_base64', {
|
||||||
|
materialId: materialId
|
||||||
|
});
|
||||||
|
console.log('获取缩略图成功');
|
||||||
|
setThumbnailUrl(dataUrl);
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
const newCache = new Map(thumbnailCache);
|
||||||
|
newCache.set(materialId, dataUrl);
|
||||||
|
setThumbnailCache(newCache);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取缩略图失败:', error);
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadThumbnail();
|
||||||
|
}, [isVisible, material.id, material.material_type, thumbnailCache, setThumbnailCache]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={elementRef}
|
||||||
|
className={`${getSizeClasses()} bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className={`${size === 'small' ? 'w-3 h-3' : size === 'large' ? 'w-6 h-6' : 'w-4 h-4'} animate-spin text-blue-600`} />
|
||||||
|
) : thumbnailUrl && !error ? (
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={`${material.name} 缩略图`}
|
||||||
|
className="w-full h-full object-cover rounded-lg"
|
||||||
|
onError={() => {
|
||||||
|
setError(true);
|
||||||
|
setThumbnailUrl(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : isVisible ? (
|
||||||
|
getTypeIcon()
|
||||||
|
) : (
|
||||||
|
// 未加载时显示占位符
|
||||||
|
<div className={`${size === 'small' ? 'w-6 h-6' : size === 'large' ? 'w-12 h-12' : 'w-8 h-8'} bg-gray-200 rounded flex items-center justify-center`}>
|
||||||
|
<div className={`${size === 'small' ? 'w-3 h-3' : size === 'large' ? 'w-6 h-6' : 'w-4 h-4'} bg-gray-300 rounded`}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { EmptyState } from './EmptyState';
|
|||||||
import { LoadingSpinner } from './LoadingSpinner';
|
import { LoadingSpinner } from './LoadingSpinner';
|
||||||
import { ErrorMessage } from './ErrorMessage';
|
import { ErrorMessage } from './ErrorMessage';
|
||||||
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
||||||
|
|
||||||
import { PageLoadingSkeleton } from './SkeletonLoader';
|
import { PageLoadingSkeleton } from './SkeletonLoader';
|
||||||
import { InteractiveButton } from './InteractiveButton';
|
import { InteractiveButton } from './InteractiveButton';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface Material {
|
|||||||
scene_detection?: SceneDetection;
|
scene_detection?: SceneDetection;
|
||||||
segments: MaterialSegment[];
|
segments: MaterialSegment[];
|
||||||
model_id?: string; // 关联的模特ID
|
model_id?: string; // 关联的模特ID
|
||||||
|
thumbnail_path?: string; // 素材缩略图路径
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
processed_at?: string;
|
processed_at?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user