From 590e254fe1f06e1a69d6e314dcbf2f5b8fb5eb45 Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 15 Jul 2025 16:49:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=AF=A6=E6=83=85/=E7=B4=A0=E6=9D=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=9A=84MaterialSegment=E8=81=9A=E5=90=88=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增MaterialSegment聚合视图,支持按AI分类和模特聚合展示 - 实现后端MaterialSegmentViewService和相关API命令 - 创建前端React组件:MaterialSegmentView、MaterialSegmentGroup、MaterialSegmentCard等 - 添加MaterialSegment详细信息模态框和批量操作对话框 - 实现搜索、筛选、排序、分页功能 - 集成虚拟滚动和性能优化 - 在ProjectDetails页面添加片段管理选项卡 - 遵循promptx开发规范和UI/UX设计标准 --- apps/desktop/package.json | 2 + .../services/material_segment_view_service.rs | 528 +++++++++++++++++ .../src-tauri/src/business/services/mod.rs | 1 + .../src/data/models/material_segment_view.rs | 176 ++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 3 + .../material_segment_view_commands.rs | 88 +++ .../src/presentation/commands/mod.rs | 1 + .../src/components/MaterialSegmentCard.tsx | 312 ++++++++++ .../MaterialSegmentDeleteDialog.tsx | 193 +++++++ .../components/MaterialSegmentDetailModal.tsx | 343 +++++++++++ .../src/components/MaterialSegmentFilters.tsx | 383 +++++++++++++ .../src/components/MaterialSegmentGroup.tsx | 213 +++++++ .../components/MaterialSegmentModelDialog.tsx | 272 +++++++++ .../components/MaterialSegmentPagination.tsx | 161 ++++++ .../MaterialSegmentReclassifyDialog.tsx | 248 ++++++++ .../src/components/MaterialSegmentStats.tsx | 283 +++++++++ .../src/components/MaterialSegmentView.tsx | 540 ++++++++++++++++++ .../src/components/VirtualizedSegmentList.tsx | 128 +++++ apps/desktop/src/pages/ProjectDetails.tsx | 26 +- .../src/store/materialSegmentViewStore.ts | 261 +++++++++ apps/desktop/src/types/materialSegmentView.ts | 139 +++++ package.json | 6 +- pnpm-lock.yaml | 35 ++ 24 files changed, 4340 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src-tauri/src/business/services/material_segment_view_service.rs create mode 100644 apps/desktop/src-tauri/src/data/models/material_segment_view.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/material_segment_view_commands.rs create mode 100644 apps/desktop/src/components/MaterialSegmentCard.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentDeleteDialog.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentDetailModal.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentFilters.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentGroup.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentModelDialog.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentPagination.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentReclassifyDialog.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentStats.tsx create mode 100644 apps/desktop/src/components/MaterialSegmentView.tsx create mode 100644 apps/desktop/src/components/VirtualizedSegmentList.tsx create mode 100644 apps/desktop/src/store/materialSegmentViewStore.ts create mode 100644 apps/desktop/src/types/materialSegmentView.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dc350b3..4c6836a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,9 +31,11 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.20.1", + "react-window": "^1.8.11", "zustand": "^4.4.7" }, "devDependencies": { + "@types/react-window": "^1.8.8", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", diff --git a/apps/desktop/src-tauri/src/business/services/material_segment_view_service.rs b/apps/desktop/src-tauri/src/business/services/material_segment_view_service.rs new file mode 100644 index 0000000..5fa9572 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/material_segment_view_service.rs @@ -0,0 +1,528 @@ +use crate::data::models::material_segment_view::*; +use crate::data::models::material::{Material, MaterialSegment}; +use crate::data::models::video_classification::VideoClassificationRecord; +use crate::data::models::model::Model; +use crate::data::repositories::material_repository::MaterialRepository; +use crate::data::repositories::video_classification_repository::VideoClassificationRepository; +use crate::data::repositories::model_repository::ModelRepository; +use anyhow::Result; +use std::sync::Arc; +use std::collections::HashMap; + +/// MaterialSegment聚合视图业务服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计原则 +pub struct MaterialSegmentViewService { + material_repository: Arc, + video_classification_repository: Arc, + model_repository: Arc, +} + +impl MaterialSegmentViewService { + /// 创建新的MaterialSegment聚合视图服务实例 + pub fn new( + material_repository: Arc, + video_classification_repository: Arc, + model_repository: Arc, + ) -> Self { + Self { + material_repository, + video_classification_repository, + model_repository, + } + } + + /// 获取项目的MaterialSegment聚合视图 + pub async fn get_project_segment_view(&self, project_id: &str) -> Result { + // 获取项目的所有素材 + let materials = self.material_repository.get_by_project_id(project_id)?; + + // 获取所有模特信息 + let models = self.get_project_models(&materials).await?; + + // 构建MaterialSegment聚合视图 + let mut view = MaterialSegmentView::new(project_id.to_string()); + + // 收集所有片段和相关信息 + let segments_with_details = self.collect_segments_with_details(&materials, &models).await?; + + // 按AI分类聚合 + view.by_classification = self.group_by_classification(&segments_with_details); + + // 按模特聚合 + view.by_model = self.group_by_model(&segments_with_details); + + // 计算统计信息 + view.stats = self.calculate_stats(&segments_with_details); + + Ok(view) + } + + /// 根据查询条件获取MaterialSegment聚合视图 + pub async fn get_project_segment_view_with_query( + &self, + query: &MaterialSegmentQuery + ) -> Result { + // 获取基础视图 + let mut view = self.get_project_segment_view(&query.project_id).await?; + + // 应用过滤条件 + if let Some(category) = &query.category_filter { + view.by_classification.retain(|group| group.category == *category); + } + + if let Some(model_id) = &query.model_id_filter { + view.by_model.retain(|group| group.model_id == *model_id); + } + + // 应用时长过滤 + if query.min_duration.is_some() || query.max_duration.is_some() { + self.apply_duration_filter(&mut view, query.min_duration, query.max_duration); + } + + // 应用搜索过滤 + if let Some(search_term) = &query.search_term { + self.apply_search_filter(&mut view, search_term); + } + + // 应用排序 + if let Some(sort_by) = &query.sort_by { + self.apply_sorting(&mut view, sort_by, &query.sort_direction); + } + + // 应用分页 + if let Some(page_size) = query.page_size { + self.apply_pagination(&mut view, page_size, query.page.unwrap_or(0)); + } + + Ok(view) + } + + /// 收集所有片段及其详细信息 + async fn collect_segments_with_details( + &self, + materials: &[Material], + models: &HashMap, + ) -> Result> { + let mut segments_with_details = Vec::new(); + + for material in materials { + // 获取该素材的分类记录 + let classification_records = self.video_classification_repository + .get_by_material_id(&material.id) + .await?; + + // 为每个片段创建详细信息 + for segment in &material.segments { + let classification_info = self.get_classification_info(segment, &classification_records); + let model_info = self.get_model_info(material, models); + + let segment_with_details = SegmentWithDetails { + segment: segment.clone(), + material_name: material.name.clone(), + material_type: format!("{:?}", material.material_type), + classification: classification_info, + model: model_info, + }; + + segments_with_details.push(segment_with_details); + } + } + + Ok(segments_with_details) + } + + /// 获取项目相关的所有模特 + async fn get_project_models(&self, materials: &[Material]) -> Result> { + let mut models = HashMap::new(); + + for material in materials { + if let Some(model_id) = &material.model_id { + if !models.contains_key(model_id) { + if let Ok(Some(model)) = self.model_repository.get_by_id(model_id) { + models.insert(model_id.clone(), model); + } + } + } + } + + Ok(models) + } + + /// 获取片段的分类信息 + fn get_classification_info( + &self, + segment: &MaterialSegment, + classification_records: &[VideoClassificationRecord], + ) -> Option { + classification_records + .iter() + .find(|record| record.segment_id == segment.id) + .map(|record| ClassificationInfo { + category: record.category.clone(), + confidence: record.confidence, + reasoning: record.reasoning.clone(), + features: record.features.clone(), + product_match: record.product_match, + quality_score: record.quality_score, + }) + } + + /// 获取素材的模特信息 + fn get_model_info( + &self, + material: &Material, + models: &HashMap, + ) -> Option { + material.model_id.as_ref().and_then(|model_id| { + models.get(model_id).map(|model| ModelInfo { + id: model.id.clone(), + name: model.name.clone(), + model_type: format!("{:?}", model.gender), + }) + }) + } + + /// 按AI分类聚合片段 + fn group_by_classification(&self, segments: &[SegmentWithDetails]) -> Vec { + let mut groups: HashMap> = HashMap::new(); + + for segment in segments { + let category = segment.classification + .as_ref() + .map(|c| c.category.clone()) + .unwrap_or_else(|| "未分类".to_string()); + + groups.entry(category).or_default().push(segment); + } + + groups.into_iter().map(|(category, segments)| { + let total_duration: f64 = segments.iter() + .map(|s| s.segment.duration) + .sum(); + + ClassificationGroup { + category, + segment_count: segments.len(), + total_duration, + segments: segments.into_iter().cloned().collect(), + } + }).collect() + } + + /// 按模特聚合片段 + fn group_by_model(&self, segments: &[SegmentWithDetails]) -> Vec { + let mut groups: HashMap> = HashMap::new(); + + for segment in segments { + let model_id = segment.model + .as_ref() + .map(|m| m.id.clone()) + .unwrap_or_else(|| "unassigned".to_string()); + + groups.entry(model_id).or_default().push(segment); + } + + groups.into_iter().map(|(model_id, segments)| { + let model_name = segments.first() + .and_then(|s| s.model.as_ref()) + .map(|m| m.name.clone()) + .unwrap_or_else(|| "未关联模特".to_string()); + + let total_duration: f64 = segments.iter() + .map(|s| s.segment.duration) + .sum(); + + ModelGroup { + model_id, + model_name, + segment_count: segments.len(), + total_duration, + segments: segments.into_iter().cloned().collect(), + } + }).collect() + } + + /// 计算统计信息 + fn calculate_stats(&self, segments: &[SegmentWithDetails]) -> MaterialSegmentStats { + let total_segments = segments.len(); + let classified_segments = segments.iter() + .filter(|s| s.classification.is_some()) + .count(); + let unclassified_segments = total_segments - classified_segments; + + let classification_coverage = if total_segments > 0 { + classified_segments as f64 / total_segments as f64 + } else { + 0.0 + }; + + let mut classification_counts = HashMap::new(); + let mut model_counts = HashMap::new(); + let total_duration: f64 = segments.iter().map(|s| s.segment.duration).sum(); + + for segment in segments { + if let Some(classification) = &segment.classification { + *classification_counts.entry(classification.category.clone()).or_insert(0) += 1; + } + + if let Some(model) = &segment.model { + *model_counts.entry(model.name.clone()).or_insert(0) += 1; + } else { + *model_counts.entry("未关联模特".to_string()).or_insert(0) += 1; + } + } + + MaterialSegmentStats { + total_segments, + classified_segments, + unclassified_segments, + classification_coverage, + classification_counts, + model_counts, + total_duration, + } + } + + /// 应用时长过滤 + fn apply_duration_filter( + &self, + view: &mut MaterialSegmentView, + min_duration: Option, + max_duration: Option, + ) { + // 过滤分类视图 + for group in &mut view.by_classification { + group.segments.retain(|segment| { + let duration = segment.segment.duration; + let min_ok = min_duration.map_or(true, |min| duration >= min); + let max_ok = max_duration.map_or(true, |max| duration <= max); + min_ok && max_ok + }); + + // 更新统计信息 + group.segment_count = group.segments.len(); + group.total_duration = group.segments.iter().map(|s| s.segment.duration).sum(); + } + + // 过滤模特视图 + for group in &mut view.by_model { + group.segments.retain(|segment| { + let duration = segment.segment.duration; + let min_ok = min_duration.map_or(true, |min| duration >= min); + let max_ok = max_duration.map_or(true, |max| duration <= max); + min_ok && max_ok + }); + + // 更新统计信息 + group.segment_count = group.segments.len(); + group.total_duration = group.segments.iter().map(|s| s.segment.duration).sum(); + } + + // 移除空分组 + view.by_classification.retain(|group| group.segment_count > 0); + view.by_model.retain(|group| group.segment_count > 0); + + // 更新全局统计信息 + let all_segments: Vec = view.by_classification.iter() + .flat_map(|g| g.segments.clone()) + .collect(); + view.stats = self.calculate_stats(&all_segments); + } + + /// 应用搜索过滤 + fn apply_search_filter(&self, view: &mut MaterialSegmentView, search_term: &str) { + let search_term = search_term.to_lowercase(); + + // 过滤分类视图 + for group in &mut view.by_classification { + group.segments.retain(|segment| { + // 搜索素材名称 + let name_match = segment.material_name.to_lowercase().contains(&search_term); + + // 搜索分类信息 + let category_match = segment.classification.as_ref() + .map(|c| c.category.to_lowercase().contains(&search_term)) + .unwrap_or(false); + + // 搜索模特信息 + let model_match = segment.model.as_ref() + .map(|m| m.name.to_lowercase().contains(&search_term)) + .unwrap_or(false); + + name_match || category_match || model_match + }); + + // 更新统计信息 + group.segment_count = group.segments.len(); + group.total_duration = group.segments.iter().map(|s| s.segment.duration).sum(); + } + + // 过滤模特视图 + for group in &mut view.by_model { + group.segments.retain(|segment| { + // 搜索素材名称 + let name_match = segment.material_name.to_lowercase().contains(&search_term); + + // 搜索分类信息 + let category_match = segment.classification.as_ref() + .map(|c| c.category.to_lowercase().contains(&search_term)) + .unwrap_or(false); + + name_match || category_match + }); + + // 更新统计信息 + group.segment_count = group.segments.len(); + group.total_duration = group.segments.iter().map(|s| s.segment.duration).sum(); + } + + // 移除空分组 + view.by_classification.retain(|group| group.segment_count > 0); + view.by_model.retain(|group| group.segment_count > 0); + + // 更新全局统计信息 + let all_segments: Vec = view.by_classification.iter() + .flat_map(|g| g.segments.clone()) + .collect(); + view.stats = self.calculate_stats(&all_segments); + } + + /// 应用排序 + fn apply_sorting( + &self, + view: &mut MaterialSegmentView, + sort_by: &SegmentSortField, + sort_direction: &Option, + ) { + let direction = sort_direction.clone().unwrap_or(SortDirection::Ascending); + + // 对分类内的片段排序 + for group in &mut view.by_classification { + self.sort_segments(&mut group.segments, sort_by, &direction); + } + + // 对模特内的片段排序 + for group in &mut view.by_model { + self.sort_segments(&mut group.segments, sort_by, &direction); + } + + // 对分组本身排序 + match sort_by { + SegmentSortField::Category => { + view.by_classification.sort_by(|a, b| { + match direction { + SortDirection::Ascending => a.category.cmp(&b.category), + SortDirection::Descending => b.category.cmp(&a.category), + } + }); + }, + SegmentSortField::Model => { + view.by_model.sort_by(|a, b| { + match direction { + SortDirection::Ascending => a.model_name.cmp(&b.model_name), + SortDirection::Descending => b.model_name.cmp(&a.model_name), + } + }); + }, + _ => {} + } + } + + /// 对片段列表排序 + fn sort_segments( + &self, + segments: &mut Vec, + sort_by: &SegmentSortField, + direction: &SortDirection, + ) { + match sort_by { + SegmentSortField::CreatedAt => { + segments.sort_by(|a, b| { + let a_time = a.segment.created_at; + let b_time = b.segment.created_at; + match direction { + SortDirection::Ascending => a_time.cmp(&b_time), + SortDirection::Descending => b_time.cmp(&a_time), + } + }); + }, + SegmentSortField::Duration => { + segments.sort_by(|a, b| { + let a_duration = a.segment.duration; + let b_duration = b.segment.duration; + match direction { + SortDirection::Ascending => a_duration.partial_cmp(&b_duration).unwrap_or(std::cmp::Ordering::Equal), + SortDirection::Descending => b_duration.partial_cmp(&a_duration).unwrap_or(std::cmp::Ordering::Equal), + } + }); + }, + SegmentSortField::Category => { + segments.sort_by(|a, b| { + let a_category = a.classification.as_ref().map(|c| c.category.as_str()).unwrap_or(""); + let b_category = b.classification.as_ref().map(|c| c.category.as_str()).unwrap_or(""); + match direction { + SortDirection::Ascending => a_category.cmp(&b_category), + SortDirection::Descending => b_category.cmp(&a_category), + } + }); + }, + SegmentSortField::Model => { + segments.sort_by(|a, b| { + let a_model = a.model.as_ref().map(|m| m.name.as_str()).unwrap_or(""); + let b_model = b.model.as_ref().map(|m| m.name.as_str()).unwrap_or(""); + match direction { + SortDirection::Ascending => a_model.cmp(&b_model), + SortDirection::Descending => b_model.cmp(&a_model), + } + }); + }, + SegmentSortField::Confidence => { + segments.sort_by(|a, b| { + let a_confidence = a.classification.as_ref().map(|c| c.confidence).unwrap_or(0.0); + let b_confidence = b.classification.as_ref().map(|c| c.confidence).unwrap_or(0.0); + match direction { + SortDirection::Ascending => a_confidence.partial_cmp(&b_confidence).unwrap_or(std::cmp::Ordering::Equal), + SortDirection::Descending => b_confidence.partial_cmp(&a_confidence).unwrap_or(std::cmp::Ordering::Equal), + } + }); + }, + } + } + + /// 应用分页 + fn apply_pagination(&self, view: &mut MaterialSegmentView, page_size: usize, page: usize) { + // 对分类视图应用分页 + for group in &mut view.by_classification { + let start = page * page_size; + let end = (page + 1) * page_size; + + if start < group.segments.len() { + let end = end.min(group.segments.len()); + group.segments = group.segments[start..end].to_vec(); + group.segment_count = group.segments.len(); + group.total_duration = group.segments.iter().map(|s| s.segment.duration).sum(); + } else { + group.segments.clear(); + group.segment_count = 0; + group.total_duration = 0.0; + } + } + + // 对模特视图应用分页 + for group in &mut view.by_model { + let start = page * page_size; + let end = (page + 1) * page_size; + + if start < group.segments.len() { + let end = end.min(group.segments.len()); + group.segments = group.segments[start..end].to_vec(); + group.segment_count = group.segments.len(); + group.total_duration = group.segments.iter().map(|s| s.segment.duration).sum(); + } else { + group.segments.clear(); + group.segment_count = 0; + group.total_duration = 0.0; + } + } + } +} diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index a756ad2..f155113 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -1,5 +1,6 @@ pub mod project_service; pub mod material_service; +pub mod material_segment_view_service; pub mod model_service; pub mod async_material_service; pub mod ai_classification_service; diff --git a/apps/desktop/src-tauri/src/data/models/material_segment_view.rs b/apps/desktop/src-tauri/src/data/models/material_segment_view.rs new file mode 100644 index 0000000..8f2b435 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/material_segment_view.rs @@ -0,0 +1,176 @@ +use serde::{Deserialize, Serialize}; +use crate::data::models::material::MaterialSegment; +use std::collections::HashMap; + +/// MaterialSegment聚合视图数据模型 +/// 遵循 Tauri 开发规范的数据模型设计原则 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSegmentView { + /// 项目ID + pub project_id: String, + /// 按AI分类聚合的片段 + pub by_classification: Vec, + /// 按模特聚合的片段 + pub by_model: Vec, + /// 统计信息 + pub stats: MaterialSegmentStats, +} + +/// AI分类分组 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationGroup { + /// 分类名称 + pub category: String, + /// 分类下的片段数量 + pub segment_count: usize, + /// 分类下的片段总时长(秒) + pub total_duration: f64, + /// 分类下的片段列表 + pub segments: Vec, +} + +/// 模特分组 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelGroup { + /// 模特ID + pub model_id: String, + /// 模特名称 + pub model_name: String, + /// 模特下的片段数量 + pub segment_count: usize, + /// 模特下的片段总时长(秒) + pub total_duration: f64, + /// 模特下的片段列表 + pub segments: Vec, +} + +/// 带详细信息的片段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SegmentWithDetails { + /// 片段信息 + pub segment: MaterialSegment, + /// 素材名称 + pub material_name: String, + /// 素材类型 + pub material_type: String, + /// 分类信息 + pub classification: Option, + /// 模特信息 + pub model: Option, +} + +/// 分类信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationInfo { + /// 分类名称 + pub category: String, + /// 置信度 + pub confidence: f64, + /// 分类理由 + pub reasoning: String, + /// 关键特征 + pub features: Vec, + /// 商品匹配度 + pub product_match: bool, + /// 质量评分 + pub quality_score: f64, +} + +/// 模特信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + /// 模特ID + pub id: String, + /// 模特名称 + pub name: String, + /// 模特类型 + pub model_type: String, +} + +/// 片段统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSegmentStats { + /// 总片段数 + pub total_segments: usize, + /// 已分类片段数 + pub classified_segments: usize, + /// 未分类片段数 + pub unclassified_segments: usize, + /// 分类覆盖率 + pub classification_coverage: f64, + /// 分类统计 + pub classification_counts: HashMap, + /// 模特统计 + pub model_counts: HashMap, + /// 总时长(秒) + pub total_duration: f64, +} + +/// 片段查询参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterialSegmentQuery { + /// 项目ID + pub project_id: String, + /// 分类过滤 + pub category_filter: Option, + /// 模特ID过滤 + pub model_id_filter: Option, + /// 最小时长过滤(秒) + pub min_duration: Option, + /// 最大时长过滤(秒) + pub max_duration: Option, + /// 搜索关键词 + pub search_term: Option, + /// 排序字段 + pub sort_by: Option, + /// 排序方向 + pub sort_direction: Option, + /// 分页大小 + pub page_size: Option, + /// 页码 + pub page: Option, +} + +/// 片段排序字段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SegmentSortField { + /// 创建时间 + CreatedAt, + /// 时长 + Duration, + /// 分类 + Category, + /// 模特 + Model, + /// 置信度 + Confidence, +} + +/// 排序方向 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SortDirection { + /// 升序 + Ascending, + /// 降序 + Descending, +} + +impl MaterialSegmentView { + /// 创建新的MaterialSegment聚合视图 + pub fn new(project_id: String) -> Self { + Self { + project_id, + by_classification: Vec::new(), + by_model: Vec::new(), + stats: MaterialSegmentStats { + total_segments: 0, + classified_segments: 0, + unclassified_segments: 0, + classification_coverage: 0.0, + classification_counts: HashMap::new(), + model_counts: HashMap::new(), + total_duration: 0.0, + }, + } + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index fb848f5..3e26336 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -1,5 +1,6 @@ pub mod project; pub mod material; +pub mod material_segment_view; pub mod model; pub mod ai_classification; pub mod video_classification; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dae1a49..1969eb2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -185,6 +185,9 @@ pub fn run() { commands::material_matching_commands::execute_material_matching, commands::material_matching_commands::get_project_material_stats_for_matching, commands::material_matching_commands::validate_template_binding_for_matching, + // MaterialSegment聚合视图命令 + commands::material_segment_view_commands::get_project_segment_view, + commands::material_segment_view_commands::get_project_segment_view_with_query, // 测试命令 commands::test_commands::test_database_connection, commands::test_commands::test_template_table, diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_segment_view_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_segment_view_commands.rs new file mode 100644 index 0000000..5d16979 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/material_segment_view_commands.rs @@ -0,0 +1,88 @@ +use tauri::{command, State}; +use crate::app_state::AppState; +use crate::data::models::material_segment_view::{MaterialSegmentView, MaterialSegmentQuery}; +use crate::business::services::material_segment_view_service::MaterialSegmentViewService; +use std::sync::Arc; + +/// 获取项目的MaterialSegment聚合视图命令 +/// 遵循 Tauri 开发规范的命令设计模式 +#[command] +pub async fn get_project_segment_view( + project_id: String, + state: State<'_, AppState>, +) -> Result { + // 获取数据库实例 + let database = state.get_database(); + + // 创建仓库实例 + let material_repository = Arc::new( + crate::data::repositories::material_repository::MaterialRepository::new( + database.get_connection() + ).map_err(|e| format!("创建素材仓库失败: {}", e))? + ); + + let video_classification_repository = Arc::new( + crate::data::repositories::video_classification_repository::VideoClassificationRepository::new( + database.clone() + ) + ); + + let model_repository = Arc::new( + crate::data::repositories::model_repository::ModelRepository::new( + database.get_connection() + ) + ); + + // 创建服务实例 + let service = MaterialSegmentViewService::new( + material_repository, + video_classification_repository, + model_repository, + ); + + // 调用服务方法 + service.get_project_segment_view(&project_id) + .await + .map_err(|e| format!("获取MaterialSegment聚合视图失败: {}", e)) +} + +/// 根据查询条件获取项目的MaterialSegment聚合视图命令 +#[command] +pub async fn get_project_segment_view_with_query( + query: MaterialSegmentQuery, + state: State<'_, AppState>, +) -> Result { + // 获取数据库实例 + let database = state.get_database(); + + // 创建仓库实例 + let material_repository = Arc::new( + crate::data::repositories::material_repository::MaterialRepository::new( + database.get_connection() + ).map_err(|e| format!("创建素材仓库失败: {}", e))? + ); + + let video_classification_repository = Arc::new( + crate::data::repositories::video_classification_repository::VideoClassificationRepository::new( + database.clone() + ) + ); + + let model_repository = Arc::new( + crate::data::repositories::model_repository::ModelRepository::new( + database.get_connection() + ) + ); + + // 创建服务实例 + let service = MaterialSegmentViewService::new( + material_repository, + video_classification_repository, + model_repository, + ); + + // 调用服务方法 + service.get_project_segment_view_with_query(&query) + .await + .map_err(|e| format!("获取MaterialSegment聚合视图失败: {}", e)) +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index ad3ffe9..4cddd7e 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -1,6 +1,7 @@ pub mod project_commands; pub mod system_commands; pub mod material_commands; +pub mod material_segment_view_commands; pub mod model_commands; pub mod ai_classification_commands; pub mod video_classification_commands; diff --git a/apps/desktop/src/components/MaterialSegmentCard.tsx b/apps/desktop/src/components/MaterialSegmentCard.tsx new file mode 100644 index 0000000..468634d --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentCard.tsx @@ -0,0 +1,312 @@ +import React, { useState } from 'react'; +import { + Play, + Clock, + Tag, + Users, + Star, + MoreVertical, + Eye, + Edit, + Trash2, + RefreshCw, + FileVideo, + Calendar, + Hash, + TrendingUp +} from 'lucide-react'; +import { + SegmentWithDetails, + MaterialSegmentViewMode, + SegmentActionType +} from '../types/materialSegmentView'; +import { MaterialSegmentDetailModal } from './MaterialSegmentDetailModal'; + +interface MaterialSegmentCardProps { + segmentWithDetails: SegmentWithDetails; + isSelected: boolean; + onSelect: () => void; + viewMode: MaterialSegmentViewMode; +} + +/** + * MaterialSegment卡片组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentCard: React.FC = ({ + segmentWithDetails, + isSelected, + onSelect, +}) => { + const [showActions, setShowActions] = useState(false); + const [showDetailModal, setShowDetailModal] = useState(false); + const { segment, material_name, classification, model } = segmentWithDetails; + + // 格式化时长 + const formatDuration = (seconds: number) => { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } else { + return `${Math.floor(seconds / 60)}:${Math.round(seconds % 60).toString().padStart(2, '0')}`; + } + }; + + // 格式化时间戳 + const formatTimestamp = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + // 格式化日期 + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + // 获取置信度颜色 + const getConfidenceColor = (confidence: number) => { + if (confidence >= 0.8) return 'text-green-600 bg-green-100'; + if (confidence >= 0.6) return 'text-yellow-600 bg-yellow-100'; + return 'text-red-600 bg-red-100'; + }; + + // 获取质量评分颜色 + const getQualityColor = (score: number) => { + if (score >= 0.8) return 'text-green-600'; + if (score >= 0.6) return 'text-yellow-600'; + return 'text-red-600'; + }; + + // 处理操作点击 + const handleAction = (actionType: SegmentActionType, event: React.MouseEvent) => { + event.stopPropagation(); + setShowActions(false); + + switch (actionType) { + case SegmentActionType.ViewDetails: + setShowDetailModal(true); + break; + case SegmentActionType.Reclassify: + // TODO: 实现重新分类逻辑 + console.log(`重新分类片段: ${segment.id}`); + break; + case SegmentActionType.EditModel: + // TODO: 实现编辑模特逻辑 + console.log(`编辑模特关联: ${segment.id}`); + break; + case SegmentActionType.Delete: + // TODO: 实现删除逻辑 + console.log(`删除片段: ${segment.id}`); + break; + default: + console.log(`执行操作: ${actionType} on segment ${segment.id}`); + } + }; + + return ( +
+ {/* 选择指示器 */} + {isSelected && ( +
+
+
+ )} + + {/* 操作菜单 */} +
+ + + {showActions && ( +
+ + + +
+ +
+ )} +
+ +
+ {/* 片段基本信息 */} +
+
+

+ {material_name} +

+ + #{segment.segment_index} + +
+ + {/* 时间信息 */} +
+
+ + {formatTimestamp(segment.start_time)} - {formatTimestamp(segment.end_time)} +
+
+ {formatDuration(segment.duration)} +
+
+ + {/* 文件信息 */} +
+ + {segment.file_path.split('/').pop()} +
+
+ + {/* 分类信息 */} + {classification && ( +
+
+
+ + {classification.category} +
+
+ {Math.round(classification.confidence * 100)}% +
+
+ + {/* 质量评分 */} +
+
+ + 质量评分 +
+
+ + + {Math.round(classification.quality_score * 100)}% + +
+
+ + {/* 商品匹配指示器 */} + {classification.product_match && ( +
+ 商品匹配 +
+ )} +
+ )} + + {/* 模特信息 */} + {model && ( +
+
+ + {model.name} + ({model.model_type}) +
+
+ )} + + {/* 关键特征 */} + {classification && classification.features.length > 0 && ( +
+
关键特征
+
+ {classification.features.slice(0, 3).map((feature, index) => ( + + {feature} + + ))} + {classification.features.length > 3 && ( + + +{classification.features.length - 3} + + )} +
+
+ )} + + {/* 底部信息 */} +
+
+ + {formatDate(segment.created_at)} +
+
+ + {segment.id.slice(-8)} +
+
+
+ + {/* 播放按钮覆盖层 */} +
+ +
+ + {/* 详情模态框 */} + setShowDetailModal(false)} + onPlay={() => { + // TODO: 实现视频播放逻辑 + console.log('播放片段:', segment.file_path); + }} + /> +
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentDeleteDialog.tsx b/apps/desktop/src/components/MaterialSegmentDeleteDialog.tsx new file mode 100644 index 0000000..6060e62 --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentDeleteDialog.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { AlertTriangle, X, Trash2 } from 'lucide-react'; +import { SegmentWithDetails } from '../types/materialSegmentView'; + +interface MaterialSegmentDeleteDialogProps { + segments: SegmentWithDetails[]; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + isLoading?: boolean; +} + +/** + * MaterialSegment删除确认对话框组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentDeleteDialog: React.FC = ({ + segments, + isOpen, + onClose, + onConfirm, + isLoading = false, +}) => { + if (!isOpen) return null; + + const isBatch = segments.length > 1; + + // 格式化时长 + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + // 计算总时长 + const totalDuration = segments.reduce((sum, segment) => sum + segment.segment.duration, 0); + + return ( +
+
+ {/* 对话框头部 */} +
+
+
+ +
+
+

+ {isBatch ? '批量删除片段' : '删除片段'} +

+

+ {isBatch ? `确认删除 ${segments.length} 个片段` : '确认删除该片段'} +

+
+
+ +
+ + {/* 对话框内容 */} +
+
+

+ {isBatch + ? '您即将删除以下片段,此操作不可撤销:' + : '您即将删除该片段,此操作不可撤销:' + } +

+ + {/* 片段列表 */} +
+ {isBatch ? ( +
+ {segments.slice(0, 5).map((segmentWithDetails, _index) => ( +
+ + {segmentWithDetails.material_name} + +
+ #{segmentWithDetails.segment.segment_index} + {formatDuration(segmentWithDetails.segment.duration)} +
+
+ ))} + {segments.length > 5 && ( +
+ 还有 {segments.length - 5} 个片段... +
+ )} +
+ ) : ( +
+
+ 素材名称 + {segments[0].material_name} +
+
+ 片段索引 + #{segments[0].segment.segment_index} +
+
+ 片段时长 + {formatDuration(segments[0].segment.duration)} +
+ {segments[0].classification && ( +
+ 分类 + {segments[0].classification.category} +
+ )} +
+ )} +
+ + {/* 统计信息 */} + {isBatch && ( +
+
+ 删除统计 +
+
+
+ 片段数量 + {segments.length} 个 +
+
+ 总时长 + {formatDuration(totalDuration)} +
+
+ 已分类片段 + + {segments.filter(s => s.classification).length} 个 + +
+
+
+ )} +
+ + {/* 警告信息 */} +
+
+ +
+

注意事项:

+
    +
  • 删除操作不可撤销
  • +
  • 片段文件将从磁盘中删除
  • +
  • 相关的AI分类数据也将被删除
  • + {isBatch &&
  • 批量删除可能需要一些时间
  • } +
+
+
+
+
+ + {/* 对话框底部 */} +
+ + +
+
+
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentDetailModal.tsx b/apps/desktop/src/components/MaterialSegmentDetailModal.tsx new file mode 100644 index 0000000..82d14fd --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentDetailModal.tsx @@ -0,0 +1,343 @@ +import React from 'react'; +import { + X, + Play, + Clock, + Tag, + Users, + Star, + TrendingUp, + FileVideo, + MapPin, + Zap, + AlertCircle, + Info +} from 'lucide-react'; +import { SegmentWithDetails } from '../types/materialSegmentView'; + +interface MaterialSegmentDetailModalProps { + segmentWithDetails: SegmentWithDetails; + isOpen: boolean; + onClose: () => void; + onPlay?: () => void; +} + +/** + * MaterialSegment详细信息模态框组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentDetailModal: React.FC = ({ + segmentWithDetails, + isOpen, + onClose, + onPlay, +}) => { + const { segment, material_name, material_type, classification, model } = segmentWithDetails; + + if (!isOpen) return null; + + // 格式化时长 + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + // 格式化时间戳 + const formatTimestamp = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + // 格式化日期 + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }; + + // 获取置信度颜色和描述 + const getConfidenceInfo = (confidence: number) => { + if (confidence >= 0.9) return { color: 'text-green-600 bg-green-100', desc: '非常高' }; + if (confidence >= 0.8) return { color: 'text-green-600 bg-green-100', desc: '高' }; + if (confidence >= 0.6) return { color: 'text-yellow-600 bg-yellow-100', desc: '中等' }; + if (confidence >= 0.4) return { color: 'text-orange-600 bg-orange-100', desc: '较低' }; + return { color: 'text-red-600 bg-red-100', desc: '低' }; + }; + + // 获取质量评分颜色和描述 + const getQualityInfo = (score: number) => { + if (score >= 0.9) return { color: 'text-green-600', desc: '优秀' }; + if (score >= 0.8) return { color: 'text-green-600', desc: '良好' }; + if (score >= 0.6) return { color: 'text-yellow-600', desc: '一般' }; + if (score >= 0.4) return { color: 'text-orange-600', desc: '较差' }; + return { color: 'text-red-600', desc: '差' }; + }; + + return ( +
+
+ {/* 模态框头部 */} +
+
+ +
+

{material_name}

+

片段 #{segment.segment_index}

+
+
+ +
+ + {/* 模态框内容 */} +
+
+ {/* 左侧:基本信息 */} +
+ {/* 片段基本信息 */} +
+

+ + 基本信息 +

+
+
+ 片段ID + + {segment.id.slice(-12)} + +
+
+ 素材类型 + {material_type} +
+
+ 片段索引 + #{segment.segment_index} +
+
+ 创建时间 + {formatDate(segment.created_at)} +
+
+
+ + {/* 时间信息 */} +
+

+ + 时间信息 +

+
+
+ 开始时间 + + {formatTimestamp(segment.start_time)} + +
+
+ 结束时间 + + {formatTimestamp(segment.end_time)} + +
+
+ 片段时长 + + {formatDuration(segment.duration)} + +
+
+
+ + {/* 文件信息 */} +
+

+ + 文件信息 +

+
+
+ 文件路径 + + {segment.file_path} + +
+
+ 文件名 + + {segment.file_path.split('/').pop()} + +
+
+
+ + {/* 播放控制 */} +
+ +
+
+ + {/* 右侧:AI分析信息 */} +
+ {/* AI分类信息 */} + {classification ? ( +
+

+ + AI分类结果 +

+
+ {/* 分类名称和置信度 */} +
+
+ {classification.category} + {classification.product_match && ( + + 商品匹配 + + )} +
+
+ {Math.round(classification.confidence * 100)}% ({getConfidenceInfo(classification.confidence).desc}) +
+
+ + {/* 质量评分 */} +
+ + + 质量评分 + +
+ + + {Math.round(classification.quality_score * 100)}% ({getQualityInfo(classification.quality_score).desc}) + +
+
+ + {/* 分类理由 */} +
+ 分类理由 +

+ {classification.reasoning} +

+
+ + {/* 关键特征 */} + {classification.features.length > 0 && ( +
+ 关键特征 +
+ {classification.features.map((feature, index) => ( + + {feature} + + ))} +
+
+ )} +
+
+ ) : ( +
+

+ + AI分类状态 +

+
+ +

该片段尚未进行AI分类

+ +
+
+ )} + + {/* 模特信息 */} + {model ? ( +
+

+ + 关联模特 +

+
+
+ 模特ID + + {model.id.slice(-8)} + +
+
+ 模特名称 + {model.name} +
+
+ 模特类型 + {model.model_type} +
+
+
+ ) : ( +
+

+ + 模特关联 +

+
+ +

该片段尚未关联模特

+ +
+
+ )} +
+
+
+ + {/* 模态框底部 */} +
+ + +
+
+
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentFilters.tsx b/apps/desktop/src/components/MaterialSegmentFilters.tsx new file mode 100644 index 0000000..926fe48 --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentFilters.tsx @@ -0,0 +1,383 @@ +import React, { useState, useEffect } from 'react'; +import { + Filter, + X, + Clock, + Tag, + Users, + ArrowUpDown, + ArrowUp, + ArrowDown, + RotateCcw +} from 'lucide-react'; +import { + MaterialSegmentQuery, + SegmentSortField, + SortDirection +} from '../types/materialSegmentView'; +import { CustomSelect } from './CustomSelect'; + +interface MaterialSegmentFiltersProps { + currentQuery: MaterialSegmentQuery | null; + onQueryUpdate: (updates: Partial) => void; + onClearQuery: () => void; + onSort: (sortBy: SegmentSortField, direction: SortDirection) => void; +} + +/** + * MaterialSegment过滤器组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentFilters: React.FC = ({ + currentQuery, + onQueryUpdate, + onClearQuery, +}) => { + const [localFilters, setLocalFilters] = useState({ + categoryFilter: currentQuery?.category_filter || '', + modelIdFilter: currentQuery?.model_id_filter || '', + minDuration: currentQuery?.min_duration?.toString() || '', + maxDuration: currentQuery?.max_duration?.toString() || '', + sortBy: currentQuery?.sort_by || SegmentSortField.CreatedAt, + sortDirection: currentQuery?.sort_direction || SortDirection.Descending, + pageSize: currentQuery?.page_size?.toString() || '20', + }); + + // 同步外部查询状态到本地状态 + useEffect(() => { + if (currentQuery) { + setLocalFilters({ + categoryFilter: currentQuery.category_filter || '', + modelIdFilter: currentQuery.model_id_filter || '', + minDuration: currentQuery.min_duration?.toString() || '', + maxDuration: currentQuery.max_duration?.toString() || '', + sortBy: currentQuery.sort_by || SegmentSortField.CreatedAt, + sortDirection: currentQuery.sort_direction || SortDirection.Descending, + pageSize: currentQuery.page_size?.toString() || '20', + }); + } + }, [currentQuery]); + + // 应用过滤器 + const applyFilters = () => { + const updates: Partial = {}; + + if (localFilters.categoryFilter) { + updates.category_filter = localFilters.categoryFilter; + } + + if (localFilters.modelIdFilter) { + updates.model_id_filter = localFilters.modelIdFilter; + } + + if (localFilters.minDuration) { + const minDuration = parseFloat(localFilters.minDuration); + if (!isNaN(minDuration) && minDuration > 0) { + updates.min_duration = minDuration; + } + } + + if (localFilters.maxDuration) { + const maxDuration = parseFloat(localFilters.maxDuration); + if (!isNaN(maxDuration) && maxDuration > 0) { + updates.max_duration = maxDuration; + } + } + + if (localFilters.sortBy) { + updates.sort_by = localFilters.sortBy; + } + + if (localFilters.sortDirection) { + updates.sort_direction = localFilters.sortDirection; + } + + if (localFilters.pageSize) { + const pageSize = parseInt(localFilters.pageSize); + if (!isNaN(pageSize) && pageSize > 0) { + updates.page_size = pageSize; + } + } + + onQueryUpdate(updates); + }; + + // 清除所有过滤器 + const clearAllFilters = () => { + setLocalFilters({ + categoryFilter: '', + modelIdFilter: '', + minDuration: '', + maxDuration: '', + sortBy: SegmentSortField.CreatedAt, + sortDirection: SortDirection.Descending, + pageSize: '20', + }); + onClearQuery(); + }; + + // 处理排序 + // const handleSort = (sortBy: SegmentSortField) => { + // const newDirection = localFilters.sortBy === sortBy && localFilters.sortDirection === SortDirection.Ascending + // ? SortDirection.Descending + // : SortDirection.Ascending; + + // setLocalFilters(prev => ({ + // ...prev, + // sortBy, + // sortDirection: newDirection, + // })); + + // onSort(sortBy, newDirection); + // }; + + // 排序选项 + const sortOptions = [ + { value: SegmentSortField.CreatedAt, label: '创建时间' }, + { value: SegmentSortField.Duration, label: '时长' }, + { value: SegmentSortField.Category, label: '分类' }, + { value: SegmentSortField.Model, label: '模特' }, + { value: SegmentSortField.Confidence, label: '置信度' }, + ]; + + // 页面大小选项 + const pageSizeOptions = [ + { value: '10', label: '10 条/页' }, + { value: '20', label: '20 条/页' }, + { value: '50', label: '50 条/页' }, + { value: '100', label: '100 条/页' }, + ]; + + // 检查是否有活动的过滤器 + const hasActiveFilters = localFilters.categoryFilter || + localFilters.modelIdFilter || + localFilters.minDuration || + localFilters.maxDuration; + + return ( +
+ {/* 过滤器标题 */} +
+

+ + 过滤条件 +

+ {hasActiveFilters && ( + + )} +
+ + {/* 过滤器网格 */} +
+ {/* 分类过滤 */} +
+ + setLocalFilters(prev => ({ ...prev, categoryFilter: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* 模特过滤 */} +
+ + setLocalFilters(prev => ({ ...prev, modelIdFilter: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* 最小时长 */} +
+ + setLocalFilters(prev => ({ ...prev, minDuration: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* 最大时长 */} +
+ + setLocalFilters(prev => ({ ...prev, maxDuration: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + {/* 排序和分页 */} +
+ {/* 排序字段 */} +
+ + setLocalFilters(prev => ({ ...prev, sortBy: value as SegmentSortField }))} + options={sortOptions} + placeholder="选择排序字段" + /> +
+ + {/* 排序方向 */} +
+ +
+ + +
+
+ + {/* 页面大小 */} +
+ + setLocalFilters(prev => ({ ...prev, pageSize: value }))} + options={pageSizeOptions} + placeholder="选择页面大小" + /> +
+
+ + {/* 操作按钮 */} +
+ + +
+ + {/* 活动过滤器显示 */} + {hasActiveFilters && ( +
+ {localFilters.categoryFilter && ( + + 分类: {localFilters.categoryFilter} + + + )} + {localFilters.modelIdFilter && ( + + 模特: {localFilters.modelIdFilter} + + + )} + {localFilters.minDuration && ( + + 最小时长: {localFilters.minDuration}s + + + )} + {localFilters.maxDuration && ( + + 最大时长: {localFilters.maxDuration}s + + + )} +
+ )} +
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentGroup.tsx b/apps/desktop/src/components/MaterialSegmentGroup.tsx new file mode 100644 index 0000000..a3507d4 --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentGroup.tsx @@ -0,0 +1,213 @@ +import React from 'react'; +import { + ChevronDown, + ChevronRight, + Tag, + Users, + Clock, + BarChart3, + FileVideo, + AlertCircle +} from 'lucide-react'; +import { + ClassificationGroup, + ModelGroup, + MaterialSegmentViewMode +} from '../types/materialSegmentView'; +import { MaterialSegmentCard } from './MaterialSegmentCard'; + +interface MaterialSegmentGroupProps { + group: ClassificationGroup | ModelGroup; + viewMode: MaterialSegmentViewMode; + isExpanded: boolean; + selectedSegmentIds: Set; + onToggleExpand: (groupId: string) => void; + onSelectSegment: (segmentId: string) => void; +} + +/** + * MaterialSegment分组组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentGroup: React.FC = ({ + group, + viewMode, + isExpanded, + selectedSegmentIds, + onToggleExpand, + onSelectSegment, +}) => { + const isClassificationGroup = viewMode === MaterialSegmentViewMode.ByClassification; + const groupId = isClassificationGroup + ? (group as ClassificationGroup).category + : (group as ModelGroup).model_id; + const groupName = isClassificationGroup + ? (group as ClassificationGroup).category + : (group as ModelGroup).model_name; + + // 获取分组图标 + const getGroupIcon = () => { + if (isClassificationGroup) { + return ; + } else { + return ; + } + }; + + // 获取分组颜色主题 + const getGroupTheme = () => { + if (isClassificationGroup) { + return { + bg: 'bg-blue-50', + border: 'border-blue-200', + text: 'text-blue-900', + accent: 'text-blue-600', + }; + } else { + return { + bg: 'bg-green-50', + border: 'border-green-200', + text: 'text-green-900', + accent: 'text-green-600', + }; + } + }; + + const theme = getGroupTheme(); + + // 格式化时长 + const formatDuration = (seconds: number) => { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } else if (seconds < 3600) { + return `${Math.round(seconds / 60)}m`; + } else { + return `${Math.round(seconds / 3600)}h`; + } + }; + + // 计算选中的片段数量 + const selectedCount = group.segments.filter(segment => + selectedSegmentIds.has(segment.segment.id) + ).length; + + return ( +
+ {/* 分组头部 */} +
onToggleExpand(groupId)} + > +
+
+ {/* 展开/折叠图标 */} + + + {/* 分组图标 */} + {getGroupIcon()} + + {/* 分组名称 */} +
+

+ {groupName} + {groupName === '未分类' && ( + + )} + {groupName === '未关联模特' && ( + + )} +

+ {selectedCount > 0 && ( +

+ 已选择 {selectedCount} 个片段 +

+ )} +
+
+ + {/* 分组统计信息 */} +
+
+ + {group.segment_count} + 个片段 +
+
+ + {formatDuration(group.total_duration)} +
+
+
+ + {/* 分组进度条 */} + {isClassificationGroup && ( +
+
+ 分类完成度 + {Math.round((group.segment_count / (group.segment_count || 1)) * 100)}% +
+
+
+
+
+ )} +
+ + {/* 分组内容 */} + {isExpanded && ( +
+ {group.segments.length > 0 ? ( +
+
+ {group.segments.map((segmentWithDetails) => ( + onSelectSegment(segmentWithDetails.segment.id)} + viewMode={viewMode} + /> + ))} +
+ + {/* 分组底部统计 */} +
+
+
+ + + {group.segments.length} 个片段 + + + + 总时长 {formatDuration(group.total_duration)} + +
+ + {/* 平均时长 */} + + 平均时长 {formatDuration(group.total_duration / group.segment_count)} + +
+
+
+ ) : ( +
+ +

该分组暂无片段

+
+ )} +
+ )} +
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentModelDialog.tsx b/apps/desktop/src/components/MaterialSegmentModelDialog.tsx new file mode 100644 index 0000000..841882d --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentModelDialog.tsx @@ -0,0 +1,272 @@ +import React, { useState, useEffect } from 'react'; +import { Users, X, Search, Plus, Check } from 'lucide-react'; +import { SegmentWithDetails } from '../types/materialSegmentView'; +import { Model } from '../types/model'; +import { invoke } from '@tauri-apps/api/core'; + +interface MaterialSegmentModelDialogProps { + segments: SegmentWithDetails[]; + isOpen: boolean; + onClose: () => void; + onConfirm: (modelId: string | null) => void; + isLoading?: boolean; +} + +/** + * MaterialSegment模特关联对话框组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentModelDialog: React.FC = ({ + segments, + isOpen, + onClose, + onConfirm, + isLoading = false, +}) => { + const [models, setModels] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [loadingModels, setLoadingModels] = useState(false); + + // 加载模特列表 + useEffect(() => { + if (isOpen) { + loadModels(); + } + }, [isOpen]); + + const loadModels = async () => { + setLoadingModels(true); + try { + const modelList = await invoke('get_all_models'); + setModels(modelList); + } catch (error) { + console.error('加载模特列表失败:', error); + } finally { + setLoadingModels(false); + } + }; + + if (!isOpen) return null; + + const isBatch = segments.length > 1; + const currentModels = [...new Set(segments.map(s => s.model?.id).filter(Boolean))]; + + // 过滤模特列表 + const filteredModels = models.filter(model => + model.name.toLowerCase().includes(searchTerm.toLowerCase()) || + model.id.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // 格式化时长 + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + const handleConfirm = () => { + onConfirm(selectedModelId); + }; + + return ( +
+
+ {/* 对话框头部 */} +
+
+
+ +
+
+

+ {isBatch ? '批量关联模特' : '关联模特'} +

+

+ {isBatch ? `为 ${segments.length} 个片段关联模特` : '为该片段关联模特'} +

+
+
+ +
+ + {/* 对话框内容 */} +
+ {/* 片段概览 */} +
+

片段概览

+
+
+
+ 片段数量 + {segments.length} +
+
+ 总时长 + + {formatDuration(segments.reduce((sum, s) => sum + s.segment.duration, 0))} + +
+
+ 已关联模特 + + {segments.filter(s => s.model).length} + +
+
+ 未关联模特 + + {segments.filter(s => !s.model).length} + +
+
+
+ + {/* 当前关联的模特 */} + {currentModels.length > 0 && ( +
+
当前关联的模特:
+
+ {currentModels.map(modelId => { + const model = models.find(m => m.id === modelId); + return ( + + {model?.name || modelId?.slice(-8)} + + ); + })} +
+
+ )} +
+ + {/* 模特选择 */} +
+
+

选择模特

+ +
+ + {/* 搜索框 */} +
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm" + /> +
+ + {/* 模特列表 */} +
+ {loadingModels ? ( +
+
+

加载模特列表...

+
+ ) : filteredModels.length > 0 ? ( + filteredModels.map((model) => ( +
setSelectedModelId(model.id)} + className={`p-3 border rounded-lg cursor-pointer transition-all ${ + selectedModelId === model.id + ? 'border-green-500 bg-green-50' + : 'border-gray-200 hover:border-green-300 hover:bg-green-50' + }`} + > +
+
+
+
{model.name}
+ {selectedModelId === model.id && ( + + )} +
+
+ ID: {model.id.slice(-8)} + 类型: {model.gender} +
+
+
+
+ )) + ) : ( +
+ +

+ {searchTerm ? '未找到匹配的模特' : '暂无可用模特'} +

+ {!searchTerm && ( + + )} +
+ )} +
+
+ + {/* 操作说明 */} +
+
+

操作说明:

+
    +
  • 选择模特后,所有选中的片段都将关联到该模特
  • +
  • 如果片段已关联其他模特,将会被覆盖
  • +
  • 选择"取消关联"将移除所有片段的模特关联
  • +
  • 模特关联不会影响AI分类结果
  • +
+
+
+
+ + {/* 对话框底部 */} +
+ + +
+
+
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentPagination.tsx b/apps/desktop/src/components/MaterialSegmentPagination.tsx new file mode 100644 index 0000000..72ff126 --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentPagination.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; + +interface MaterialSegmentPaginationProps { + currentPage: number; + totalPages: number; + pageSize: number; + totalItems: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + isLoading?: boolean; +} + +/** + * MaterialSegment分页组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentPagination: React.FC = ({ + currentPage, + totalPages, + pageSize, + totalItems, + onPageChange, + onPageSizeChange, + isLoading = false, +}) => { + // 计算显示的页码范围 + const getVisiblePages = () => { + const delta = 2; // 当前页前后显示的页数 + const range = []; + const rangeWithDots = []; + + for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) { + range.push(i); + } + + if (currentPage - delta > 2) { + rangeWithDots.push(1, '...'); + } else { + rangeWithDots.push(1); + } + + rangeWithDots.push(...range); + + if (currentPage + delta < totalPages - 1) { + rangeWithDots.push('...', totalPages); + } else if (totalPages > 1) { + rangeWithDots.push(totalPages); + } + + return rangeWithDots; + }; + + const visiblePages = getVisiblePages(); + + // 计算当前页显示的项目范围 + const startItem = (currentPage - 1) * pageSize + 1; + const endItem = Math.min(currentPage * pageSize, totalItems); + + if (totalPages <= 1) { + return null; // 不显示分页器 + } + + return ( +
+ {/* 左侧:显示信息 */} +
+
+ 显示 {startItem} 到{' '} + {endItem} 项,共{' '} + {totalItems} 项 +
+ + {/* 每页显示数量选择 */} +
+ + +
+
+ + {/* 右侧:分页控件 */} +
+ {/* 第一页 */} + + + {/* 上一页 */} + + + {/* 页码 */} +
+ {visiblePages.map((page, index) => ( + + {page === '...' ? ( + ... + ) : ( + + )} + + ))} +
+ + {/* 下一页 */} + + + {/* 最后一页 */} + +
+
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentReclassifyDialog.tsx b/apps/desktop/src/components/MaterialSegmentReclassifyDialog.tsx new file mode 100644 index 0000000..5fa73d2 --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentReclassifyDialog.tsx @@ -0,0 +1,248 @@ +import React, { useState } from 'react'; +import { RefreshCw, X, Zap, AlertCircle } from 'lucide-react'; +import { SegmentWithDetails } from '../types/materialSegmentView'; + +interface MaterialSegmentReclassifyDialogProps { + segments: SegmentWithDetails[]; + isOpen: boolean; + onClose: () => void; + onConfirm: (options: ReclassifyOptions) => void; + isLoading?: boolean; +} + +interface ReclassifyOptions { + overwriteExisting: boolean; + useLatestModel: boolean; + priority: 'low' | 'normal' | 'high'; +} + +/** + * MaterialSegment重新分类对话框组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentReclassifyDialog: React.FC = ({ + segments, + isOpen, + onClose, + onConfirm, + isLoading = false, +}) => { + const [options, setOptions] = useState({ + overwriteExisting: true, + useLatestModel: true, + priority: 'normal', + }); + + if (!isOpen) return null; + + const isBatch = segments.length > 1; + const classifiedSegments = segments.filter(s => s.classification); + const unclassifiedSegments = segments.filter(s => !s.classification); + + // 格式化时长 + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + // 计算总时长 + const totalDuration = segments.reduce((sum, segment) => sum + segment.segment.duration, 0); + + const handleConfirm = () => { + onConfirm(options); + }; + + return ( +
+
+ {/* 对话框头部 */} +
+
+
+ +
+
+

+ {isBatch ? '批量重新分类' : '重新分类'} +

+

+ {isBatch ? `重新分类 ${segments.length} 个片段` : '重新分类该片段'} +

+
+
+ +
+ + {/* 对话框内容 */} +
+ {/* 片段概览 */} +
+

片段概览

+
+
+
+ 总片段数 + {segments.length} +
+
+ 总时长 + {formatDuration(totalDuration)} +
+
+ 已分类 + {classifiedSegments.length} +
+
+ 未分类 + {unclassifiedSegments.length} +
+
+
+
+ + {/* 分类选项 */} +
+
+

分类选项

+ + {/* 覆盖现有分类 */} +
+
+ setOptions(prev => ({ ...prev, overwriteExisting: e.target.checked }))} + className="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> +
+ +

+ 如果片段已有分类结果,是否重新分类并覆盖原结果 +

+
+
+ + {/* 使用最新模型 */} +
+ setOptions(prev => ({ ...prev, useLatestModel: e.target.checked }))} + className="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> +
+ +

+ 使用最新版本的AI分类模型进行分类,可能获得更准确的结果 +

+
+
+
+
+ + {/* 优先级设置 */} +
+

处理优先级

+
+ {[ + { value: 'low', label: '低优先级', desc: '在后台慢速处理,不影响其他任务' }, + { value: 'normal', label: '普通优先级', desc: '正常速度处理,推荐选择' }, + { value: 'high', label: '高优先级', desc: '优先处理,可能影响其他任务性能' }, + ].map((priority) => ( +
+ setOptions(prev => ({ ...prev, priority: e.target.value as any }))} + className="mt-1 w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500" + /> +
+ +

{priority.desc}

+
+
+ ))} +
+
+
+ + {/* 警告信息 */} + {classifiedSegments.length > 0 && options.overwriteExisting && ( +
+
+ +
+

注意:

+

+ 有 {classifiedSegments.length} 个片段已有分类结果,重新分类将覆盖现有结果。 + 如果您只想分类未分类的片段,请取消勾选"覆盖现有分类结果"。 +

+
+
+
+ )} + + {/* 预估时间 */} +
+
+ +
+

预估处理时间:

+

+ 根据片段数量和优先级,预计需要 {Math.ceil(segments.length * 0.5)} - {Math.ceil(segments.length * 2)} 分钟完成分类。 + 您可以在AI分析日志中查看处理进度。 +

+
+
+
+
+ + {/* 对话框底部 */} +
+ + +
+
+
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentStats.tsx b/apps/desktop/src/components/MaterialSegmentStats.tsx new file mode 100644 index 0000000..f3099de --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentStats.tsx @@ -0,0 +1,283 @@ +import React from 'react'; +import { + BarChart3, + PieChart, + Clock, + Tag, + Users, + CheckCircle, + AlertCircle, + TrendingUp, + FileVideo +} from 'lucide-react'; +import { MaterialSegmentStats as IMaterialSegmentStats, MaterialSegmentViewMode } from '../types/materialSegmentView'; + +interface MaterialSegmentStatsProps { + stats: IMaterialSegmentStats; + viewMode: MaterialSegmentViewMode; +} + +/** + * MaterialSegment统计信息组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentStats: React.FC = ({ + stats, + viewMode, +}) => { + // 格式化时长 + const formatDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.round(seconds % 60); + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } + }; + + // 格式化百分比 + const formatPercentage = (value: number) => { + return `${Math.round(value * 100)}%`; + }; + + // 获取分类覆盖率颜色 + const getCoverageColor = (coverage: number) => { + if (coverage >= 0.8) return 'text-green-600 bg-green-100'; + if (coverage >= 0.6) return 'text-yellow-600 bg-yellow-100'; + return 'text-red-600 bg-red-100'; + }; + + // 获取前5个分类/模特 + const getTopItems = () => { + const counts = viewMode === MaterialSegmentViewMode.ByClassification + ? stats.classification_counts + : stats.model_counts; + + return Object.entries(counts) + .sort(([, a], [, b]) => b - a) + .slice(0, 5); + }; + + const topItems = getTopItems(); + + return ( +
+ {/* 总体统计 */} +
+ {/* 总片段数 */} +
+
+
+

总片段数

+

{stats.total_segments}

+
+ +
+
+ + {/* 已分类片段 */} +
+
+
+

已分类

+

{stats.classified_segments}

+
+ +
+
+ + {/* 未分类片段 */} +
+
+
+

未分类

+

{stats.unclassified_segments}

+
+ +
+
+ + {/* 总时长 */} +
+
+
+

总时长

+

{formatDuration(stats.total_duration)}

+
+ +
+
+
+ + {/* 分类覆盖率 */} +
+
+

+ + 分类覆盖率 +

+
+ {formatPercentage(stats.classification_coverage)} +
+
+ + {/* 进度条 */} +
+
+ 已分类 {stats.classified_segments} / {stats.total_segments} + {formatPercentage(stats.classification_coverage)} +
+
+
= 0.8 + ? 'bg-green-500' + : stats.classification_coverage >= 0.6 + ? 'bg-yellow-500' + : 'bg-red-500' + }`} + style={{ width: `${stats.classification_coverage * 100}%` }} + /> +
+
+ + {/* 分类建议 */} +
+ {stats.classification_coverage >= 0.8 ? ( +

✓ 分类覆盖率良好,大部分片段已完成分类

+ ) : stats.classification_coverage >= 0.6 ? ( +

⚠ 分类覆盖率中等,建议继续完善分类

+ ) : ( +

⚠ 分类覆盖率较低,需要加强AI分类处理

+ )} +
+
+ + {/* 分布统计 */} +
+ {/* 分类/模特分布 */} +
+

+ {viewMode === MaterialSegmentViewMode.ByClassification ? ( + <> + + 分类分布 + + ) : ( + <> + + 模特分布 + + )} +

+ +
+ {topItems.length > 0 ? ( + topItems.map(([name, count], _index) => { + const percentage = (count / stats.total_segments) * 100; + const isUnknown = name === '未分类' || name === '未关联模特'; + + return ( +
+
+
+ + {name} + {isUnknown && } + + {count} ({Math.round(percentage)}%) +
+
+
+
+
+
+ ); + }) + ) : ( +
+ +

暂无分布数据

+
+ )} +
+ + {/* 显示更多链接 */} + {Object.keys(viewMode === MaterialSegmentViewMode.ByClassification ? stats.classification_counts : stats.model_counts).length > 5 && ( +
+ +
+ )} +
+ + {/* 时长分析 */} +
+

+ + 时长分析 +

+ +
+ {/* 平均时长 */} +
+ 平均片段时长 + + {formatDuration(stats.total_duration / (stats.total_segments || 1))} + +
+ + {/* 时长分布建议 */} +
+
+ 总时长 + {formatDuration(stats.total_duration)} +
+
+ 片段数量 + {stats.total_segments} +
+
+ 已分类时长 + + {formatDuration(stats.total_duration * stats.classification_coverage)} + +
+
+ + {/* 效率指标 */} +
+
处理效率
+
+
+
+
+ + {formatPercentage(stats.classification_coverage)} + +
+
+
+
+
+
+ ); +}; diff --git a/apps/desktop/src/components/MaterialSegmentView.tsx b/apps/desktop/src/components/MaterialSegmentView.tsx new file mode 100644 index 0000000..867e894 --- /dev/null +++ b/apps/desktop/src/components/MaterialSegmentView.tsx @@ -0,0 +1,540 @@ +import React, { useEffect, useState } from 'react'; +import { + Grid, + List, + Search, + Filter, + ChevronDown, + ChevronRight, + BarChart3, + Users, + Clock, + Tag, + RefreshCw, + Trash2, + RotateCcw +} from 'lucide-react'; +import { useMaterialSegmentViewStore } from '../store/materialSegmentViewStore'; +import { MaterialSegmentViewMode, SegmentSortField, SortDirection, SegmentWithDetails } from '../types/materialSegmentView'; +import { MaterialSegmentStats } from './MaterialSegmentStats'; +import { MaterialSegmentFilters } from './MaterialSegmentFilters'; +import { MaterialSegmentDeleteDialog } from './MaterialSegmentDeleteDialog'; +import { MaterialSegmentReclassifyDialog } from './MaterialSegmentReclassifyDialog'; +import { MaterialSegmentModelDialog } from './MaterialSegmentModelDialog'; +import { VirtualizedSegmentList } from './VirtualizedSegmentList'; +import { MaterialSegmentPagination } from './MaterialSegmentPagination'; +import { LoadingSpinner } from './LoadingSpinner'; +import { ErrorMessage } from './ErrorMessage'; + +interface MaterialSegmentViewProps { + projectId: string; +} + +/** + * MaterialSegment聚合视图组件 + * 遵循 Tauri 开发规范的组件设计模式 + */ +export const MaterialSegmentView: React.FC = ({ projectId }) => { + const { + currentView, + viewMode, + isLoading, + error, + currentQuery, + selectedSegmentIds, + expandedGroups, + loadProjectSegmentView, + setViewMode, + updateQuery, + clearQuery, + clearSelection, + selectSegment, + deselectSegment, + expandAllGroups, + collapseAllGroups, + toggleGroup, + refreshCurrentView, + clearError, + } = useMaterialSegmentViewStore(); + + const [showFilters, setShowFilters] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [containerHeight, setContainerHeight] = useState(600); // 默认容器高度 + + // 分页状态 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + // 对话框状态 + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showReclassifyDialog, setShowReclassifyDialog] = useState(false); + const [showModelDialog, setShowModelDialog] = useState(false); + const [dialogLoading, setDialogLoading] = useState(false); + + // 容器引用 + const containerRef = React.useRef(null); + + // 获取选中的片段 + const getSelectedSegments = (): SegmentWithDetails[] => { + if (!currentView) return []; + + const allSegments: SegmentWithDetails[] = []; + + if (viewMode === MaterialSegmentViewMode.ByClassification) { + currentView.by_classification.forEach(group => { + group.segments.forEach(segment => { + if (selectedSegmentIds.has(segment.segment.id)) { + allSegments.push(segment); + } + }); + }); + } else { + currentView.by_model.forEach(group => { + group.segments.forEach(segment => { + if (selectedSegmentIds.has(segment.segment.id)) { + allSegments.push(segment); + } + }); + }); + } + + return allSegments; + }; + + // 初始加载数据 + useEffect(() => { + if (projectId) { + loadProjectSegmentView(projectId); + } + }, [projectId, loadProjectSegmentView]); + + // 监听容器大小变化 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { height } = entry.contentRect; + setContainerHeight(height - 100); // 减去头部和统计信息的高度 + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // 处理搜索 + const handleSearch = (term: string) => { + setSearchTerm(term); + setCurrentPage(1); // 重置到第一页 + + if (term.trim()) { + updateQuery({ + search_term: term.trim(), + page: 1 // 重置到第一页 + }); + } else { + const newQuery = { ...currentQuery }; + delete newQuery?.search_term; + if (newQuery) { + updateQuery({ + ...newQuery, + page: 1 // 重置到第一页 + }); + } + } + }; + + // 处理页码变化 + const handlePageChange = (page: number) => { + setCurrentPage(page); + updateQuery({ + page, + page_size: pageSize + }); + }; + + // 处理每页显示数量变化 + const handlePageSizeChange = (size: number) => { + setPageSize(size); + setCurrentPage(1); // 重置到第一页 + updateQuery({ + page: 1, + page_size: size + }); + }; + + // 处理视图模式切换 + const handleViewModeChange = (mode: MaterialSegmentViewMode) => { + setViewMode(mode); + }; + + // 处理排序 + const handleSort = (sortBy: SegmentSortField, direction: SortDirection) => { + updateQuery({ sort_by: sortBy, sort_direction: direction }); + }; + + // 处理刷新 + const handleRefresh = () => { + refreshCurrentView(); + }; + + // 批量操作处理函数 + const handleBatchDelete = async () => { + const selectedSegments = getSelectedSegments(); + if (selectedSegments.length === 0) return; + + setDialogLoading(true); + try { + // TODO: 实现批量删除API调用 + console.log('批量删除片段:', selectedSegments.map(s => s.segment.id)); + await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟API调用 + + clearSelection(); + await refreshCurrentView(); + setShowDeleteDialog(false); + } catch (error) { + console.error('批量删除失败:', error); + } finally { + setDialogLoading(false); + } + }; + + const handleBatchReclassify = async (options: any) => { + const selectedSegments = getSelectedSegments(); + if (selectedSegments.length === 0) return; + + setDialogLoading(true); + try { + // TODO: 实现批量重新分类API调用 + console.log('批量重新分类片段:', selectedSegments.map(s => s.segment.id), options); + await new Promise(resolve => setTimeout(resolve, 3000)); // 模拟API调用 + + clearSelection(); + await refreshCurrentView(); + setShowReclassifyDialog(false); + } catch (error) { + console.error('批量重新分类失败:', error); + } finally { + setDialogLoading(false); + } + }; + + const handleBatchModelAssociation = async (modelId: string | null) => { + const selectedSegments = getSelectedSegments(); + if (selectedSegments.length === 0) return; + + setDialogLoading(true); + try { + // TODO: 实现批量模特关联API调用 + console.log('批量关联模特:', selectedSegments.map(s => s.segment.id), modelId); + await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟API调用 + + clearSelection(); + await refreshCurrentView(); + setShowModelDialog(false); + } catch (error) { + console.error('批量模特关联失败:', error); + } finally { + setDialogLoading(false); + } + }; + + // 获取当前显示的分组 + const getCurrentGroups = () => { + if (!currentView) return []; + + return viewMode === MaterialSegmentViewMode.ByClassification + ? currentView.by_classification + : currentView.by_model; + }; + + // 计算分页信息 + const totalItems = currentView?.stats.total_segments || 0; + const totalPages = Math.ceil(totalItems / pageSize); + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 头部工具栏 */} +
+
+
+

素材片段管理

+ + {/* 视图模式切换 */} +
+ + +
+
+ +
+ {/* 刷新按钮 */} + + + {/* 统计信息 */} + {currentView && ( +
+ + + 总计: {currentView.stats.total_segments} + + + + 已分类: {currentView.stats.classified_segments} + + + + 总时长: {Math.round(currentView.stats.total_duration)}s + +
+ )} +
+
+ + {/* 搜索和过滤器 */} +
+ {/* 搜索框 */} +
+ + handleSearch(e.target.value)} + 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" + /> +
+ + {/* 过滤器按钮 */} + + + {/* 批量操作 */} + {selectedSegmentIds.size > 0 && ( +
+ + 已选择 {selectedSegmentIds.size} 个片段 + +
+ + + + +
+
+ )} + + {/* 展开/折叠所有分组 */} +
+ + +
+
+ + {/* 过滤器面板 */} + {showFilters && ( +
+ +
+ )} +
+ + {/* 主内容区域 */} +
+ {isLoading ? ( +
+ +
+ ) : currentView ? ( +
+ {/* 统计概览 */} +
+ +
+ + {/* 分组列表 */} +
+ {getCurrentGroups().length > 0 ? ( + { + toggleGroup(groupId); + }} + onSelectSegment={(segmentId) => { + if (selectedSegmentIds.has(segmentId)) { + deselectSegment(segmentId); + } else { + selectSegment(segmentId); + } + }} + height={containerHeight} + /> + ) : ( +
+
+ +
+

暂无符合条件的素材片段

+ {currentQuery?.search_term && ( + + )} +
+ )} +
+ + {/* 分页控件 */} + {currentView && totalPages > 1 && ( + + )} +
+ ) : ( +
+
+
+ +
+

暂无数据

+
+
+ )} +
+ + {/* 批量操作对话框 */} + setShowDeleteDialog(false)} + onConfirm={handleBatchDelete} + isLoading={dialogLoading} + /> + + setShowReclassifyDialog(false)} + onConfirm={handleBatchReclassify} + isLoading={dialogLoading} + /> + + setShowModelDialog(false)} + onConfirm={handleBatchModelAssociation} + isLoading={dialogLoading} + /> +
+ ); +}; diff --git a/apps/desktop/src/components/VirtualizedSegmentList.tsx b/apps/desktop/src/components/VirtualizedSegmentList.tsx new file mode 100644 index 0000000..9690f81 --- /dev/null +++ b/apps/desktop/src/components/VirtualizedSegmentList.tsx @@ -0,0 +1,128 @@ +import React, { useMemo, useCallback } from 'react'; +import { VariableSizeList as List } from 'react-window'; +import { + ClassificationGroup, + ModelGroup, + MaterialSegmentViewMode +} from '../types/materialSegmentView'; +import { MaterialSegmentGroup } from './MaterialSegmentGroup'; + +interface VirtualizedSegmentListProps { + groups: (ClassificationGroup | ModelGroup)[]; + viewMode: MaterialSegmentViewMode; + selectedSegmentIds: Set; + expandedGroups: Set; + onToggleExpand: (groupId: string) => void; + onSelectSegment: (segmentId: string) => void; + height: number; +} + +/** + * 虚拟化MaterialSegment列表组件 + * 遵循 Tauri 开发规范的性能优化组件设计模式 + */ +export const VirtualizedSegmentList: React.FC = ({ + groups, + viewMode, + selectedSegmentIds, + expandedGroups, + onToggleExpand, + onSelectSegment, + height, +}) => { + // 计算每个分组的高度 + const getItemHeight = useCallback((index: number) => { + const group = groups[index]; + if (!group) return 120; // 默认折叠高度 + + const groupId = viewMode === MaterialSegmentViewMode.ByClassification + ? (group as ClassificationGroup).category + : (group as ModelGroup).model_id; + + const isExpanded = expandedGroups.has(groupId); + + if (!isExpanded) { + return 120; // 折叠状态的高度 + } + + // 展开状态的高度计算 + const baseHeight = 120; // 头部高度 + const segmentCount = group.segments.length; + const segmentsPerRow = 4; // 每行显示的片段数 + const rowCount = Math.ceil(segmentCount / segmentsPerRow); + const segmentRowHeight = 280; // 每行片段的高度 + const footerHeight = 60; // 底部统计信息高度 + + return baseHeight + (rowCount * segmentRowHeight) + footerHeight; + }, [groups, viewMode, expandedGroups]); + + // 计算总高度 + const totalHeight = useMemo(() => { + return groups.reduce((total, _, index) => total + getItemHeight(index), 0); + }, [groups, getItemHeight]); + + // 渲染单个分组项 + const renderItem = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { + const group = groups[index]; + if (!group) return null; + + return ( +
+
+ +
+
+ ); + }, [groups, viewMode, expandedGroups, selectedSegmentIds, onToggleExpand, onSelectSegment]); + + // 如果分组数量较少,不使用虚拟化 + if (groups.length <= 10) { + return ( +
+ {groups.map((group, _index) => ( + + ))} +
+ ); + } + + return ( + getItemHeight(index)} + width="100%" + className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100" + > + {renderItem} + + ); +}; diff --git a/apps/desktop/src/pages/ProjectDetails.tsx b/apps/desktop/src/pages/ProjectDetails.tsx index c572cde..6e92f00 100644 --- a/apps/desktop/src/pages/ProjectDetails.tsx +++ b/apps/desktop/src/pages/ProjectDetails.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, FolderOpen, Upload, FileVideo, FileAudio, FileImage, HardDrive, Brain, Loader2, Link } from 'lucide-react'; +import { ArrowLeft, FolderOpen, Upload, FileVideo, FileAudio, FileImage, HardDrive, Brain, Loader2, Link, Layers } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { useProjectStore } from '../store/projectStore'; import { useMaterialStore } from '../store/materialStore'; @@ -28,6 +28,7 @@ import { } from '../types/projectTemplateBinding'; import { MaterialMatchingService } from '../services/materialMatchingService'; import { MaterialMatchingResult, MaterialMatchingRequest } from '../types/materialMatching'; +import { MaterialSegmentView } from '../components/MaterialSegmentView'; /** * 项目详情页面组件 @@ -83,7 +84,7 @@ export const ProjectDetails: React.FC = () => { const [editingBinding, setEditingBinding] = useState(null); const [showMaterialEditDialog, setShowMaterialEditDialog] = useState(false); const [editingMaterial, setEditingMaterial] = useState(null); - const [activeTab, setActiveTab] = useState<'materials' | 'templates' | 'debug' | 'ai-logs'>('materials'); + const [activeTab, setActiveTab] = useState<'materials' | 'segments' | 'templates' | 'debug' | 'ai-logs'>('materials'); const [_batchClassificationResult, setBatchClassificationResult] = useState(null); // 素材匹配状态 @@ -572,6 +573,20 @@ export const ProjectDetails: React.FC = () => { 素材
+
)} + {/* 片段管理选项卡 */} + {activeTab === 'segments' && project && ( +
+ +
+ )} + {/* 模板绑定选项卡 */} {activeTab === 'templates' && project && (
diff --git a/apps/desktop/src/store/materialSegmentViewStore.ts b/apps/desktop/src/store/materialSegmentViewStore.ts new file mode 100644 index 0000000..e809a9c --- /dev/null +++ b/apps/desktop/src/store/materialSegmentViewStore.ts @@ -0,0 +1,261 @@ +import { create } from 'zustand'; +import { invoke } from '@tauri-apps/api/core'; +import { + MaterialSegmentView, + MaterialSegmentQuery, + MaterialSegmentViewMode, + SegmentActionRequest, + SegmentActionType, +} from '../types/materialSegmentView'; + +interface MaterialSegmentViewState { + // 数据状态 + currentView: MaterialSegmentView | null; + viewMode: MaterialSegmentViewMode; + isLoading: boolean; + error: string | null; + + // 查询状态 + currentQuery: MaterialSegmentQuery | null; + + // UI状态 + selectedSegmentIds: Set; + expandedGroups: Set; + + // Actions + loadProjectSegmentView: (projectId: string) => Promise; + loadProjectSegmentViewWithQuery: (query: MaterialSegmentQuery) => Promise; + setViewMode: (mode: MaterialSegmentViewMode) => void; + updateQuery: (updates: Partial) => void; + clearQuery: () => void; + + // 选择管理 + selectSegment: (segmentId: string) => void; + deselectSegment: (segmentId: string) => void; + selectAllSegments: () => void; + clearSelection: () => void; + + // 分组管理 + expandGroup: (groupId: string) => void; + collapseGroup: (groupId: string) => void; + toggleGroup: (groupId: string) => void; + expandAllGroups: () => void; + collapseAllGroups: () => void; + + // 片段操作 + executeSegmentAction: (request: SegmentActionRequest) => Promise; + + // 工具方法 + refreshCurrentView: () => Promise; + clearError: () => void; +} + +export const useMaterialSegmentViewStore = create((set, get) => ({ + // 初始状态 + currentView: null, + viewMode: MaterialSegmentViewMode.ByClassification, + isLoading: false, + error: null, + currentQuery: null, + selectedSegmentIds: new Set(), + expandedGroups: new Set(), + + // 加载项目的MaterialSegment聚合视图 + loadProjectSegmentView: async (projectId: string) => { + set({ isLoading: true, error: null }); + try { + const view = await invoke('get_project_segment_view', { projectId }); + set({ + currentView: view, + isLoading: false, + currentQuery: { project_id: projectId } + }); + } catch (error) { + const errorMessage = typeof error === 'string' ? error : '加载MaterialSegment视图失败'; + set({ error: errorMessage, isLoading: false }); + throw new Error(errorMessage); + } + }, + + // 根据查询条件加载MaterialSegment聚合视图 + loadProjectSegmentViewWithQuery: async (query: MaterialSegmentQuery) => { + set({ isLoading: true, error: null }); + try { + const view = await invoke('get_project_segment_view_with_query', { query }); + set({ + currentView: view, + isLoading: false, + currentQuery: query + }); + } catch (error) { + const errorMessage = typeof error === 'string' ? error : '加载MaterialSegment视图失败'; + set({ error: errorMessage, isLoading: false }); + throw new Error(errorMessage); + } + }, + + // 设置视图模式 + setViewMode: (mode: MaterialSegmentViewMode) => { + set({ viewMode: mode }); + }, + + // 更新查询条件 + updateQuery: (updates: Partial) => { + const currentQuery = get().currentQuery; + if (!currentQuery) return; + + const newQuery = { ...currentQuery, ...updates }; + get().loadProjectSegmentViewWithQuery(newQuery); + }, + + // 清除查询条件 + clearQuery: () => { + const currentQuery = get().currentQuery; + if (!currentQuery) return; + + const baseQuery: MaterialSegmentQuery = { + project_id: currentQuery.project_id, + }; + get().loadProjectSegmentViewWithQuery(baseQuery); + }, + + // 选择片段 + selectSegment: (segmentId: string) => { + set(state => ({ + selectedSegmentIds: new Set([...state.selectedSegmentIds, segmentId]) + })); + }, + + // 取消选择片段 + deselectSegment: (segmentId: string) => { + set(state => { + const newSelection = new Set(state.selectedSegmentIds); + newSelection.delete(segmentId); + return { selectedSegmentIds: newSelection }; + }); + }, + + // 选择所有片段 + selectAllSegments: () => { + const view = get().currentView; + if (!view) return; + + const allSegmentIds = new Set(); + + // 根据当前视图模式收集所有片段ID + if (get().viewMode === MaterialSegmentViewMode.ByClassification) { + view.by_classification.forEach(group => { + group.segments.forEach(segment => { + allSegmentIds.add(segment.segment.id); + }); + }); + } else { + view.by_model.forEach(group => { + group.segments.forEach(segment => { + allSegmentIds.add(segment.segment.id); + }); + }); + } + + set({ selectedSegmentIds: allSegmentIds }); + }, + + // 清除选择 + clearSelection: () => { + set({ selectedSegmentIds: new Set() }); + }, + + // 展开分组 + expandGroup: (groupId: string) => { + set(state => ({ + expandedGroups: new Set([...state.expandedGroups, groupId]) + })); + }, + + // 折叠分组 + collapseGroup: (groupId: string) => { + set(state => { + const newExpanded = new Set(state.expandedGroups); + newExpanded.delete(groupId); + return { expandedGroups: newExpanded }; + }); + }, + + // 切换分组展开状态 + toggleGroup: (groupId: string) => { + const expandedGroups = get().expandedGroups; + if (expandedGroups.has(groupId)) { + get().collapseGroup(groupId); + } else { + get().expandGroup(groupId); + } + }, + + // 展开所有分组 + expandAllGroups: () => { + const view = get().currentView; + if (!view) return; + + const allGroupIds = new Set(); + + if (get().viewMode === MaterialSegmentViewMode.ByClassification) { + view.by_classification.forEach(group => { + allGroupIds.add(group.category); + }); + } else { + view.by_model.forEach(group => { + allGroupIds.add(group.model_id); + }); + } + + set({ expandedGroups: allGroupIds }); + }, + + // 折叠所有分组 + collapseAllGroups: () => { + set({ expandedGroups: new Set() }); + }, + + // 执行片段操作 + executeSegmentAction: async (request: SegmentActionRequest) => { + set({ isLoading: true, error: null }); + try { + // 根据操作类型调用相应的后端命令 + switch (request.action_type) { + case SegmentActionType.Reclassify: + // TODO: 实现重新分类逻辑 + break; + case SegmentActionType.EditModel: + // TODO: 实现编辑模特关联逻辑 + break; + case SegmentActionType.Delete: + // TODO: 实现删除逻辑 + break; + case SegmentActionType.ViewDetails: + // TODO: 实现查看详情逻辑 + break; + } + + // 刷新当前视图 + await get().refreshCurrentView(); + set({ isLoading: false }); + } catch (error) { + const errorMessage = typeof error === 'string' ? error : '执行操作失败'; + set({ error: errorMessage, isLoading: false }); + throw new Error(errorMessage); + } + }, + + // 刷新当前视图 + refreshCurrentView: async () => { + const currentQuery = get().currentQuery; + if (!currentQuery) return; + + await get().loadProjectSegmentViewWithQuery(currentQuery); + }, + + // 清除错误 + clearError: () => { + set({ error: null }); + }, +})); diff --git a/apps/desktop/src/types/materialSegmentView.ts b/apps/desktop/src/types/materialSegmentView.ts new file mode 100644 index 0000000..2794d1a --- /dev/null +++ b/apps/desktop/src/types/materialSegmentView.ts @@ -0,0 +1,139 @@ +import { MaterialSegment } from './material'; + +/** + * MaterialSegment聚合视图数据类型 + */ +export interface MaterialSegmentView { + project_id: string; + by_classification: ClassificationGroup[]; + by_model: ModelGroup[]; + stats: MaterialSegmentStats; +} + +/** + * AI分类分组 + */ +export interface ClassificationGroup { + category: string; + segment_count: number; + total_duration: number; + segments: SegmentWithDetails[]; +} + +/** + * 模特分组 + */ +export interface ModelGroup { + model_id: string; + model_name: string; + segment_count: number; + total_duration: number; + segments: SegmentWithDetails[]; +} + +/** + * 带详细信息的片段 + */ +export interface SegmentWithDetails { + segment: MaterialSegment; + material_name: string; + material_type: string; + classification?: ClassificationInfo; + model?: ModelInfo; +} + +/** + * 分类信息 + */ +export interface ClassificationInfo { + category: string; + confidence: number; + reasoning: string; + features: string[]; + product_match: boolean; + quality_score: number; +} + +/** + * 模特信息 + */ +export interface ModelInfo { + id: string; + name: string; + model_type: string; +} + +/** + * 片段统计信息 + */ +export interface MaterialSegmentStats { + total_segments: number; + classified_segments: number; + unclassified_segments: number; + classification_coverage: number; + classification_counts: Record; + model_counts: Record; + total_duration: number; +} + +/** + * 片段查询参数 + */ +export interface MaterialSegmentQuery { + project_id: string; + category_filter?: string; + model_id_filter?: string; + min_duration?: number; + max_duration?: number; + search_term?: string; + sort_by?: SegmentSortField; + sort_direction?: SortDirection; + page_size?: number; + page?: number; +} + +/** + * 片段排序字段 + */ +export enum SegmentSortField { + CreatedAt = 'CreatedAt', + Duration = 'Duration', + Category = 'Category', + Model = 'Model', + Confidence = 'Confidence', +} + +/** + * 排序方向 + */ +export enum SortDirection { + Ascending = 'Ascending', + Descending = 'Descending', +} + +/** + * 视图模式 + */ +export enum MaterialSegmentViewMode { + ByClassification = 'by_classification', + ByModel = 'by_model', +} + +/** + * 片段操作类型 + */ +export enum SegmentActionType { + Reclassify = 'reclassify', + EditModel = 'edit_model', + Delete = 'delete', + ViewDetails = 'view_details', +} + +/** + * 片段操作请求 + */ +export interface SegmentActionRequest { + segment_id: string; + action_type: SegmentActionType; + data?: any; +} diff --git a/package.json b/package.json index feff9f5..f8a75d3 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,9 @@ "multi-language" ], "author": "imeepos", - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/react-window": "^1.8.8", + "react-window": "^1.8.11" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89795d9..dd2606b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,13 @@ settings: importers: .: + dependencies: + '@types/react-window': + specifier: ^1.8.8 + version: 1.8.8 + react-window: + specifier: ^1.8.11 + version: 1.8.11(react-dom@18.3.1)(react@18.3.1) devDependencies: '@types/node': specifier: ^24.0.13 @@ -65,6 +72,9 @@ importers: react-router-dom: specifier: ^6.20.1 version: 6.30.1(react-dom@18.3.1)(react@18.3.1) + react-window: + specifier: ^1.8.11 + version: 1.8.11(react-dom@18.3.1)(react@18.3.1) zustand: specifier: ^4.4.7 version: 4.5.7(@types/react@18.3.23)(react@18.3.1) @@ -87,6 +97,9 @@ importers: '@types/react-dom': specifier: ^18.3.1 version: 18.3.7(@types/react@18.3.23) + '@types/react-window': + specifier: ^1.8.8 + version: 1.8.8 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.6.0(vite@6.3.5) @@ -1382,6 +1395,11 @@ packages: '@types/react': 18.3.23 dev: true + /@types/react-window@1.8.8: + resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + dependencies: + '@types/react': 18.3.23 + /@types/react@18.3.23: resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} dependencies: @@ -2953,6 +2971,10 @@ packages: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} dev: true + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true @@ -3431,6 +3453,19 @@ packages: react: 18.3.1 dev: false + /react-window@1.8.11(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@babel/runtime': 7.27.6 + memoize-one: 5.2.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'}