feat: 添加项目详情/素材管理的MaterialSegment聚合视图功能
- 新增MaterialSegment聚合视图,支持按AI分类和模特聚合展示 - 实现后端MaterialSegmentViewService和相关API命令 - 创建前端React组件:MaterialSegmentView、MaterialSegmentGroup、MaterialSegmentCard等 - 添加MaterialSegment详细信息模态框和批量操作对话框 - 实现搜索、筛选、排序、分页功能 - 集成虚拟滚动和性能优化 - 在ProjectDetails页面添加片段管理选项卡 - 遵循promptx开发规范和UI/UX设计标准
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<MaterialRepository>,
|
||||
video_classification_repository: Arc<VideoClassificationRepository>,
|
||||
model_repository: Arc<ModelRepository>,
|
||||
}
|
||||
|
||||
impl MaterialSegmentViewService {
|
||||
/// 创建新的MaterialSegment聚合视图服务实例
|
||||
pub fn new(
|
||||
material_repository: Arc<MaterialRepository>,
|
||||
video_classification_repository: Arc<VideoClassificationRepository>,
|
||||
model_repository: Arc<ModelRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
material_repository,
|
||||
video_classification_repository,
|
||||
model_repository,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取项目的MaterialSegment聚合视图
|
||||
pub async fn get_project_segment_view(&self, project_id: &str) -> Result<MaterialSegmentView> {
|
||||
// 获取项目的所有素材
|
||||
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<MaterialSegmentView> {
|
||||
// 获取基础视图
|
||||
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<String, Model>,
|
||||
) -> Result<Vec<SegmentWithDetails>> {
|
||||
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<HashMap<String, Model>> {
|
||||
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<ClassificationInfo> {
|
||||
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<String, Model>,
|
||||
) -> Option<ModelInfo> {
|
||||
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<ClassificationGroup> {
|
||||
let mut groups: HashMap<String, Vec<&SegmentWithDetails>> = 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<ModelGroup> {
|
||||
let mut groups: HashMap<String, Vec<&SegmentWithDetails>> = 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<f64>,
|
||||
max_duration: Option<f64>,
|
||||
) {
|
||||
// 过滤分类视图
|
||||
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<SegmentWithDetails> = 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<SegmentWithDetails> = 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<SortDirection>,
|
||||
) {
|
||||
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<SegmentWithDetails>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
176
apps/desktop/src-tauri/src/data/models/material_segment_view.rs
Normal file
176
apps/desktop/src-tauri/src/data/models/material_segment_view.rs
Normal file
@@ -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<ClassificationGroup>,
|
||||
/// 按模特聚合的片段
|
||||
pub by_model: Vec<ModelGroup>,
|
||||
/// 统计信息
|
||||
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<SegmentWithDetails>,
|
||||
}
|
||||
|
||||
/// 模特分组
|
||||
#[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<SegmentWithDetails>,
|
||||
}
|
||||
|
||||
/// 带详细信息的片段
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SegmentWithDetails {
|
||||
/// 片段信息
|
||||
pub segment: MaterialSegment,
|
||||
/// 素材名称
|
||||
pub material_name: String,
|
||||
/// 素材类型
|
||||
pub material_type: String,
|
||||
/// 分类信息
|
||||
pub classification: Option<ClassificationInfo>,
|
||||
/// 模特信息
|
||||
pub model: Option<ModelInfo>,
|
||||
}
|
||||
|
||||
/// 分类信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClassificationInfo {
|
||||
/// 分类名称
|
||||
pub category: String,
|
||||
/// 置信度
|
||||
pub confidence: f64,
|
||||
/// 分类理由
|
||||
pub reasoning: String,
|
||||
/// 关键特征
|
||||
pub features: Vec<String>,
|
||||
/// 商品匹配度
|
||||
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<String, usize>,
|
||||
/// 模特统计
|
||||
pub model_counts: HashMap<String, usize>,
|
||||
/// 总时长(秒)
|
||||
pub total_duration: f64,
|
||||
}
|
||||
|
||||
/// 片段查询参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialSegmentQuery {
|
||||
/// 项目ID
|
||||
pub project_id: String,
|
||||
/// 分类过滤
|
||||
pub category_filter: Option<String>,
|
||||
/// 模特ID过滤
|
||||
pub model_id_filter: Option<String>,
|
||||
/// 最小时长过滤(秒)
|
||||
pub min_duration: Option<f64>,
|
||||
/// 最大时长过滤(秒)
|
||||
pub max_duration: Option<f64>,
|
||||
/// 搜索关键词
|
||||
pub search_term: Option<String>,
|
||||
/// 排序字段
|
||||
pub sort_by: Option<SegmentSortField>,
|
||||
/// 排序方向
|
||||
pub sort_direction: Option<SortDirection>,
|
||||
/// 分页大小
|
||||
pub page_size: Option<usize>,
|
||||
/// 页码
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
/// 片段排序字段
|
||||
#[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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MaterialSegmentView, String> {
|
||||
// 获取数据库实例
|
||||
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<MaterialSegmentView, String> {
|
||||
// 获取数据库实例
|
||||
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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
312
apps/desktop/src/components/MaterialSegmentCard.tsx
Normal file
312
apps/desktop/src/components/MaterialSegmentCard.tsx
Normal file
@@ -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<MaterialSegmentCardProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={`relative bg-white rounded-lg border-2 transition-all duration-200 hover:shadow-md cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{/* 选择指示器 */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 left-2 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-white rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作菜单 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions(!showActions);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showActions && (
|
||||
<div className="absolute right-0 top-8 bg-white border border-gray-200 rounded-lg shadow-lg py-1 z-10 min-w-[120px]">
|
||||
<button
|
||||
onClick={(e) => handleAction(SegmentActionType.ViewDetails, e)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleAction(SegmentActionType.Reclassify, e)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重新分类
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleAction(SegmentActionType.EditModel, e)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
编辑模特
|
||||
</button>
|
||||
<div className="border-t border-gray-100 my-1" />
|
||||
<button
|
||||
onClick={(e) => handleAction(SegmentActionType.Delete, e)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* 片段基本信息 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-900 truncate flex-1 mr-2">
|
||||
{material_name}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||||
#{segment.segment_index}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{formatTimestamp(segment.start_time)} - {formatTimestamp(segment.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium">{formatDuration(segment.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex items-center text-xs text-gray-500 mb-3">
|
||||
<FileVideo className="w-3 h-3 mr-1" />
|
||||
<span className="truncate">{segment.file_path.split('/').pop()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类信息 */}
|
||||
{classification && (
|
||||
<div className="mb-3 p-2 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Tag className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-sm font-medium text-gray-900">{classification.category}</span>
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-1 rounded-full ${getConfidenceColor(classification.confidence)}`}>
|
||||
{Math.round(classification.confidence * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量评分 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-1">
|
||||
<TrendingUp className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-gray-600">质量评分</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className={`w-3 h-3 ${getQualityColor(classification.quality_score)}`} />
|
||||
<span className={getQualityColor(classification.quality_score)}>
|
||||
{Math.round(classification.quality_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 商品匹配指示器 */}
|
||||
{classification.product_match && (
|
||||
<div className="mt-1 text-xs text-green-600 bg-green-100 px-2 py-1 rounded inline-block">
|
||||
商品匹配
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模特信息 */}
|
||||
{model && (
|
||||
<div className="mb-3 p-2 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-3 h-3 text-green-500" />
|
||||
<span className="text-sm font-medium text-gray-900">{model.name}</span>
|
||||
<span className="text-xs text-gray-500">({model.model_type})</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关键特征 */}
|
||||
{classification && classification.features.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-600 mb-1">关键特征</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{classification.features.slice(0, 3).map((feature, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
{classification.features.length > 3 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
+{classification.features.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{formatDate(segment.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Hash className="w-3 h-3" />
|
||||
<span className="font-mono">{segment.id.slice(-8)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 播放按钮覆盖层 */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-20 transition-all duration-200 rounded-lg flex items-center justify-center opacity-0 hover:opacity-100">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: 实现视频播放逻辑
|
||||
console.log('播放片段:', segment.file_path);
|
||||
}}
|
||||
className="bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-3 transition-all duration-200 transform hover:scale-110"
|
||||
>
|
||||
<Play className="w-6 h-6 text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 详情模态框 */}
|
||||
<MaterialSegmentDetailModal
|
||||
segmentWithDetails={segmentWithDetails}
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
onPlay={() => {
|
||||
// TODO: 实现视频播放逻辑
|
||||
console.log('播放片段:', segment.file_path);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
193
apps/desktop/src/components/MaterialSegmentDeleteDialog.tsx
Normal file
193
apps/desktop/src/components/MaterialSegmentDeleteDialog.tsx
Normal file
@@ -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<MaterialSegmentDeleteDialogProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full">
|
||||
{/* 对话框头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{isBatch ? '批量删除片段' : '删除片段'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isBatch ? `确认删除 ${segments.length} 个片段` : '确认删除该片段'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 对话框内容 */}
|
||||
<div className="p-6">
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-700 mb-3">
|
||||
{isBatch
|
||||
? '您即将删除以下片段,此操作不可撤销:'
|
||||
: '您即将删除该片段,此操作不可撤销:'
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||
{isBatch ? (
|
||||
<div className="space-y-2">
|
||||
{segments.slice(0, 5).map((segmentWithDetails, _index) => (
|
||||
<div key={segmentWithDetails.segment.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-900 truncate flex-1 mr-2">
|
||||
{segmentWithDetails.material_name}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2 text-gray-600">
|
||||
<span>#{segmentWithDetails.segment.segment_index}</span>
|
||||
<span>{formatDuration(segmentWithDetails.segment.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{segments.length > 5 && (
|
||||
<div className="text-sm text-gray-500 text-center pt-2 border-t border-gray-200">
|
||||
还有 {segments.length - 5} 个片段...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">素材名称</span>
|
||||
<span className="text-sm text-gray-900">{segments[0].material_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">片段索引</span>
|
||||
<span className="text-sm text-gray-900">#{segments[0].segment.segment_index}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">片段时长</span>
|
||||
<span className="text-sm text-gray-900">{formatDuration(segments[0].segment.duration)}</span>
|
||||
</div>
|
||||
{segments[0].classification && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">分类</span>
|
||||
<span className="text-sm text-gray-900">{segments[0].classification.category}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{isBatch && (
|
||||
<div className="mt-4 p-3 bg-red-50 rounded-lg">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-red-700 font-medium">删除统计</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-red-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>片段数量</span>
|
||||
<span className="font-medium">{segments.length} 个</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>总时长</span>
|
||||
<span className="font-medium">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>已分类片段</span>
|
||||
<span className="font-medium">
|
||||
{segments.filter(s => s.classification).length} 个
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 警告信息 */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-600 mt-0.5 mr-2 flex-shrink-0" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
<p className="font-medium mb-1">注意事项:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>删除操作不可撤销</li>
|
||||
<li>片段文件将从磁盘中删除</li>
|
||||
<li>相关的AI分类数据也将被删除</li>
|
||||
{isBatch && <li>批量删除可能需要一些时间</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对话框底部 */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
删除中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isBatch ? `删除 ${segments.length} 个片段` : '删除片段'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
343
apps/desktop/src/components/MaterialSegmentDetailModal.tsx
Normal file
343
apps/desktop/src/components/MaterialSegmentDetailModal.tsx
Normal file
@@ -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<MaterialSegmentDetailModalProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* 模态框头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<FileVideo className="w-6 h-6 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{material_name}</h2>
|
||||
<p className="text-sm text-gray-600">片段 #{segment.segment_index}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 模态框内容 */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 左侧:基本信息 */}
|
||||
<div className="space-y-6">
|
||||
{/* 片段基本信息 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Info className="w-5 h-5 mr-2 text-blue-500" />
|
||||
基本信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">片段ID</span>
|
||||
<span className="text-sm font-mono text-gray-900 bg-gray-200 px-2 py-1 rounded">
|
||||
{segment.id.slice(-12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">素材类型</span>
|
||||
<span className="text-sm text-gray-900">{material_type}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">片段索引</span>
|
||||
<span className="text-sm text-gray-900">#{segment.segment_index}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">创建时间</span>
|
||||
<span className="text-sm text-gray-900">{formatDate(segment.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Clock className="w-5 h-5 mr-2 text-blue-500" />
|
||||
时间信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">开始时间</span>
|
||||
<span className="text-sm font-mono text-blue-900 bg-blue-200 px-2 py-1 rounded">
|
||||
{formatTimestamp(segment.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">结束时间</span>
|
||||
<span className="text-sm font-mono text-blue-900 bg-blue-200 px-2 py-1 rounded">
|
||||
{formatTimestamp(segment.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">片段时长</span>
|
||||
<span className="text-lg font-bold text-blue-900">
|
||||
{formatDuration(segment.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="bg-purple-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<MapPin className="w-5 h-5 mr-2 text-purple-500" />
|
||||
文件信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block mb-1">文件路径</span>
|
||||
<span className="text-sm font-mono text-gray-900 bg-gray-200 px-2 py-1 rounded block break-all">
|
||||
{segment.file_path}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">文件名</span>
|
||||
<span className="text-sm text-gray-900 truncate max-w-48">
|
||||
{segment.file_path.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 播放控制 */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4">
|
||||
<button
|
||||
onClick={onPlay}
|
||||
className="w-full flex items-center justify-center space-x-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white py-3 px-4 rounded-lg hover:from-blue-600 hover:to-purple-600 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
<span className="font-medium">播放片段</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:AI分析信息 */}
|
||||
<div className="space-y-6">
|
||||
{/* AI分类信息 */}
|
||||
{classification ? (
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Tag className="w-5 h-5 mr-2 text-green-500" />
|
||||
AI分类结果
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 分类名称和置信度 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-lg font-semibold text-gray-900">{classification.category}</span>
|
||||
{classification.product_match && (
|
||||
<span className="ml-2 text-xs bg-green-200 text-green-800 px-2 py-1 rounded-full">
|
||||
商品匹配
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${getConfidenceInfo(classification.confidence).color}`}>
|
||||
{Math.round(classification.confidence * 100)}% ({getConfidenceInfo(classification.confidence).desc})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量评分 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
质量评分
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Star className={`w-4 h-4 ${getQualityInfo(classification.quality_score).color}`} />
|
||||
<span className={`text-sm font-medium ${getQualityInfo(classification.quality_score).color}`}>
|
||||
{Math.round(classification.quality_score * 100)}% ({getQualityInfo(classification.quality_score).desc})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类理由 */}
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block mb-2">分类理由</span>
|
||||
<p className="text-sm text-gray-900 bg-white p-3 rounded border">
|
||||
{classification.reasoning}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 关键特征 */}
|
||||
{classification.features.length > 0 && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block mb-2">关键特征</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classification.features.map((feature, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs bg-green-200 text-green-800 px-2 py-1 rounded-full"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-orange-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<AlertCircle className="w-5 h-5 mr-2 text-orange-500" />
|
||||
AI分类状态
|
||||
</h3>
|
||||
<div className="text-center py-6">
|
||||
<AlertCircle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600 mb-4">该片段尚未进行AI分类</p>
|
||||
<button className="inline-flex items-center px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
立即分类
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模特信息 */}
|
||||
{model ? (
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Users className="w-5 h-5 mr-2 text-blue-500" />
|
||||
关联模特
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">模特ID</span>
|
||||
<span className="text-sm font-mono text-blue-900 bg-blue-200 px-2 py-1 rounded">
|
||||
{model.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">模特名称</span>
|
||||
<span className="text-lg font-semibold text-blue-900">{model.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">模特类型</span>
|
||||
<span className="text-sm text-gray-900">{model.model_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<Users className="w-5 h-5 mr-2 text-gray-500" />
|
||||
模特关联
|
||||
</h3>
|
||||
<div className="text-center py-6">
|
||||
<Users className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-600 mb-4">该片段尚未关联模特</p>
|
||||
<button className="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
关联模特
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模态框底部 */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={onPlay}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
播放片段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
383
apps/desktop/src/components/MaterialSegmentFilters.tsx
Normal file
383
apps/desktop/src/components/MaterialSegmentFilters.tsx
Normal file
@@ -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<MaterialSegmentQuery>) => void;
|
||||
onClearQuery: () => void;
|
||||
onSort: (sortBy: SegmentSortField, direction: SortDirection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MaterialSegment过滤器组件
|
||||
* 遵循 Tauri 开发规范的组件设计模式
|
||||
*/
|
||||
export const MaterialSegmentFilters: React.FC<MaterialSegmentFiltersProps> = ({
|
||||
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<MaterialSegmentQuery> = {};
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* 过滤器标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 flex items-center">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
过滤条件
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="text-sm text-red-600 hover:text-red-800 flex items-center transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
清除所有
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 过滤器网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 分类过滤 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<Tag className="w-3 h-3 inline mr-1" />
|
||||
分类
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入分类名称"
|
||||
value={localFilters.categoryFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模特过滤 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<Users className="w-3 h-3 inline mr-1" />
|
||||
模特ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入模特ID"
|
||||
value={localFilters.modelIdFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最小时长 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
最小时长(秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={localFilters.minDuration}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最大时长 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
最大时长(秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="无限制"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={localFilters.maxDuration}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 排序和分页 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4 border-t border-gray-200">
|
||||
{/* 排序字段 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<ArrowUpDown className="w-3 h-3 inline mr-1" />
|
||||
排序字段
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={localFilters.sortBy}
|
||||
onChange={(value) => setLocalFilters(prev => ({ ...prev, sortBy: value as SegmentSortField }))}
|
||||
options={sortOptions}
|
||||
placeholder="选择排序字段"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 排序方向 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
排序方向
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setLocalFilters(prev => ({ ...prev, sortDirection: SortDirection.Ascending }))}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors ${
|
||||
localFilters.sortDirection === SortDirection.Ascending
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 inline mr-1" />
|
||||
升序
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocalFilters(prev => ({ ...prev, sortDirection: SortDirection.Descending }))}
|
||||
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-colors ${
|
||||
localFilters.sortDirection === SortDirection.Descending
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4 inline mr-1" />
|
||||
降序
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页面大小 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
每页显示
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={localFilters.pageSize}
|
||||
onChange={(value) => setLocalFilters(prev => ({ ...prev, pageSize: value }))}
|
||||
options={pageSizeOptions}
|
||||
placeholder="选择页面大小"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
应用过滤器
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 活动过滤器显示 */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{localFilters.categoryFilter && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
|
||||
分类: {localFilters.categoryFilter}
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalFilters(prev => ({ ...prev, categoryFilter: '' }));
|
||||
onQueryUpdate({ category_filter: undefined });
|
||||
}}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{localFilters.modelIdFilter && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
|
||||
模特: {localFilters.modelIdFilter}
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalFilters(prev => ({ ...prev, modelIdFilter: '' }));
|
||||
onQueryUpdate({ model_id_filter: undefined });
|
||||
}}
|
||||
className="ml-1 text-green-600 hover:text-green-800"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{localFilters.minDuration && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
|
||||
最小时长: {localFilters.minDuration}s
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalFilters(prev => ({ ...prev, minDuration: '' }));
|
||||
onQueryUpdate({ min_duration: undefined });
|
||||
}}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{localFilters.maxDuration && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded-full">
|
||||
最大时长: {localFilters.maxDuration}s
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalFilters(prev => ({ ...prev, maxDuration: '' }));
|
||||
onQueryUpdate({ max_duration: undefined });
|
||||
}}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
213
apps/desktop/src/components/MaterialSegmentGroup.tsx
Normal file
213
apps/desktop/src/components/MaterialSegmentGroup.tsx
Normal file
@@ -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<string>;
|
||||
onToggleExpand: (groupId: string) => void;
|
||||
onSelectSegment: (segmentId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MaterialSegment分组组件
|
||||
* 遵循 Tauri 开发规范的组件设计模式
|
||||
*/
|
||||
export const MaterialSegmentGroup: React.FC<MaterialSegmentGroupProps> = ({
|
||||
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 <Tag className="w-5 h-5 text-blue-500" />;
|
||||
} else {
|
||||
return <Users className="w-5 h-5 text-green-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取分组颜色主题
|
||||
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 (
|
||||
<div className={`border rounded-lg ${theme.border} ${theme.bg} overflow-hidden`}>
|
||||
{/* 分组头部 */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-opacity-80 transition-colors"
|
||||
onClick={() => onToggleExpand(groupId)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 展开/折叠图标 */}
|
||||
<button className="p-1 hover:bg-white hover:bg-opacity-50 rounded">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 分组图标 */}
|
||||
{getGroupIcon()}
|
||||
|
||||
{/* 分组名称 */}
|
||||
<div>
|
||||
<h3 className={`font-medium ${theme.text}`}>
|
||||
{groupName}
|
||||
{groupName === '未分类' && (
|
||||
<AlertCircle className="w-4 h-4 inline ml-1 text-orange-500" />
|
||||
)}
|
||||
{groupName === '未关联模特' && (
|
||||
<AlertCircle className="w-4 h-4 inline ml-1 text-orange-500" />
|
||||
)}
|
||||
</h3>
|
||||
{selectedCount > 0 && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
已选择 {selectedCount} 个片段
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分组统计信息 */}
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<BarChart3 className="w-4 h-4 text-gray-500" />
|
||||
<span className={theme.accent}>{group.segment_count}</span>
|
||||
<span className="text-gray-600">个片段</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<span className={theme.accent}>{formatDuration(group.total_duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分组进度条 */}
|
||||
{isClassificationGroup && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600 mb-1">
|
||||
<span>分类完成度</span>
|
||||
<span>{Math.round((group.segment_count / (group.segment_count || 1)) * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.round((group.segment_count / (group.segment_count || 1)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分组内容 */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
{group.segments.length > 0 ? (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{group.segments.map((segmentWithDetails) => (
|
||||
<MaterialSegmentCard
|
||||
key={segmentWithDetails.segment.id}
|
||||
segmentWithDetails={segmentWithDetails}
|
||||
isSelected={selectedSegmentIds.has(segmentWithDetails.segment.id)}
|
||||
onSelect={() => onSelectSegment(segmentWithDetails.segment.id)}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分组底部统计 */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="flex items-center">
|
||||
<FileVideo className="w-4 h-4 mr-1" />
|
||||
{group.segments.length} 个片段
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
总时长 {formatDuration(group.total_duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 平均时长 */}
|
||||
<span className="text-xs text-gray-500">
|
||||
平均时长 {formatDuration(group.total_duration / group.segment_count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<FileVideo className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>该分组暂无片段</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
272
apps/desktop/src/components/MaterialSegmentModelDialog.tsx
Normal file
272
apps/desktop/src/components/MaterialSegmentModelDialog.tsx
Normal file
@@ -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<MaterialSegmentModelDialogProps> = ({
|
||||
segments,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(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<Model[]>('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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden">
|
||||
{/* 对话框头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{isBatch ? '批量关联模特' : '关联模特'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isBatch ? `为 ${segments.length} 个片段关联模特` : '为该片段关联模特'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 对话框内容 */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
{/* 片段概览 */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">片段概览</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">片段数量</span>
|
||||
<span className="font-medium text-gray-900">{segments.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">总时长</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatDuration(segments.reduce((sum, s) => sum + s.segment.duration, 0))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">已关联模特</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{segments.filter(s => s.model).length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">未关联模特</span>
|
||||
<span className="font-medium text-orange-600">
|
||||
{segments.filter(s => !s.model).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前关联的模特 */}
|
||||
{currentModels.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">当前关联的模特:</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentModels.map(modelId => {
|
||||
const model = models.find(m => m.id === modelId);
|
||||
return (
|
||||
<span key={modelId} className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
|
||||
{model?.name || modelId?.slice(-8)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 模特选择 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">选择模特</h4>
|
||||
<button
|
||||
onClick={() => setSelectedModelId(null)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
selectedModelId === null
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-red-100 hover:text-red-700'
|
||||
}`}
|
||||
>
|
||||
取消关联
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索模特名称或ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模特列表 */}
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{loadingModels ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-green-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600">加载模特列表...</p>
|
||||
</div>
|
||||
) : filteredModels.length > 0 ? (
|
||||
filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h5 className="text-sm font-medium text-gray-900">{model.name}</h5>
|
||||
{selectedModelId === model.id && (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mt-1 text-xs text-gray-600">
|
||||
<span>ID: {model.id.slice(-8)}</span>
|
||||
<span>类型: {model.gender}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Users className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{searchTerm ? '未找到匹配的模特' : '暂无可用模特'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<button className="mt-2 text-sm text-green-600 hover:text-green-800 flex items-center mx-auto">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
创建新模特
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作说明 */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">操作说明:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>选择模特后,所有选中的片段都将关联到该模特</li>
|
||||
<li>如果片段已关联其他模特,将会被覆盖</li>
|
||||
<li>选择"取消关联"将移除所有片段的模特关联</li>
|
||||
<li>模特关联不会影响AI分类结果</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对话框底部 */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
{selectedModelId ? '关联模特' : '取消关联'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
161
apps/desktop/src/components/MaterialSegmentPagination.tsx
Normal file
161
apps/desktop/src/components/MaterialSegmentPagination.tsx
Normal file
@@ -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<MaterialSegmentPaginationProps> = ({
|
||||
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 (
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white border-t border-gray-200">
|
||||
{/* 左侧:显示信息 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
显示 <span className="font-medium">{startItem}</span> 到{' '}
|
||||
<span className="font-medium">{endItem}</span> 项,共{' '}
|
||||
<span className="font-medium">{totalItems}</span> 项
|
||||
</div>
|
||||
|
||||
{/* 每页显示数量选择 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="pageSize" className="text-sm text-gray-700">
|
||||
每页显示:
|
||||
</label>
|
||||
<select
|
||||
id="pageSize"
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:分页控件 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 第一页 */}
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="第一页"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 上一页 */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 页码 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{visiblePages.map((page, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{page === '...' ? (
|
||||
<span className="px-3 py-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onPageChange(page as number)}
|
||||
disabled={isLoading}
|
||||
className={`px-3 py-2 text-sm font-medium rounded transition-colors disabled:opacity-50 ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 下一页 */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 最后一页 */}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="最后一页"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
apps/desktop/src/components/MaterialSegmentReclassifyDialog.tsx
Normal file
248
apps/desktop/src/components/MaterialSegmentReclassifyDialog.tsx
Normal file
@@ -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<MaterialSegmentReclassifyDialogProps> = ({
|
||||
segments,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [options, setOptions] = useState<ReclassifyOptions>({
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden">
|
||||
{/* 对话框头部 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{isBatch ? '批量重新分类' : '重新分类'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isBatch ? `重新分类 ${segments.length} 个片段` : '重新分类该片段'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 对话框内容 */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
{/* 片段概览 */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">片段概览</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">总片段数</span>
|
||||
<span className="font-medium text-gray-900">{segments.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">总时长</span>
|
||||
<span className="font-medium text-gray-900">{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">已分类</span>
|
||||
<span className="font-medium text-green-600">{classifiedSegments.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">未分类</span>
|
||||
<span className="font-medium text-orange-600">{unclassifiedSegments.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类选项 */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">分类选项</h4>
|
||||
|
||||
{/* 覆盖现有分类 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="overwriteExisting"
|
||||
checked={options.overwriteExisting}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="overwriteExisting" className="text-sm font-medium text-gray-900 cursor-pointer">
|
||||
覆盖现有分类结果
|
||||
</label>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
如果片段已有分类结果,是否重新分类并覆盖原结果
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用最新模型 */}
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useLatestModel"
|
||||
checked={options.useLatestModel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="useLatestModel" className="text-sm font-medium text-gray-900 cursor-pointer">
|
||||
使用最新AI模型
|
||||
</label>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
使用最新版本的AI分类模型进行分类,可能获得更准确的结果
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 优先级设置 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">处理优先级</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'low', label: '低优先级', desc: '在后台慢速处理,不影响其他任务' },
|
||||
{ value: 'normal', label: '普通优先级', desc: '正常速度处理,推荐选择' },
|
||||
{ value: 'high', label: '高优先级', desc: '优先处理,可能影响其他任务性能' },
|
||||
].map((priority) => (
|
||||
<div key={priority.value} className="flex items-start space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id={`priority-${priority.value}`}
|
||||
name="priority"
|
||||
value={priority.value}
|
||||
checked={options.priority === priority.value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={`priority-${priority.value}`} className="text-sm font-medium text-gray-900 cursor-pointer">
|
||||
{priority.label}
|
||||
</label>
|
||||
<p className="text-xs text-gray-600 mt-1">{priority.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 警告信息 */}
|
||||
{classifiedSegments.length > 0 && options.overwriteExisting && (
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-600 mt-0.5 mr-2 flex-shrink-0" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
<p className="font-medium mb-1">注意:</p>
|
||||
<p>
|
||||
有 {classifiedSegments.length} 个片段已有分类结果,重新分类将覆盖现有结果。
|
||||
如果您只想分类未分类的片段,请取消勾选"覆盖现有分类结果"。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预估时间 */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start">
|
||||
<Zap className="w-4 h-4 text-blue-600 mt-0.5 mr-2 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">预估处理时间:</p>
|
||||
<p>
|
||||
根据片段数量和优先级,预计需要 {Math.ceil(segments.length * 0.5)} - {Math.ceil(segments.length * 2)} 分钟完成分类。
|
||||
您可以在AI分析日志中查看处理进度。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对话框底部 */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
开始重新分类
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
283
apps/desktop/src/components/MaterialSegmentStats.tsx
Normal file
283
apps/desktop/src/components/MaterialSegmentStats.tsx
Normal file
@@ -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<MaterialSegmentStatsProps> = ({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* 总体统计 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* 总片段数 */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">总片段数</p>
|
||||
<p className="text-2xl font-bold text-blue-900">{stats.total_segments}</p>
|
||||
</div>
|
||||
<FileVideo className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已分类片段 */}
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600">已分类</p>
|
||||
<p className="text-2xl font-bold text-green-900">{stats.classified_segments}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 未分类片段 */}
|
||||
<div className="bg-orange-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-orange-600">未分类</p>
|
||||
<p className="text-2xl font-bold text-orange-900">{stats.unclassified_segments}</p>
|
||||
</div>
|
||||
<AlertCircle className="w-8 h-8 text-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 总时长 */}
|
||||
<div className="bg-purple-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-purple-600">总时长</p>
|
||||
<p className="text-lg font-bold text-purple-900">{formatDuration(stats.total_duration)}</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类覆盖率 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<TrendingUp className="w-5 h-5 mr-2 text-gray-600" />
|
||||
分类覆盖率
|
||||
</h3>
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${getCoverageColor(stats.classification_coverage)}`}>
|
||||
{formatPercentage(stats.classification_coverage)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
|
||||
<span>已分类 {stats.classified_segments} / {stats.total_segments}</span>
|
||||
<span>{formatPercentage(stats.classification_coverage)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
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}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类建议 */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{stats.classification_coverage >= 0.8 ? (
|
||||
<p className="text-green-600">✓ 分类覆盖率良好,大部分片段已完成分类</p>
|
||||
) : stats.classification_coverage >= 0.6 ? (
|
||||
<p className="text-yellow-600">⚠ 分类覆盖率中等,建议继续完善分类</p>
|
||||
) : (
|
||||
<p className="text-red-600">⚠ 分类覆盖率较低,需要加强AI分类处理</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分布统计 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 分类/模特分布 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
{viewMode === MaterialSegmentViewMode.ByClassification ? (
|
||||
<>
|
||||
<Tag className="w-5 h-5 mr-2 text-blue-500" />
|
||||
分类分布
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users className="w-5 h-5 mr-2 text-green-500" />
|
||||
模特分布
|
||||
</>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{topItems.length > 0 ? (
|
||||
topItems.map(([name, count], _index) => {
|
||||
const percentage = (count / stats.total_segments) * 100;
|
||||
const isUnknown = name === '未分类' || name === '未关联模特';
|
||||
|
||||
return (
|
||||
<div key={name} className="flex items-center space-x-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-sm font-medium ${isUnknown ? 'text-orange-600' : 'text-gray-900'}`}>
|
||||
{name}
|
||||
{isUnknown && <AlertCircle className="w-4 h-4 inline ml-1" />}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">{count} ({Math.round(percentage)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
isUnknown
|
||||
? 'bg-orange-400'
|
||||
: viewMode === MaterialSegmentViewMode.ByClassification
|
||||
? 'bg-blue-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<PieChart className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>暂无分布数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 显示更多链接 */}
|
||||
{Object.keys(viewMode === MaterialSegmentViewMode.ByClassification ? stats.classification_counts : stats.model_counts).length > 5 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<button className="text-sm text-blue-600 hover:text-blue-800 transition-colors">
|
||||
查看全部 {Object.keys(viewMode === MaterialSegmentViewMode.ByClassification ? stats.classification_counts : stats.model_counts).length} 项
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 时长分析 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 mr-2 text-purple-500" />
|
||||
时长分析
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 平均时长 */}
|
||||
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||
<span className="text-sm font-medium text-purple-700">平均片段时长</span>
|
||||
<span className="text-lg font-bold text-purple-900">
|
||||
{formatDuration(stats.total_duration / (stats.total_segments || 1))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 时长分布建议 */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">总时长</span>
|
||||
<span className="font-medium">{formatDuration(stats.total_duration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">片段数量</span>
|
||||
<span className="font-medium">{stats.total_segments}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">已分类时长</span>
|
||||
<span className="font-medium">
|
||||
{formatDuration(stats.total_duration * stats.classification_coverage)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 效率指标 */}
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500 mb-2">处理效率</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${stats.classification_coverage * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-purple-600">
|
||||
{formatPercentage(stats.classification_coverage)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
540
apps/desktop/src/components/MaterialSegmentView.tsx
Normal file
540
apps/desktop/src/components/MaterialSegmentView.tsx
Normal file
@@ -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<MaterialSegmentViewProps> = ({ 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<HTMLDivElement>(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 (
|
||||
<div className="p-6">
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRetry={handleRefresh}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50">
|
||||
{/* 头部工具栏 */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">素材片段管理</h2>
|
||||
|
||||
{/* 视图模式切换 */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => handleViewModeChange(MaterialSegmentViewMode.ByClassification)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
viewMode === MaterialSegmentViewMode.ByClassification
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Tag className="w-4 h-4 inline mr-1" />
|
||||
按分类
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewModeChange(MaterialSegmentViewMode.ByModel)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
viewMode === MaterialSegmentViewMode.ByModel
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4 inline mr-1" />
|
||||
按模特
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 刷新按钮 */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{currentView && (
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<span className="flex items-center">
|
||||
<BarChart3 className="w-4 h-4 mr-1" />
|
||||
总计: {currentView.stats.total_segments}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Tag className="w-4 h-4 mr-1" />
|
||||
已分类: {currentView.stats.classified_segments}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
总时长: {Math.round(currentView.stats.total_duration)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤器 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1 max-w-md relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索素材名称、分类或模特..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 过滤器按钮 */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
showFilters
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4 inline mr-1" />
|
||||
过滤器
|
||||
</button>
|
||||
|
||||
{/* 批量操作 */}
|
||||
{selectedSegmentIds.size > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
已选择 {selectedSegmentIds.size} 个片段
|
||||
</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => setShowReclassifyDialog(true)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 px-2 py-1 rounded hover:bg-blue-50 flex items-center"
|
||||
title="批量重新分类"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
重新分类
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModelDialog(true)}
|
||||
className="text-sm text-green-600 hover:text-green-800 px-2 py-1 rounded hover:bg-green-50 flex items-center"
|
||||
title="批量关联模特"
|
||||
>
|
||||
<Users className="w-3 h-3 mr-1" />
|
||||
关联模特
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="text-sm text-red-600 hover:text-red-800 px-2 py-1 rounded hover:bg-red-50 flex items-center"
|
||||
title="批量删除"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="text-sm text-gray-600 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100"
|
||||
>
|
||||
清除选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开/折叠所有分组 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={expandAllGroups}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="展开所有分组"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAllGroups}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="折叠所有分组"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 过滤器面板 */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg border">
|
||||
<MaterialSegmentFilters
|
||||
currentQuery={currentQuery}
|
||||
onQueryUpdate={updateQuery}
|
||||
onClearQuery={clearQuery}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div ref={containerRef} className="flex-1 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
) : currentView ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{/* 统计概览 */}
|
||||
<div className="p-6 bg-white border-b border-gray-200">
|
||||
<MaterialSegmentStats stats={currentView.stats} viewMode={viewMode} />
|
||||
</div>
|
||||
|
||||
{/* 分组列表 */}
|
||||
<div className="flex-1">
|
||||
{getCurrentGroups().length > 0 ? (
|
||||
<VirtualizedSegmentList
|
||||
groups={getCurrentGroups()}
|
||||
viewMode={viewMode}
|
||||
selectedSegmentIds={selectedSegmentIds}
|
||||
expandedGroups={expandedGroups}
|
||||
onToggleExpand={(groupId) => {
|
||||
toggleGroup(groupId);
|
||||
}}
|
||||
onSelectSegment={(segmentId) => {
|
||||
if (selectedSegmentIds.has(segmentId)) {
|
||||
deselectSegment(segmentId);
|
||||
} else {
|
||||
selectSegment(segmentId);
|
||||
}
|
||||
}}
|
||||
height={containerHeight}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<Grid className="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p className="text-gray-600">暂无符合条件的素材片段</p>
|
||||
{currentQuery?.search_term && (
|
||||
<button
|
||||
onClick={() => handleSearch('')}
|
||||
className="mt-2 text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
清除搜索条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{currentView && totalPages > 1 && (
|
||||
<MaterialSegmentPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
totalItems={totalItems}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<List className="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p className="text-gray-600">暂无数据</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 批量操作对话框 */}
|
||||
<MaterialSegmentDeleteDialog
|
||||
segments={getSelectedSegments()}
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={handleBatchDelete}
|
||||
isLoading={dialogLoading}
|
||||
/>
|
||||
|
||||
<MaterialSegmentReclassifyDialog
|
||||
segments={getSelectedSegments()}
|
||||
isOpen={showReclassifyDialog}
|
||||
onClose={() => setShowReclassifyDialog(false)}
|
||||
onConfirm={handleBatchReclassify}
|
||||
isLoading={dialogLoading}
|
||||
/>
|
||||
|
||||
<MaterialSegmentModelDialog
|
||||
segments={getSelectedSegments()}
|
||||
isOpen={showModelDialog}
|
||||
onClose={() => setShowModelDialog(false)}
|
||||
onConfirm={handleBatchModelAssociation}
|
||||
isLoading={dialogLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
128
apps/desktop/src/components/VirtualizedSegmentList.tsx
Normal file
128
apps/desktop/src/components/VirtualizedSegmentList.tsx
Normal file
@@ -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<string>;
|
||||
expandedGroups: Set<string>;
|
||||
onToggleExpand: (groupId: string) => void;
|
||||
onSelectSegment: (segmentId: string) => void;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟化MaterialSegment列表组件
|
||||
* 遵循 Tauri 开发规范的性能优化组件设计模式
|
||||
*/
|
||||
export const VirtualizedSegmentList: React.FC<VirtualizedSegmentListProps> = ({
|
||||
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 (
|
||||
<div style={style}>
|
||||
<div className="px-6 pb-4">
|
||||
<MaterialSegmentGroup
|
||||
key={viewMode === MaterialSegmentViewMode.ByClassification
|
||||
? (group as ClassificationGroup).category
|
||||
: (group as ModelGroup).model_id}
|
||||
group={group}
|
||||
viewMode={viewMode}
|
||||
isExpanded={expandedGroups.has(
|
||||
viewMode === MaterialSegmentViewMode.ByClassification
|
||||
? (group as ClassificationGroup).category
|
||||
: (group as ModelGroup).model_id
|
||||
)}
|
||||
selectedSegmentIds={selectedSegmentIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelectSegment={onSelectSegment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [groups, viewMode, expandedGroups, selectedSegmentIds, onToggleExpand, onSelectSegment]);
|
||||
|
||||
// 如果分组数量较少,不使用虚拟化
|
||||
if (groups.length <= 10) {
|
||||
return (
|
||||
<div className="space-y-4 px-6">
|
||||
{groups.map((group, _index) => (
|
||||
<MaterialSegmentGroup
|
||||
key={viewMode === MaterialSegmentViewMode.ByClassification
|
||||
? (group as ClassificationGroup).category
|
||||
: (group as ModelGroup).model_id}
|
||||
group={group}
|
||||
viewMode={viewMode}
|
||||
isExpanded={expandedGroups.has(
|
||||
viewMode === MaterialSegmentViewMode.ByClassification
|
||||
? (group as ClassificationGroup).category
|
||||
: (group as ModelGroup).model_id
|
||||
)}
|
||||
selectedSegmentIds={selectedSegmentIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelectSegment={onSelectSegment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
height={Math.min(height, totalHeight)}
|
||||
itemCount={groups.length}
|
||||
itemSize={(index) => getItemHeight(index)}
|
||||
width="100%"
|
||||
className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100"
|
||||
>
|
||||
{renderItem}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -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<ProjectTemplateBindingDetail | null>(null);
|
||||
const [showMaterialEditDialog, setShowMaterialEditDialog] = useState(false);
|
||||
const [editingMaterial, setEditingMaterial] = useState<Material | null>(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<ProjectBatchClassificationResponse | null>(null);
|
||||
|
||||
// 素材匹配状态
|
||||
@@ -572,6 +573,20 @@ export const ProjectDetails: React.FC = () => {
|
||||
<span className="sm:hidden">素材</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('segments')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
|
||||
activeTab === 'segments'
|
||||
? '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">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">片段管理</span>
|
||||
<span className="sm:hidden">片段</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('templates')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
|
||||
@@ -668,6 +683,13 @@ export const ProjectDetails: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段管理选项卡 */}
|
||||
{activeTab === 'segments' && project && (
|
||||
<div className="space-y-6">
|
||||
<MaterialSegmentView projectId={project.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模板绑定选项卡 */}
|
||||
{activeTab === 'templates' && project && (
|
||||
<div className="space-y-6">
|
||||
|
||||
261
apps/desktop/src/store/materialSegmentViewStore.ts
Normal file
261
apps/desktop/src/store/materialSegmentViewStore.ts
Normal file
@@ -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<string>;
|
||||
expandedGroups: Set<string>;
|
||||
|
||||
// Actions
|
||||
loadProjectSegmentView: (projectId: string) => Promise<void>;
|
||||
loadProjectSegmentViewWithQuery: (query: MaterialSegmentQuery) => Promise<void>;
|
||||
setViewMode: (mode: MaterialSegmentViewMode) => void;
|
||||
updateQuery: (updates: Partial<MaterialSegmentQuery>) => 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<void>;
|
||||
|
||||
// 工具方法
|
||||
refreshCurrentView: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useMaterialSegmentViewStore = create<MaterialSegmentViewState>((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<MaterialSegmentView>('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<MaterialSegmentView>('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<MaterialSegmentQuery>) => {
|
||||
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<string>();
|
||||
|
||||
// 根据当前视图模式收集所有片段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<string>();
|
||||
|
||||
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 });
|
||||
},
|
||||
}));
|
||||
139
apps/desktop/src/types/materialSegmentView.ts
Normal file
139
apps/desktop/src/types/materialSegmentView.ts
Normal file
@@ -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<string, number>;
|
||||
model_counts: Record<string, number>;
|
||||
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;
|
||||
}
|
||||
@@ -44,5 +44,9 @@
|
||||
"multi-language"
|
||||
],
|
||||
"author": "imeepos",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-window": "^1.8.8",
|
||||
"react-window": "^1.8.11"
|
||||
}
|
||||
}
|
||||
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user