feat: 优化项目详情页布局为上下布局

布局优化:
- 从左右布局改为上下布局,提升用户体验
- 统计信息移至顶部,采用卡片式设计
- 选项卡导航采用现代化设计风格

 响应式设计:
- 统计卡片支持2-4列自适应布局
- 选项卡在小屏幕上显示简化文本
- 操作按钮在移动端仅显示图标
- 素材网格支持1-5列响应式布局

 用户体验提升:
- 更好的信息层次结构
- 更高效的空间利用
- 移动端友好的交互设计
- 符合现代UI设计规范

遵循promptx/frontend-developer开发规范,确保:
- 移动优先的响应式设计
- 良好的可访问性支持
- 一致的设计语言
- 优秀的性能表现
This commit is contained in:
imeepos
2025-07-14 14:26:16 +08:00
parent 4b3b00bcfc
commit 4b26c0406c
8 changed files with 1620 additions and 163 deletions

View File

@@ -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))
}
}

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -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| {
// 初始化日志系统

View File

@@ -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))
}

View File

@@ -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;

View 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>
);
};

View File

@@ -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,61 +142,62 @@ 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 ${
<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'
@@ -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>