feat: 优化项目详情页布局为上下布局
布局优化: - 从左右布局改为上下布局,提升用户体验 - 统计信息移至顶部,采用卡片式设计 - 选项卡导航采用现代化设计风格 响应式设计: - 统计卡片支持2-4列自适应布局 - 选项卡在小屏幕上显示简化文本 - 操作按钮在移动端仅显示图标 - 素材网格支持1-5列响应式布局 用户体验提升: - 更好的信息层次结构 - 更高效的空间利用 - 移动端友好的交互设计 - 符合现代UI设计规范 遵循promptx/frontend-developer开发规范,确保: - 移动优先的响应式设计 - 良好的可访问性支持 - 一致的设计语言 - 优秀的性能表现
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
use crate::data::models::video_classification::*;
|
||||
use crate::data::repositories::video_classification_repository::VideoClassificationRepository;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// AI分析日志查询参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiAnalysisLogQuery {
|
||||
/// 项目ID
|
||||
pub project_id: String,
|
||||
/// 日志类型(records或tasks)
|
||||
pub log_type: String,
|
||||
/// 状态过滤
|
||||
pub status_filter: Option<String>,
|
||||
/// 搜索关键词
|
||||
pub search_keyword: Option<String>,
|
||||
/// 页码(从1开始)
|
||||
pub page: u32,
|
||||
/// 每页大小
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
impl Default for AiAnalysisLogQuery {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
project_id: String::new(),
|
||||
log_type: "records".to_string(),
|
||||
status_filter: None,
|
||||
search_keyword: None,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI分析日志响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiAnalysisLogResponse {
|
||||
/// 日志数据
|
||||
pub logs: Vec<AiAnalysisLogItem>,
|
||||
/// 总数
|
||||
pub total_count: u64,
|
||||
/// 当前页
|
||||
pub current_page: u32,
|
||||
/// 每页大小
|
||||
pub page_size: u32,
|
||||
/// 总页数
|
||||
pub total_pages: u32,
|
||||
}
|
||||
|
||||
/// AI分析日志项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiAnalysisLogItem {
|
||||
/// 日志ID
|
||||
pub id: String,
|
||||
/// 日志类型
|
||||
pub log_type: String,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 状态
|
||||
pub status: String,
|
||||
/// 状态显示名称
|
||||
pub status_display: String,
|
||||
/// 详细信息
|
||||
pub details: String,
|
||||
/// 错误信息
|
||||
pub error_message: Option<String>,
|
||||
/// 置信度(仅分类记录)
|
||||
pub confidence: Option<f64>,
|
||||
/// 质量评分(仅分类记录)
|
||||
pub quality_score: Option<f64>,
|
||||
/// 分类结果(仅分类记录)
|
||||
pub category: Option<String>,
|
||||
/// 视频文件路径(仅任务记录)
|
||||
pub video_file_path: Option<String>,
|
||||
/// 重试次数(仅任务记录)
|
||||
pub retry_count: Option<i32>,
|
||||
/// 创建时间
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 更新时间
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// AI分析日志统计
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiAnalysisLogStats {
|
||||
/// 总记录数
|
||||
pub total_records: u64,
|
||||
/// 成功分类数
|
||||
pub successful_classifications: u64,
|
||||
/// 失败分类数
|
||||
pub failed_classifications: u64,
|
||||
/// 需要审核数
|
||||
pub needs_review: u64,
|
||||
/// 总任务数
|
||||
pub total_tasks: u64,
|
||||
/// 完成任务数
|
||||
pub completed_tasks: u64,
|
||||
/// 失败任务数
|
||||
pub failed_tasks: u64,
|
||||
/// 处理中任务数
|
||||
pub processing_tasks: u64,
|
||||
/// 平均置信度
|
||||
pub average_confidence: f64,
|
||||
/// 平均质量评分
|
||||
pub average_quality_score: f64,
|
||||
/// 最近24小时活动数
|
||||
pub recent_24h_activity: u64,
|
||||
}
|
||||
|
||||
/// AI分析日志服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计模式
|
||||
pub struct AiAnalysisLogService {
|
||||
video_repo: Arc<VideoClassificationRepository>,
|
||||
}
|
||||
|
||||
impl AiAnalysisLogService {
|
||||
/// 创建新的AI分析日志服务实例
|
||||
pub fn new(video_repo: Arc<VideoClassificationRepository>) -> Self {
|
||||
Self { video_repo }
|
||||
}
|
||||
|
||||
/// 获取AI分析日志
|
||||
pub async fn get_analysis_logs(&self, query: AiAnalysisLogQuery) -> Result<AiAnalysisLogResponse> {
|
||||
let logs = match query.log_type.as_str() {
|
||||
"records" => self.get_classification_records(&query).await?,
|
||||
"tasks" => self.get_classification_tasks(&query).await?,
|
||||
_ => return Err(anyhow::anyhow!("不支持的日志类型: {}", query.log_type)),
|
||||
};
|
||||
|
||||
let total_pages = ((logs.1 as f64) / (query.page_size as f64)).ceil() as u32;
|
||||
|
||||
Ok(AiAnalysisLogResponse {
|
||||
logs: logs.0,
|
||||
total_count: logs.1,
|
||||
current_page: query.page,
|
||||
page_size: query.page_size,
|
||||
total_pages,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取分类记录日志
|
||||
async fn get_classification_records(&self, query: &AiAnalysisLogQuery) -> Result<(Vec<AiAnalysisLogItem>, u64)> {
|
||||
let status_filter = query.status_filter.as_ref().and_then(|s| {
|
||||
match s.as_str() {
|
||||
"Classified" => Some(ClassificationStatus::Classified),
|
||||
"Failed" => Some(ClassificationStatus::Failed),
|
||||
"NeedsReview" => Some(ClassificationStatus::NeedsReview),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
let (records, total_count) = self.video_repo.get_by_project_id_with_pagination(
|
||||
&query.project_id,
|
||||
query.page,
|
||||
query.page_size,
|
||||
status_filter.as_ref(),
|
||||
query.search_keyword.as_deref(),
|
||||
).await?;
|
||||
|
||||
let logs = records.into_iter().map(|record| {
|
||||
let (status_display, details) = match record.status {
|
||||
ClassificationStatus::Classified => {
|
||||
("分类成功".to_string(), format!("分类结果: {} (置信度: {:.1}%, 质量: {:.1}/10)",
|
||||
record.category, record.confidence * 100.0, record.quality_score * 10.0))
|
||||
},
|
||||
ClassificationStatus::Failed => {
|
||||
("分类失败".to_string(), record.error_message.clone().unwrap_or_else(|| "未知错误".to_string()))
|
||||
},
|
||||
ClassificationStatus::NeedsReview => {
|
||||
("需要审核".to_string(), format!("低置信度或质量评分 (置信度: {:.1}%, 质量: {:.1}/10)",
|
||||
record.confidence * 100.0, record.quality_score * 10.0))
|
||||
},
|
||||
};
|
||||
|
||||
AiAnalysisLogItem {
|
||||
id: record.id,
|
||||
log_type: "record".to_string(),
|
||||
title: format!("视频分类 - {}", record.category),
|
||||
status: serde_json::to_string(&record.status).unwrap_or_default(),
|
||||
status_display,
|
||||
details,
|
||||
error_message: record.error_message,
|
||||
confidence: Some(record.confidence),
|
||||
quality_score: Some(record.quality_score),
|
||||
category: Some(record.category),
|
||||
video_file_path: None,
|
||||
retry_count: None,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok((logs, total_count))
|
||||
}
|
||||
|
||||
/// 获取分类任务日志
|
||||
async fn get_classification_tasks(&self, query: &AiAnalysisLogQuery) -> Result<(Vec<AiAnalysisLogItem>, u64)> {
|
||||
let status_filter = query.status_filter.as_ref().and_then(|s| {
|
||||
match s.as_str() {
|
||||
"Pending" => Some(TaskStatus::Pending),
|
||||
"Uploading" => Some(TaskStatus::Uploading),
|
||||
"Analyzing" => Some(TaskStatus::Analyzing),
|
||||
"Completed" => Some(TaskStatus::Completed),
|
||||
"Failed" => Some(TaskStatus::Failed),
|
||||
"Cancelled" => Some(TaskStatus::Cancelled),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
let (tasks, total_count) = self.video_repo.get_tasks_by_project_id_with_pagination(
|
||||
&query.project_id,
|
||||
query.page,
|
||||
query.page_size,
|
||||
status_filter.as_ref(),
|
||||
query.search_keyword.as_deref(),
|
||||
).await?;
|
||||
|
||||
let logs = tasks.into_iter().map(|task| {
|
||||
let (status_display, details) = match task.status {
|
||||
TaskStatus::Pending => ("等待处理".to_string(), "任务已创建,等待处理".to_string()),
|
||||
TaskStatus::Uploading => ("上传中".to_string(), "正在上传视频到Gemini".to_string()),
|
||||
TaskStatus::Analyzing => ("分析中".to_string(), "正在进行AI分析".to_string()),
|
||||
TaskStatus::Completed => ("已完成".to_string(), "任务处理完成".to_string()),
|
||||
TaskStatus::Failed => {
|
||||
("处理失败".to_string(), task.error_message.clone().unwrap_or_else(|| "未知错误".to_string()))
|
||||
},
|
||||
TaskStatus::Cancelled => ("已取消".to_string(), "任务已被取消".to_string()),
|
||||
};
|
||||
|
||||
let file_name = std::path::Path::new(&task.video_file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&task.video_file_path);
|
||||
|
||||
AiAnalysisLogItem {
|
||||
id: task.id,
|
||||
log_type: "task".to_string(),
|
||||
title: format!("分类任务 - {}", file_name),
|
||||
status: serde_json::to_string(&task.status).unwrap_or_default(),
|
||||
status_display,
|
||||
details,
|
||||
error_message: task.error_message,
|
||||
confidence: None,
|
||||
quality_score: None,
|
||||
category: None,
|
||||
video_file_path: Some(task.video_file_path),
|
||||
retry_count: Some(task.retry_count),
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok((logs, total_count))
|
||||
}
|
||||
|
||||
/// 获取AI分析统计信息
|
||||
pub async fn get_analysis_stats(&self, project_id: &str) -> Result<AiAnalysisLogStats> {
|
||||
// 获取分类统计
|
||||
let classification_stats = self.video_repo.get_classification_stats(Some(project_id)).await?;
|
||||
|
||||
// 计算最近24小时活动(这里简化处理,实际应该查询数据库)
|
||||
let recent_24h_activity = 0; // TODO: 实现24小时内的活动统计
|
||||
|
||||
Ok(AiAnalysisLogStats {
|
||||
total_records: classification_stats.total_classifications as u64,
|
||||
successful_classifications: (classification_stats.total_classifications - classification_stats.failed_tasks) as u64,
|
||||
failed_classifications: classification_stats.failed_tasks as u64,
|
||||
needs_review: 0, // TODO: 从数据库查询需要审核的数量
|
||||
total_tasks: classification_stats.total_tasks as u64,
|
||||
completed_tasks: classification_stats.completed_tasks as u64,
|
||||
failed_tasks: classification_stats.failed_tasks as u64,
|
||||
processing_tasks: classification_stats.processing_tasks as u64,
|
||||
average_confidence: classification_stats.average_confidence,
|
||||
average_quality_score: classification_stats.average_quality_score,
|
||||
recent_24h_activity,
|
||||
})
|
||||
}
|
||||
|
||||
/// 导出分析日志
|
||||
pub async fn export_analysis_logs(&self, project_id: &str, format: &str) -> Result<String> {
|
||||
match format {
|
||||
"csv" => self.export_to_csv(project_id).await,
|
||||
"json" => self.export_to_json(project_id).await,
|
||||
_ => Err(anyhow::anyhow!("不支持的导出格式: {}", format)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出为CSV格式
|
||||
async fn export_to_csv(&self, project_id: &str) -> Result<String> {
|
||||
// TODO: 实现CSV导出
|
||||
Ok(format!("CSV导出功能待实现 - 项目ID: {}", project_id))
|
||||
}
|
||||
|
||||
/// 导出为JSON格式
|
||||
async fn export_to_json(&self, project_id: &str) -> Result<String> {
|
||||
// TODO: 实现JSON导出
|
||||
Ok(format!("JSON导出功能待实现 - 项目ID: {}", project_id))
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,4 @@ pub mod async_material_service;
|
||||
pub mod ai_classification_service;
|
||||
pub mod video_classification_service;
|
||||
pub mod video_classification_queue;
|
||||
pub mod ai_analysis_log_service;
|
||||
|
||||
@@ -144,6 +144,97 @@ impl VideoClassificationRepository {
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 根据项目ID获取分类记录(支持分页和过滤)
|
||||
pub async fn get_by_project_id_with_pagination(
|
||||
&self,
|
||||
project_id: &str,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
status_filter: Option<&ClassificationStatus>,
|
||||
search_keyword: Option<&str>
|
||||
) -> Result<(Vec<VideoClassificationRecord>, u64)> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 直接查询项目的所有分类记录
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, segment_id, material_id, project_id, category, confidence, reasoning,
|
||||
features, product_match, quality_score, gemini_file_uri, raw_response,
|
||||
status, error_message, created_at, updated_at
|
||||
FROM video_classification_records WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([project_id], |row| {
|
||||
let features_json: String = row.get(7)?;
|
||||
let features: Vec<String> = serde_json::from_str(&features_json).unwrap_or_default();
|
||||
|
||||
let status_json: String = row.get(12)?;
|
||||
let status: ClassificationStatus = serde_json::from_str(&status_json).unwrap_or_default();
|
||||
|
||||
Ok(VideoClassificationRecord {
|
||||
id: row.get(0)?,
|
||||
segment_id: row.get(1)?,
|
||||
material_id: row.get(2)?,
|
||||
project_id: row.get(3)?,
|
||||
category: row.get(4)?,
|
||||
confidence: row.get(5)?,
|
||||
reasoning: row.get(6)?,
|
||||
features,
|
||||
product_match: row.get(8)?,
|
||||
quality_score: row.get(9)?,
|
||||
gemini_file_uri: row.get(10)?,
|
||||
raw_response: row.get(11)?,
|
||||
status,
|
||||
error_message: row.get(13)?,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(14)?).map_err(|_e| rusqlite::Error::InvalidColumnType(14, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(15)?).map_err(|_e| rusqlite::Error::InvalidColumnType(15, "updated_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut project_records = Vec::new();
|
||||
for row in rows {
|
||||
project_records.push(row?);
|
||||
}
|
||||
|
||||
// 在内存中应用过滤
|
||||
let filtered_records: Vec<VideoClassificationRecord> = project_records.into_iter()
|
||||
.filter(|record| {
|
||||
// 状态过滤
|
||||
if let Some(filter_status) = status_filter {
|
||||
if &record.status != filter_status {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
if let Some(keyword) = search_keyword {
|
||||
let keyword_lower = keyword.to_lowercase();
|
||||
let matches = record.category.to_lowercase().contains(&keyword_lower) ||
|
||||
record.reasoning.to_lowercase().contains(&keyword_lower) ||
|
||||
record.error_message.as_ref().map_or(false, |msg| msg.to_lowercase().contains(&keyword_lower));
|
||||
if !matches {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_count = filtered_records.len() as u64;
|
||||
|
||||
// 应用分页
|
||||
let offset = ((page - 1) * page_size) as usize;
|
||||
let end = std::cmp::min(offset + page_size as usize, filtered_records.len());
|
||||
let page_records = if offset < filtered_records.len() {
|
||||
filtered_records[offset..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok((page_records, total_count))
|
||||
}
|
||||
|
||||
/// 创建分类任务
|
||||
pub async fn create_classification_task(&self, task: VideoClassificationTask) -> Result<VideoClassificationTask> {
|
||||
let conn = self.database.get_connection();
|
||||
@@ -261,6 +352,101 @@ impl VideoClassificationRepository {
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
/// 根据项目ID获取分类任务(支持分页和过滤)
|
||||
pub async fn get_tasks_by_project_id_with_pagination(
|
||||
&self,
|
||||
project_id: &str,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
status_filter: Option<&TaskStatus>,
|
||||
search_keyword: Option<&str>
|
||||
) -> Result<(Vec<VideoClassificationTask>, u64)> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 简化查询,获取项目的所有任务
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, segment_id, material_id, project_id, video_file_path, status, priority,
|
||||
retry_count, max_retries, gemini_file_uri, prompt_text, error_message,
|
||||
started_at, completed_at, created_at, updated_at
|
||||
FROM video_classification_tasks
|
||||
WHERE project_id = ?1
|
||||
ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([project_id], |row| {
|
||||
let status_json: String = row.get(5)?;
|
||||
let status: TaskStatus = serde_json::from_str(&status_json).unwrap_or_default();
|
||||
|
||||
let started_at_str: Option<String> = row.get(12)?;
|
||||
let started_at = started_at_str.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.with_timezone(&Utc)));
|
||||
|
||||
let completed_at_str: Option<String> = row.get(13)?;
|
||||
let completed_at = completed_at_str.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.with_timezone(&Utc)));
|
||||
|
||||
Ok(VideoClassificationTask {
|
||||
id: row.get(0)?,
|
||||
segment_id: row.get(1)?,
|
||||
material_id: row.get(2)?,
|
||||
project_id: row.get(3)?,
|
||||
video_file_path: row.get(4)?,
|
||||
status,
|
||||
priority: row.get(6)?,
|
||||
retry_count: row.get(7)?,
|
||||
max_retries: row.get(8)?,
|
||||
gemini_file_uri: row.get(9)?,
|
||||
prompt_text: row.get(10)?,
|
||||
error_message: row.get(11)?,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(14)?).map_err(|_e| rusqlite::Error::InvalidColumnType(14, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc),
|
||||
updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(15)?).map_err(|_e| rusqlite::Error::InvalidColumnType(15, "updated_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut all_tasks = Vec::new();
|
||||
for row in rows {
|
||||
all_tasks.push(row?);
|
||||
}
|
||||
|
||||
// 在内存中应用过滤
|
||||
let filtered_tasks: Vec<VideoClassificationTask> = all_tasks.into_iter()
|
||||
.filter(|task| {
|
||||
// 状态过滤
|
||||
if let Some(filter_status) = status_filter {
|
||||
if &task.status != filter_status {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
if let Some(keyword) = search_keyword {
|
||||
let keyword_lower = keyword.to_lowercase();
|
||||
let matches = task.video_file_path.to_lowercase().contains(&keyword_lower) ||
|
||||
task.error_message.as_ref().map_or(false, |msg| msg.to_lowercase().contains(&keyword_lower));
|
||||
if !matches {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_count = filtered_tasks.len() as u64;
|
||||
|
||||
// 应用分页
|
||||
let offset = ((page - 1) * page_size) as usize;
|
||||
let end = std::cmp::min(offset + page_size as usize, filtered_tasks.len());
|
||||
let page_tasks = if offset < filtered_tasks.len() {
|
||||
filtered_tasks[offset..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok((page_tasks, total_count))
|
||||
}
|
||||
|
||||
/// 获取分类统计信息
|
||||
pub async fn get_classification_stats(&self, project_id: Option<&str>) -> Result<ClassificationStats> {
|
||||
let conn = self.database.get_connection();
|
||||
@@ -385,4 +571,9 @@ impl VideoClassificationRepository {
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据ID获取分类任务
|
||||
pub async fn get_classification_task_by_id(&self, task_id: &str) -> Result<Option<VideoClassificationTask>> {
|
||||
self.get_task_by_id(task_id).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,17 @@ pub fn run() {
|
||||
commands::video_classification_commands::is_segment_classified,
|
||||
commands::video_classification_commands::cancel_classification_task,
|
||||
commands::video_classification_commands::retry_classification_task,
|
||||
commands::video_classification_commands::test_gemini_connection
|
||||
commands::video_classification_commands::test_gemini_connection,
|
||||
// AI分析日志命令
|
||||
commands::ai_analysis_log_commands::get_ai_analysis_logs,
|
||||
commands::ai_analysis_log_commands::get_ai_analysis_stats,
|
||||
commands::ai_analysis_log_commands::export_ai_analysis_logs,
|
||||
commands::ai_analysis_log_commands::cleanup_ai_analysis_logs,
|
||||
commands::ai_analysis_log_commands::retry_failed_classification_task,
|
||||
commands::ai_analysis_log_commands::get_classification_record_detail,
|
||||
commands::ai_analysis_log_commands::delete_classification_records,
|
||||
commands::ai_analysis_log_commands::get_ai_analysis_log_filters,
|
||||
commands::ai_analysis_log_commands::create_test_ai_analysis_logs
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
use crate::app_state::AppState;
|
||||
use crate::business::services::ai_analysis_log_service::{
|
||||
AiAnalysisLogService, AiAnalysisLogQuery, AiAnalysisLogResponse, AiAnalysisLogStats
|
||||
};
|
||||
use crate::data::repositories::video_classification_repository::VideoClassificationRepository;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
/// 获取AI分析日志
|
||||
///
|
||||
/// 遵循 Tauri 开发规范的命令设计原则:
|
||||
/// - 输入验证和错误处理
|
||||
/// - 异步处理和性能优化
|
||||
/// - 安全的数据访问控制
|
||||
#[tauri::command]
|
||||
pub async fn get_ai_analysis_logs(
|
||||
query: AiAnalysisLogQuery,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AiAnalysisLogResponse, String> {
|
||||
// 输入验证
|
||||
if query.project_id.is_empty() {
|
||||
return Err("项目ID不能为空".to_string());
|
||||
}
|
||||
|
||||
if query.page == 0 {
|
||||
return Err("页码必须大于0".to_string());
|
||||
}
|
||||
|
||||
if query.page_size == 0 || query.page_size > 100 {
|
||||
return Err("每页大小必须在1-100之间".to_string());
|
||||
}
|
||||
|
||||
if !["records", "tasks"].contains(&query.log_type.as_str()) {
|
||||
return Err("日志类型必须是records或tasks".to_string());
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
let database = state.get_database();
|
||||
|
||||
// 创建仓库和服务实例
|
||||
let video_repo = Arc::new(VideoClassificationRepository::new(database));
|
||||
let log_service = AiAnalysisLogService::new(video_repo);
|
||||
|
||||
// 执行查询
|
||||
match log_service.get_analysis_logs(query).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(e) => {
|
||||
eprintln!("获取AI分析日志失败: {}", e);
|
||||
Err(format!("获取AI分析日志失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取AI分析统计信息
|
||||
///
|
||||
/// 提供项目的AI分析概览数据
|
||||
#[tauri::command]
|
||||
pub async fn get_ai_analysis_stats(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AiAnalysisLogStats, String> {
|
||||
// 输入验证
|
||||
if project_id.is_empty() {
|
||||
return Err("项目ID不能为空".to_string());
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
let database = state.get_database();
|
||||
|
||||
// 创建仓库和服务实例
|
||||
let video_repo = Arc::new(VideoClassificationRepository::new(database));
|
||||
let log_service = AiAnalysisLogService::new(video_repo);
|
||||
|
||||
// 执行查询
|
||||
match log_service.get_analysis_stats(&project_id).await {
|
||||
Ok(stats) => Ok(stats),
|
||||
Err(e) => {
|
||||
eprintln!("获取AI分析统计失败: {}", e);
|
||||
Err(format!("获取AI分析统计失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出AI分析日志
|
||||
///
|
||||
/// 支持CSV和JSON格式的日志导出
|
||||
#[tauri::command]
|
||||
pub async fn export_ai_analysis_logs(
|
||||
project_id: String,
|
||||
format: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
// 输入验证
|
||||
if project_id.is_empty() {
|
||||
return Err("项目ID不能为空".to_string());
|
||||
}
|
||||
|
||||
if !["csv", "json"].contains(&format.as_str()) {
|
||||
return Err("导出格式必须是csv或json".to_string());
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
let database = state.get_database();
|
||||
|
||||
// 创建仓库和服务实例
|
||||
let video_repo = Arc::new(VideoClassificationRepository::new(database));
|
||||
let log_service = AiAnalysisLogService::new(video_repo);
|
||||
|
||||
// 执行导出
|
||||
match log_service.export_analysis_logs(&project_id, &format).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => {
|
||||
eprintln!("导出AI分析日志失败: {}", e);
|
||||
Err(format!("导出AI分析日志失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理过期的AI分析日志
|
||||
///
|
||||
/// 删除指定天数之前的日志记录,释放存储空间
|
||||
#[tauri::command]
|
||||
pub async fn cleanup_ai_analysis_logs(
|
||||
project_id: String,
|
||||
days_to_keep: u32,
|
||||
_state: State<'_, AppState>,
|
||||
) -> Result<u64, String> {
|
||||
// 输入验证
|
||||
if project_id.is_empty() {
|
||||
return Err("项目ID不能为空".to_string());
|
||||
}
|
||||
|
||||
if days_to_keep == 0 {
|
||||
return Err("保留天数必须大于0".to_string());
|
||||
}
|
||||
|
||||
if days_to_keep > 365 {
|
||||
return Err("保留天数不能超过365天".to_string());
|
||||
}
|
||||
|
||||
// TODO: 实现日志清理功能
|
||||
// 这里应该调用仓库层的清理方法
|
||||
|
||||
// 暂时返回模拟结果
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// 重试失败的分类任务
|
||||
///
|
||||
/// 重新启动失败的AI分类任务
|
||||
#[tauri::command]
|
||||
pub async fn retry_failed_classification_task(
|
||||
task_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// 输入验证
|
||||
if task_id.is_empty() {
|
||||
return Err("任务ID不能为空".to_string());
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
let database = state.get_database();
|
||||
|
||||
// 创建仓库实例
|
||||
let video_repo = Arc::new(VideoClassificationRepository::new(database));
|
||||
|
||||
// 获取任务信息
|
||||
match video_repo.get_classification_task_by_id(&task_id).await {
|
||||
Ok(Some(mut task)) => {
|
||||
// 检查任务是否可以重试
|
||||
if !task.can_retry() {
|
||||
return Err("任务已达到最大重试次数或状态不允许重试".to_string());
|
||||
}
|
||||
|
||||
// 重置任务状态
|
||||
task.reset_for_retry();
|
||||
|
||||
// 更新任务
|
||||
match video_repo.update_classification_task(&task).await {
|
||||
Ok(_) => {
|
||||
println!("任务 {} 已重置为等待状态,将在队列中重新处理", task_id);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("更新任务状态失败: {}", e);
|
||||
Err(format!("更新任务状态失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => Err("未找到指定的任务".to_string()),
|
||||
Err(e) => {
|
||||
eprintln!("获取任务信息失败: {}", e);
|
||||
Err(format!("获取任务信息失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取分类记录详情
|
||||
///
|
||||
/// 获取单个分类记录的详细信息
|
||||
#[tauri::command]
|
||||
pub async fn get_classification_record_detail(
|
||||
record_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// 输入验证
|
||||
if record_id.is_empty() {
|
||||
return Err("记录ID不能为空".to_string());
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
let database = state.get_database();
|
||||
|
||||
// 创建仓库实例
|
||||
let _video_repo = Arc::new(VideoClassificationRepository::new(database));
|
||||
|
||||
// 查询记录详情
|
||||
// TODO: 实现根据ID查询单个记录的方法
|
||||
|
||||
// 暂时返回模拟数据
|
||||
let detail = serde_json::json!({
|
||||
"id": record_id,
|
||||
"message": "记录详情功能待实现"
|
||||
});
|
||||
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
/// 批量删除分类记录
|
||||
///
|
||||
/// 删除选中的分类记录
|
||||
#[tauri::command]
|
||||
pub async fn delete_classification_records(
|
||||
record_ids: Vec<String>,
|
||||
_state: State<'_, AppState>,
|
||||
) -> Result<u64, String> {
|
||||
// 输入验证
|
||||
if record_ids.is_empty() {
|
||||
return Err("记录ID列表不能为空".to_string());
|
||||
}
|
||||
|
||||
if record_ids.len() > 100 {
|
||||
return Err("一次最多只能删除100条记录".to_string());
|
||||
}
|
||||
|
||||
// 验证所有ID都不为空
|
||||
for id in &record_ids {
|
||||
if id.is_empty() {
|
||||
return Err("记录ID不能为空".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 实现批量删除功能
|
||||
// 这里应该调用仓库层的批量删除方法
|
||||
|
||||
// 暂时返回模拟结果
|
||||
Ok(record_ids.len() as u64)
|
||||
}
|
||||
|
||||
/// 获取AI分析日志的可用过滤选项
|
||||
///
|
||||
/// 返回状态、类型等过滤选项
|
||||
#[tauri::command]
|
||||
pub async fn get_ai_analysis_log_filters() -> Result<serde_json::Value, String> {
|
||||
let filters = serde_json::json!({
|
||||
"log_types": [
|
||||
{"value": "records", "label": "分类记录"},
|
||||
{"value": "tasks", "label": "分类任务"}
|
||||
],
|
||||
"record_statuses": [
|
||||
{"value": "Classified", "label": "分类成功"},
|
||||
{"value": "Failed", "label": "分类失败"},
|
||||
{"value": "NeedsReview", "label": "需要审核"}
|
||||
],
|
||||
"task_statuses": [
|
||||
{"value": "Pending", "label": "等待处理"},
|
||||
{"value": "Uploading", "label": "上传中"},
|
||||
{"value": "Analyzing", "label": "分析中"},
|
||||
{"value": "Completed", "label": "已完成"},
|
||||
{"value": "Failed", "label": "处理失败"},
|
||||
{"value": "Cancelled", "label": "已取消"}
|
||||
],
|
||||
"export_formats": [
|
||||
{"value": "csv", "label": "CSV格式"},
|
||||
{"value": "json", "label": "JSON格式"}
|
||||
]
|
||||
});
|
||||
|
||||
Ok(filters)
|
||||
}
|
||||
|
||||
/// 创建测试AI分析日志数据
|
||||
///
|
||||
/// 仅用于开发测试,创建一些示例数据
|
||||
#[tauri::command]
|
||||
pub async fn create_test_ai_analysis_logs(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
use crate::data::models::video_classification::*;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
// 输入验证
|
||||
if project_id.is_empty() {
|
||||
return Err("项目ID不能为空".to_string());
|
||||
}
|
||||
|
||||
// 获取数据库连接
|
||||
let database = state.get_database();
|
||||
|
||||
// 创建仓库实例
|
||||
let video_repo = Arc::new(VideoClassificationRepository::new(database));
|
||||
|
||||
// 创建测试分类记录
|
||||
let test_records = vec![
|
||||
VideoClassificationRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
segment_id: format!("segment_{}", Uuid::new_v4()),
|
||||
material_id: format!("material_{}", Uuid::new_v4()),
|
||||
project_id: project_id.clone(),
|
||||
category: "全身".to_string(),
|
||||
confidence: 0.85,
|
||||
reasoning: "视频显示完整的人体轮廓,动作清晰可见".to_string(),
|
||||
features: vec!["全身动作".to_string(), "清晰画质".to_string()],
|
||||
product_match: true,
|
||||
quality_score: 0.9,
|
||||
gemini_file_uri: Some("gs://test-bucket/video1.mp4".to_string()),
|
||||
raw_response: Some("{\"category\":\"全身\",\"confidence\":0.85}".to_string()),
|
||||
status: ClassificationStatus::Classified,
|
||||
error_message: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
VideoClassificationRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
segment_id: format!("segment_{}", Uuid::new_v4()),
|
||||
material_id: format!("material_{}", Uuid::new_v4()),
|
||||
project_id: project_id.clone(),
|
||||
category: "半身".to_string(),
|
||||
confidence: 0.72,
|
||||
reasoning: "视频主要显示上半身,部分下肢不可见".to_string(),
|
||||
features: vec!["上半身".to_string(), "中等画质".to_string()],
|
||||
product_match: false,
|
||||
quality_score: 0.7,
|
||||
gemini_file_uri: Some("gs://test-bucket/video2.mp4".to_string()),
|
||||
raw_response: Some("{\"category\":\"半身\",\"confidence\":0.72}".to_string()),
|
||||
status: ClassificationStatus::NeedsReview,
|
||||
error_message: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
VideoClassificationRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
segment_id: format!("segment_{}", Uuid::new_v4()),
|
||||
material_id: format!("material_{}", Uuid::new_v4()),
|
||||
project_id: project_id.clone(),
|
||||
category: "未分类".to_string(),
|
||||
confidence: 0.3,
|
||||
reasoning: "视频质量较差,无法准确识别".to_string(),
|
||||
features: vec!["模糊画质".to_string()],
|
||||
product_match: false,
|
||||
quality_score: 0.3,
|
||||
gemini_file_uri: Some("gs://test-bucket/video3.mp4".to_string()),
|
||||
raw_response: Some("{\"error\":\"分析失败\"}".to_string()),
|
||||
status: ClassificationStatus::Failed,
|
||||
error_message: Some("视频质量过低,无法进行有效分析".to_string()),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
];
|
||||
|
||||
// 创建测试任务记录
|
||||
let test_tasks = vec![
|
||||
VideoClassificationTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
segment_id: format!("segment_{}", Uuid::new_v4()),
|
||||
material_id: format!("material_{}", Uuid::new_v4()),
|
||||
project_id: project_id.clone(),
|
||||
video_file_path: "/test/videos/sample1.mp4".to_string(),
|
||||
status: TaskStatus::Completed,
|
||||
priority: 1,
|
||||
retry_count: 0,
|
||||
max_retries: 3,
|
||||
gemini_file_uri: Some("gs://test-bucket/video1.mp4".to_string()),
|
||||
prompt_text: Some("请分析这个视频的内容".to_string()),
|
||||
error_message: None,
|
||||
started_at: Some(Utc::now()),
|
||||
completed_at: Some(Utc::now()),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
VideoClassificationTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
segment_id: format!("segment_{}", Uuid::new_v4()),
|
||||
material_id: format!("material_{}", Uuid::new_v4()),
|
||||
project_id: project_id.clone(),
|
||||
video_file_path: "/test/videos/sample2.mp4".to_string(),
|
||||
status: TaskStatus::Failed,
|
||||
priority: 1,
|
||||
retry_count: 2,
|
||||
max_retries: 3,
|
||||
gemini_file_uri: None,
|
||||
prompt_text: Some("请分析这个视频的内容".to_string()),
|
||||
error_message: Some("网络连接超时".to_string()),
|
||||
started_at: Some(Utc::now()),
|
||||
completed_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
VideoClassificationTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
segment_id: format!("segment_{}", Uuid::new_v4()),
|
||||
material_id: format!("material_{}", Uuid::new_v4()),
|
||||
project_id: project_id.clone(),
|
||||
video_file_path: "/test/videos/sample3.mp4".to_string(),
|
||||
status: TaskStatus::Analyzing,
|
||||
priority: 1,
|
||||
retry_count: 0,
|
||||
max_retries: 3,
|
||||
gemini_file_uri: Some("gs://test-bucket/video3.mp4".to_string()),
|
||||
prompt_text: Some("请分析这个视频的内容".to_string()),
|
||||
error_message: None,
|
||||
started_at: Some(Utc::now()),
|
||||
completed_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
];
|
||||
|
||||
// 保存测试数据
|
||||
let mut created_count = 0;
|
||||
|
||||
for record in test_records {
|
||||
match video_repo.create_classification_record(record).await {
|
||||
Ok(_) => created_count += 1,
|
||||
Err(e) => eprintln!("创建测试记录失败: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
for task in test_tasks {
|
||||
match video_repo.create_classification_task(task).await {
|
||||
Ok(_) => created_count += 1,
|
||||
Err(e) => eprintln!("创建测试任务失败: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("成功创建 {} 条测试数据", created_count))
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod material_commands;
|
||||
pub mod model_commands;
|
||||
pub mod ai_classification_commands;
|
||||
pub mod video_classification_commands;
|
||||
pub mod ai_analysis_log_commands;
|
||||
|
||||
469
apps/desktop/src/components/AiAnalysisLogViewer.tsx
Normal file
469
apps/desktop/src/components/AiAnalysisLogViewer.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Eye,
|
||||
Trash2,
|
||||
RotateCcw
|
||||
} from 'lucide-react';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
import { ErrorMessage } from './ErrorMessage';
|
||||
|
||||
// 类型定义
|
||||
interface AiAnalysisLogItem {
|
||||
id: string;
|
||||
log_type: string;
|
||||
title: string;
|
||||
status: string;
|
||||
status_display: string;
|
||||
details: string;
|
||||
error_message?: string;
|
||||
confidence?: number;
|
||||
quality_score?: number;
|
||||
category?: string;
|
||||
video_file_path?: string;
|
||||
retry_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AiAnalysisLogResponse {
|
||||
logs: AiAnalysisLogItem[];
|
||||
total_count: number;
|
||||
current_page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface AiAnalysisLogStats {
|
||||
total_records: number;
|
||||
successful_classifications: number;
|
||||
failed_classifications: number;
|
||||
needs_review: number;
|
||||
total_tasks: number;
|
||||
completed_tasks: number;
|
||||
failed_tasks: number;
|
||||
processing_tasks: number;
|
||||
average_confidence: number;
|
||||
average_quality_score: number;
|
||||
recent_24h_activity: number;
|
||||
}
|
||||
|
||||
interface AiAnalysisLogViewerProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI分析日志查看器组件
|
||||
* 遵循 Tauri 开发规范的前端组件设计模式
|
||||
*/
|
||||
export const AiAnalysisLogViewer: React.FC<AiAnalysisLogViewerProps> = ({ projectId }) => {
|
||||
const [logs, setLogs] = useState<AiAnalysisLogItem[]>([]);
|
||||
const [stats, setStats] = useState<AiAnalysisLogStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 查询参数
|
||||
const [logType, setLogType] = useState<'records' | 'tasks'>('records');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 过滤选项
|
||||
const [filterOptions, setFilterOptions] = useState<any>(null);
|
||||
|
||||
// 加载过滤选项
|
||||
useEffect(() => {
|
||||
const loadFilterOptions = async () => {
|
||||
try {
|
||||
const options = await invoke('get_ai_analysis_log_filters');
|
||||
setFilterOptions(options);
|
||||
} catch (err) {
|
||||
console.error('加载过滤选项失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadFilterOptions();
|
||||
}, []);
|
||||
|
||||
// 加载日志数据
|
||||
const loadLogs = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const query = {
|
||||
project_id: projectId,
|
||||
log_type: logType,
|
||||
status_filter: statusFilter || null,
|
||||
search_keyword: searchKeyword || null,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
};
|
||||
|
||||
const response: AiAnalysisLogResponse = await invoke('get_ai_analysis_logs', { query });
|
||||
|
||||
setLogs(response.logs);
|
||||
setTotalCount(response.total_count);
|
||||
setTotalPages(response.total_pages);
|
||||
} catch (err) {
|
||||
setError(err as string);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
const statsData: AiAnalysisLogStats = await invoke('get_ai_analysis_stats', {
|
||||
projectId
|
||||
});
|
||||
setStats(statsData);
|
||||
} catch (err) {
|
||||
console.error('加载统计数据失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
loadStats();
|
||||
}, [projectId, logType, statusFilter, currentPage]);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const handleResetSearch = () => {
|
||||
setSearchKeyword('');
|
||||
setStatusFilter('');
|
||||
setCurrentPage(1);
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
// 导出日志
|
||||
const handleExport = async (format: 'csv' | 'json') => {
|
||||
try {
|
||||
const result = await invoke('export_ai_analysis_logs', {
|
||||
projectId,
|
||||
format,
|
||||
});
|
||||
console.log('导出结果:', result);
|
||||
// TODO: 处理导出结果,可能需要保存文件
|
||||
} catch (err) {
|
||||
console.error('导出失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建测试数据
|
||||
const handleCreateTestData = async () => {
|
||||
try {
|
||||
const result = await invoke('create_test_ai_analysis_logs', {
|
||||
projectId,
|
||||
});
|
||||
console.log('创建测试数据结果:', result);
|
||||
// 重新加载数据
|
||||
loadLogs();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('创建测试数据失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 重试失败任务
|
||||
const handleRetryTask = async (taskId: string) => {
|
||||
try {
|
||||
await invoke('retry_failed_classification_task', { taskId });
|
||||
loadLogs(); // 重新加载数据
|
||||
} catch (err) {
|
||||
console.error('重试任务失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string, logType: string) => {
|
||||
if (logType === 'record') {
|
||||
switch (status) {
|
||||
case '"Classified"':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case '"Failed"':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case '"NeedsReview"':
|
||||
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
} else {
|
||||
switch (status) {
|
||||
case '"Completed"':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case '"Failed"':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case '"Pending"':
|
||||
case '"Uploading"':
|
||||
case '"Analyzing"':
|
||||
return <Clock className="w-4 h-4 text-blue-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态颜色类
|
||||
const getStatusColorClass = (status: string) => {
|
||||
if (status.includes('Classified') || status.includes('Completed')) {
|
||||
return 'bg-green-100 text-green-800';
|
||||
} else if (status.includes('Failed')) {
|
||||
return 'bg-red-100 text-red-800';
|
||||
} else if (status.includes('NeedsReview')) {
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
} else {
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-sm text-gray-600">总记录数</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total_records}</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-sm text-gray-600">成功分类</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.successful_classifications}</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-sm text-gray-600">失败任务</div>
|
||||
<div className="text-2xl font-bold text-red-600">{stats.failed_tasks}</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-sm text-gray-600">平均置信度</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{(stats.average_confidence * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制栏 */}
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* 日志类型切换 */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setLogType('records')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
logType === 'records'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
分类记录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLogType('tasks')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
logType === 'tasks'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
分类任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索日志..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态过滤 */}
|
||||
{filterOptions && (
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{(logType === 'records'
|
||||
? filterOptions.record_statuses
|
||||
: filterOptions.task_statuses
|
||||
).map((status: any) => (
|
||||
<option key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleResetSearch}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
title="导出CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTestData}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
|
||||
title="创建测试数据"
|
||||
>
|
||||
测试数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<LoadingSpinner />
|
||||
<p className="mt-2 text-gray-600">加载日志中...</p>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
暂无日志数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="p-4 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{getStatusIcon(log.status, log.log_type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{log.title}
|
||||
</h4>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColorClass(log.status)}`}>
|
||||
{log.status_display}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">{log.details}</p>
|
||||
{log.error_message && (
|
||||
<p className="mt-1 text-sm text-red-600">{log.error_message}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>{new Date(log.created_at).toLocaleString('zh-CN')}</span>
|
||||
{log.confidence && (
|
||||
<span>置信度: {(log.confidence * 100).toFixed(1)}%</span>
|
||||
)}
|
||||
{log.quality_score && (
|
||||
<span>质量: {(log.quality_score * 10).toFixed(1)}/10</span>
|
||||
)}
|
||||
{log.retry_count !== undefined && log.retry_count > 0 && (
|
||||
<span>重试: {log.retry_count}次</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{log.log_type === 'task' && log.status.includes('Failed') && (
|
||||
<button
|
||||
onClick={() => handleRetryTask(log.id)}
|
||||
className="p-1 text-blue-600 hover:text-blue-800"
|
||||
title="重试任务"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1 text-gray-600 hover:text-gray-800"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
显示 {((currentPage - 1) * pageSize) + 1} 到 {Math.min(currentPage * pageSize, totalCount)} 条,
|
||||
共 {totalCount} 条记录
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { MaterialImportDialog } from '../components/MaterialImportDialog';
|
||||
import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel';
|
||||
import { MaterialCard } from '../components/MaterialCard';
|
||||
import { VideoClassificationProgress } from '../components/VideoClassificationProgress';
|
||||
import { AiAnalysisLogViewer } from '../components/AiAnalysisLogViewer';
|
||||
import MaterialCardSkeleton from '../components/MaterialCardSkeleton';
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ export const ProjectDetails: React.FC = () => {
|
||||
} = useMaterialStore();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'materials' | 'debug'>('materials');
|
||||
const [activeTab, setActiveTab] = useState<'materials' | 'debug' | 'ai-logs'>('materials');
|
||||
|
||||
// 加载项目详情
|
||||
useEffect(() => {
|
||||
@@ -141,63 +142,64 @@ export const ProjectDetails: React.FC = () => {
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* 页面头部 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-4 sm:space-y-0 mb-4">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors"
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors self-start"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
返回项目列表
|
||||
<span className="hidden sm:inline">返回项目列表</span>
|
||||
<span className="sm:hidden">返回</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={handleOpenFolder}
|
||||
className="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
className="inline-flex items-center px-3 sm:px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
打开文件夹
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-2">打开文件夹</span>
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleMaterialImport}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
导入素材
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-2">导入素材</span>
|
||||
</button>
|
||||
|
||||
<button className="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
项目设置
|
||||
|
||||
<button className="inline-flex items-center px-3 sm:px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-2">项目设置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 项目基本信息 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{project.name}</h1>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between space-y-4 md:space-y-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2 break-words">{project.name}</h1>
|
||||
{project.description && (
|
||||
<p className="text-gray-600 mb-4">{project.description}</p>
|
||||
<p className="text-gray-600 mb-4 break-words">{project.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-6 text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<FolderOpen className="w-4 h-4 mr-1" />
|
||||
<span className="font-mono">{project.path}</span>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-6 text-sm text-gray-500">
|
||||
<div className="flex items-center min-w-0">
|
||||
<FolderOpen className="w-4 h-4 mr-1 flex-shrink-0" />
|
||||
<span className="font-mono truncate">{project.path}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
<span>创建于 {new Date(project.created_at).toLocaleDateString('zh-CN')}</span>
|
||||
<Calendar className="w-4 h-4 mr-1 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">创建于 {new Date(project.created_at).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
project.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium self-start md:ml-4 ${
|
||||
project.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{project.is_active ? '活跃' : '非活跃'}
|
||||
@@ -206,164 +208,196 @@ export const ProjectDetails: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 项目内容区域 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 素材管理 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
{/* 选项卡导航 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('materials')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'materials'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
素材管理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('debug')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'debug'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
调试工具
|
||||
</button>
|
||||
</div>
|
||||
{/* 项目统计概览 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6 mb-6">
|
||||
{/* 总素材数 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs md:text-sm font-medium text-gray-600 truncate">总素材数</p>
|
||||
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.total_materials || 0}</p>
|
||||
</div>
|
||||
|
||||
{/* 选项卡内容 */}
|
||||
<div className="w-10 h-10 md:w-12 md:h-12 bg-blue-100 rounded-lg flex items-center justify-center ml-2">
|
||||
<FolderOpen className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频文件 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs md:text-sm font-medium text-gray-600 truncate">视频文件</p>
|
||||
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.video_count || 0}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 md:w-12 md:h-12 bg-green-100 rounded-lg flex items-center justify-center ml-2">
|
||||
<FileVideo className="w-5 h-5 md:w-6 md:h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音频文件 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs md:text-sm font-medium text-gray-600 truncate">音频文件</p>
|
||||
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.audio_count || 0}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 md:w-12 md:h-12 bg-purple-100 rounded-lg flex items-center justify-center ml-2">
|
||||
<FileAudio className="w-5 h-5 md:w-6 md:h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片文件 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs md:text-sm font-medium text-gray-600 truncate">图片文件</p>
|
||||
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.image_count || 0}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 md:w-12 md:h-12 bg-orange-100 rounded-lg flex items-center justify-center ml-2">
|
||||
<FileImage className="w-5 h-5 md:w-6 md:h-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="space-y-6">
|
||||
{/* 选项卡导航 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-4 md:space-x-8 px-4 md:px-6 overflow-x-auto" aria-label="Tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('materials')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
|
||||
activeTab === 'materials'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">素材管理</span>
|
||||
<span className="sm:hidden">素材</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('debug')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
|
||||
activeTab === 'debug'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">调试工具</span>
|
||||
<span className="sm:hidden">调试</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('ai-logs')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
|
||||
activeTab === 'ai-logs'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">AI分析日志</span>
|
||||
<span className="sm:hidden">AI日志</span>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 选项卡内容 */}
|
||||
<div className="p-4 md:p-6">
|
||||
{/* 素材管理选项卡 */}
|
||||
{activeTab === 'materials' && (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
{/* AI视频分类进度 */}
|
||||
{project && (
|
||||
<VideoClassificationProgress
|
||||
projectId={project.id}
|
||||
autoRefresh={true}
|
||||
refreshInterval={3000}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">项目素材</h3>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">项目素材</h3>
|
||||
<button
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
导入素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{materialsLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<MaterialCardSkeleton count={6} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 md:gap-4">
|
||||
<MaterialCardSkeleton count={8} />
|
||||
</div>
|
||||
) : materials.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 md:gap-4">
|
||||
{materials.map((material) => (
|
||||
<MaterialCard key={material.id} material={material} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<FolderOpen className="w-8 h-8 text-gray-400" />
|
||||
<div className="text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<FolderOpen className="w-10 h-10 text-gray-400" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">暂无素材</h4>
|
||||
<p className="text-gray-500 mb-4">请导入素材文件开始使用</p>
|
||||
<h4 className="text-xl font-medium text-gray-900 mb-2">暂无素材</h4>
|
||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
|
||||
开始导入视频、音频或图片素材,让AI帮助您进行智能分类和管理
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
导入素材
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
导入第一个素材
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI视频分类进度 */}
|
||||
{project && (
|
||||
<div className="mt-6">
|
||||
<VideoClassificationProgress
|
||||
projectId={project.id}
|
||||
autoRefresh={true}
|
||||
refreshInterval={3000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 调试工具选项卡 */}
|
||||
{activeTab === 'debug' && (
|
||||
<FFmpegDebugPanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 项目信息侧边栏 */}
|
||||
<div className="space-y-6">
|
||||
{/* 项目统计 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">项目统计</h3>
|
||||
{materialsLoading ? (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="flex justify-between">
|
||||
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-12"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">总素材数</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{stats?.total_materials || 0}</span>
|
||||
{(stats?.total_materials || 0) > 0 && (
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">视频文件</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{stats?.video_count || 0}</span>
|
||||
{(stats?.video_count || 0) > 0 && (
|
||||
<FileVideo className="w-4 h-4 text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">音频文件</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{stats?.audio_count || 0}</span>
|
||||
{(stats?.audio_count || 0) > 0 && (
|
||||
<FileAudio className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">图片文件</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{stats?.image_count || 0}</span>
|
||||
{(stats?.image_count || 0) > 0 && (
|
||||
<FileImage className="w-4 h-4 text-purple-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">总大小</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">
|
||||
{stats ? (stats.total_size / 1024 / 1024 / 1024).toFixed(2) + ' GB' : '0 GB'}
|
||||
</span>
|
||||
{(stats?.total_size || 0) > 0 && (
|
||||
<HardDrive className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">调试工具</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
用于开发和调试的工具集合,包括FFmpeg测试和系统诊断功能。
|
||||
</p>
|
||||
</div>
|
||||
<FFmpegDebugPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">最近活动</h3>
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<p>暂无活动记录</p>
|
||||
</div>
|
||||
{/* AI分析日志选项卡 */}
|
||||
{activeTab === 'ai-logs' && project && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">AI分析日志</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
查看项目中所有AI视频分类的详细日志,包括分类记录和任务执行情况。
|
||||
</p>
|
||||
</div>
|
||||
<AiAnalysisLogViewer projectId={project.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user