diff --git a/apps/desktop/src-tauri/src/business/services/material_service.rs b/apps/desktop/src-tauri/src/business/services/material_service.rs index 8286339..99327b4 100644 --- a/apps/desktop/src-tauri/src/business/services/material_service.rs +++ b/apps/desktop/src-tauri/src/business/services/material_service.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use crate::data::models::material::{ Material, MaterialType, ProcessingStatus, CreateMaterialRequest, - MaterialImportResult, MaterialProcessingConfig, MaterialMetadata + MaterialImportResult, MaterialProcessingConfig, MaterialMetadata, + BatchDeleteResult, BatchDeleteFailedItem }; use crate::data::repositories::material_repository::MaterialRepository; use crate::infrastructure::ffmpeg::FFmpegService; @@ -231,6 +232,50 @@ impl MaterialService { Ok(()) } + /// 批量删除素材 + /// 返回删除结果统计信息 + pub fn batch_delete_materials( + repository: &MaterialRepository, + material_ids: Vec, + ) -> Result { + info!( + material_count = material_ids.len(), + "开始批量删除素材" + ); + + // 参数验证 + if material_ids.is_empty() { + return Err(anyhow!("素材ID列表不能为空")); + } + + if material_ids.len() > 100 { + return Err(anyhow!("单次批量删除不能超过100个素材")); + } + + // 执行批量删除 + let (successful_deletes, failed_deletes) = repository.batch_delete(&material_ids)?; + + let result = BatchDeleteResult { + total_count: material_ids.len(), + success_count: successful_deletes.len(), + failed_count: failed_deletes.len(), + successful_ids: successful_deletes, + failed_items: failed_deletes.into_iter().map(|(id, error)| BatchDeleteFailedItem { + id, + error_message: error, + }).collect(), + }; + + info!( + total_count = result.total_count, + success_count = result.success_count, + failed_count = result.failed_count, + "批量删除素材完成" + ); + + Ok(result) + } + /// 更新素材处理状态 pub fn update_material_status( repository: &MaterialRepository, diff --git a/apps/desktop/src-tauri/src/data/models/material.rs b/apps/desktop/src-tauri/src/data/models/material.rs index 92aee13..d2c846d 100644 --- a/apps/desktop/src-tauri/src/data/models/material.rs +++ b/apps/desktop/src-tauri/src/data/models/material.rs @@ -411,3 +411,20 @@ impl MaterialSegment { score } } + +/// 批量删除失败项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchDeleteFailedItem { + pub id: String, + pub error_message: String, +} + +/// 批量删除结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchDeleteResult { + pub total_count: usize, + pub success_count: usize, + pub failed_count: usize, + pub successful_ids: Vec, + pub failed_items: Vec, +} diff --git a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs index 012bc8a..28d6dfc 100644 --- a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs @@ -187,12 +187,67 @@ impl MaterialRepository { pub fn delete(&self, id: &str) -> Result<()> { let conn = self.database.get_connection(); let conn = conn.lock().unwrap(); - + conn.execute("DELETE FROM materials WHERE id = ?1", [id])?; - + Ok(()) } + /// 批量删除素材 + /// 使用事务确保数据一致性,返回成功删除的素材ID列表和失败的素材ID及错误信息 + pub fn batch_delete(&self, material_ids: &[String]) -> Result<(Vec, Vec<(String, String)>)> { + let conn = self.database.get_connection(); + let mut conn = conn.lock().unwrap(); + + let mut successful_deletes = Vec::new(); + let mut failed_deletes = Vec::new(); + + // 开始事务 + let tx = conn.transaction()?; + + for material_id in material_ids { + // 首先检查素材是否存在 + let exists: bool = tx.query_row( + "SELECT 1 FROM materials WHERE id = ?1", + [material_id], + |_| Ok(true) + ).unwrap_or(false); + + if !exists { + failed_deletes.push((material_id.clone(), "素材不存在".to_string())); + continue; + } + + // 删除相关的片段数据 + match tx.execute("DELETE FROM material_segments WHERE material_id = ?1", [material_id]) { + Ok(_) => {}, + Err(e) => { + failed_deletes.push((material_id.clone(), format!("删除片段失败: {}", e))); + continue; + } + } + + // 删除素材本身 + match tx.execute("DELETE FROM materials WHERE id = ?1", [material_id]) { + Ok(rows_affected) => { + if rows_affected > 0 { + successful_deletes.push(material_id.clone()); + } else { + failed_deletes.push((material_id.clone(), "删除失败,未找到记录".to_string())); + } + }, + Err(e) => { + failed_deletes.push((material_id.clone(), format!("删除失败: {}", e))); + } + } + } + + // 提交事务 + tx.commit()?; + + Ok((successful_deletes, failed_deletes)) + } + /// 创建素材片段 pub fn create_segment(&self, segment: &MaterialSegment) -> Result<()> { let conn = self.database.get_connection(); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1efc79b..e63ee3c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ pub fn run() { commands::material_commands::get_all_materials, commands::material_commands::get_material_by_id, commands::material_commands::delete_material, + commands::material_commands::batch_delete_materials, commands::material_commands::get_project_material_stats, commands::material_commands::batch_process_materials, commands::material_commands::update_material_status, @@ -348,3 +349,8 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + mod batch_delete_test; +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs index f2fc729..8b1cffe 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -556,7 +556,7 @@ pub async fn delete_material( ) -> Result<(), String> { let repository_guard = state.get_material_repository() .map_err(|e| format!("获取素材仓库失败: {}", e))?; - + let repository = repository_guard.as_ref() .ok_or("素材仓库未初始化")?; @@ -564,6 +564,22 @@ pub async fn delete_material( .map_err(|e| e.to_string()) } +/// 批量删除素材命令 +#[command] +pub async fn batch_delete_materials( + state: State<'_, AppState>, + material_ids: Vec, +) -> Result { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + MaterialService::batch_delete_materials(repository, material_ids) + .map_err(|e| e.to_string()) +} + /// 获取项目素材统计命令 #[command] pub async fn get_project_material_stats( diff --git a/apps/desktop/src-tauri/src/tests/batch_delete_test.rs b/apps/desktop/src-tauri/src/tests/batch_delete_test.rs new file mode 100644 index 0000000..ade830b --- /dev/null +++ b/apps/desktop/src-tauri/src/tests/batch_delete_test.rs @@ -0,0 +1,45 @@ +#[cfg(test)] +mod batch_delete_tests { + use crate::data::models::material::{BatchDeleteResult, BatchDeleteFailedItem}; + + #[test] + fn test_batch_delete_result_creation() { + // 测试BatchDeleteResult结构体的创建 + let result = BatchDeleteResult { + total_count: 3, + success_count: 1, + failed_count: 2, + successful_ids: vec!["success1".to_string()], + failed_items: vec![ + BatchDeleteFailedItem { + id: "failed1".to_string(), + error_message: "素材不存在".to_string(), + }, + BatchDeleteFailedItem { + id: "failed2".to_string(), + error_message: "删除失败".to_string(), + }, + ], + }; + + assert_eq!(result.total_count, 3); + assert_eq!(result.success_count, 1); + assert_eq!(result.failed_count, 2); + assert_eq!(result.successful_ids.len(), 1); + assert_eq!(result.failed_items.len(), 2); + assert_eq!(result.successful_ids[0], "success1"); + assert_eq!(result.failed_items[0].id, "failed1"); + assert_eq!(result.failed_items[0].error_message, "素材不存在"); + } + + #[test] + fn test_batch_delete_failed_item_creation() { + let failed_item = BatchDeleteFailedItem { + id: "test_id".to_string(), + error_message: "测试错误消息".to_string(), + }; + + assert_eq!(failed_item.id, "test_id"); + assert_eq!(failed_item.error_message, "测试错误消息"); + } +} diff --git a/apps/desktop/src/components/BatchDeleteConfirmDialog.tsx b/apps/desktop/src/components/BatchDeleteConfirmDialog.tsx new file mode 100644 index 0000000..953831b --- /dev/null +++ b/apps/desktop/src/components/BatchDeleteConfirmDialog.tsx @@ -0,0 +1,206 @@ +import React, { useState } from 'react'; +import { ExclamationTriangleIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'; +import { LoadingSpinner } from './LoadingSpinner'; +import { Modal } from './Modal'; +import { Material, BatchDeleteResult } from '../types/material'; + +interface BatchDeleteConfirmDialogProps { + /** 是否显示对话框 */ + isOpen: boolean; + /** 要删除的素材列表 */ + materials: Material[]; + /** 是否正在删除 */ + deleting?: boolean; + /** 删除结果(删除完成后显示) */ + deleteResult?: BatchDeleteResult | null; + /** 确认删除回调 */ + onConfirm: () => void; + /** 取消回调 */ + onCancel: () => void; + /** 关闭结果对话框回调 */ + onCloseResult?: () => void; +} + +/** + * 批量删除确认对话框组件 + * 遵循前端开发规范的确认对话框设计,提供安全的批量删除确认流程 + */ +export const BatchDeleteConfirmDialog: React.FC = ({ + isOpen, + materials, + deleting, + deleteResult, + onConfirm, + onCancel, + onCloseResult, +}) => { + const [showDetails, setShowDetails] = useState(false); + + // 如果有删除结果,显示结果对话框 + if (deleteResult) { + return ( + {} : (onCloseResult || onCancel)} + title="批量删除结果" + icon={deleteResult.failed_count > 0 ? + : + + } + size="md" + variant={deleteResult.failed_count > 0 ? "warning" : "success"} + closeOnBackdropClick={!deleting} + closeOnEscape={!deleting} + > +
+ {/* 删除结果统计 */} +
+
+
+
{deleteResult.total_count}
+
总计
+
+
+
{deleteResult.success_count}
+
成功
+
+
+
{deleteResult.failed_count}
+
失败
+
+
+
+ + {/* 成功消息 */} + {deleteResult.success_count > 0 && ( +
+
+ +
+

+ 成功删除 {deleteResult.success_count} 个素材 +

+
+
+
+ )} + + {/* 失败详情 */} + {deleteResult.failed_count > 0 && ( +
+
+ +
+

+ {deleteResult.failed_count} 个素材删除失败 +

+ + + + {showDetails && ( +
+ {deleteResult.failed_items.map((item, index) => ( +
+
+ ID: {item.id} +
+
{item.error_message}
+
+ ))} +
+ )} +
+
+
+ )} + + {/* 按钮区域 */} +
+ +
+
+
+ ); + } + + // 确认删除对话框 + return ( + {} : onCancel} + title="批量删除素材" + icon={} + size="md" + variant="danger" + closeOnBackdropClick={!deleting} + closeOnEscape={!deleting} + > +
+

+ 确定要删除选中的 {materials.length} 个素材吗?此操作无法撤销。 +

+ + {/* 素材列表预览 */} +
+

要删除的素材:

+
+ {materials.slice(0, 10).map((material) => ( +
+ • {material.name} +
+ ))} + {materials.length > 10 && ( +
+ ... 还有 {materials.length - 10} 个素材 +
+ )} +
+
+ +
+
+ +
+

重要提醒

+

+ 此操作将永久删除选中的素材及其相关数据,包括切分片段、分类记录等。请确认您要继续。 +

+
+
+
+ + {/* 按钮区域 */} +
+ + +
+
+
+ ); +}; diff --git a/apps/desktop/src/components/MaterialCard.tsx b/apps/desktop/src/components/MaterialCard.tsx index cb7f930..16a718e 100644 --- a/apps/desktop/src/components/MaterialCard.tsx +++ b/apps/desktop/src/components/MaterialCard.tsx @@ -19,6 +19,10 @@ interface MaterialCardProps { onDelete?: (materialId: string, materialName: string) => void; onReprocess?: (materialId: string) => void; onUsageReset?: () => void; // 使用状态重置后的回调 + // 批量选择相关 + isSelectionMode?: boolean; + isSelected?: boolean; + onToggleSelection?: (materialId: string) => void; } // 格式化时间(秒转为 mm:ss 格式) @@ -67,7 +71,16 @@ const formatDate = (dateString: string): string => { * 素材卡片组件 * 显示素材信息和切分片段 */ -export const MaterialCard: React.FC = ({ material, onEdit, onDelete, onReprocess, onUsageReset }) => { +export const MaterialCard: React.FC = ({ + material, + onEdit, + onDelete, + onReprocess, + onUsageReset, + isSelectionMode = false, + isSelected = false, + onToggleSelection +}) => { const { getMaterialSegments } = useMaterialStore(); const { startClassification, isLoading: classificationLoading } = useVideoClassificationStore(); const { usageStats } = useMaterialUsage(); @@ -312,7 +325,21 @@ export const MaterialCard: React.FC = ({ material, onEdit, on }; return ( -
+
+ {/* 批量选择复选框 */} + {isSelectionMode && ( +
+ onToggleSelection?.(material.id)} + className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 focus:ring-2" + /> +
+ )} + {/* 素材基本信息 */}
{/* 缩略图 */} diff --git a/apps/desktop/src/hooks/useBatchSelection.ts b/apps/desktop/src/hooks/useBatchSelection.ts new file mode 100644 index 0000000..2ebbe58 --- /dev/null +++ b/apps/desktop/src/hooks/useBatchSelection.ts @@ -0,0 +1,146 @@ +import { useState, useCallback, useMemo } from 'react'; + +/** + * 批量选择Hook + * 用于管理列表项的批量选择状态 + */ +export interface UseBatchSelectionOptions { + /** 是否启用批量选择模式 */ + enabled?: boolean; + /** 最大选择数量限制 */ + maxSelection?: number; +} + +export interface UseBatchSelectionReturn { + /** 当前选中的项目ID列表 */ + selectedIds: string[]; + /** 是否处于批量选择模式 */ + isSelectionMode: boolean; + /** 是否全选状态 */ + isAllSelected: boolean; + /** 是否部分选中状态 */ + isIndeterminate: boolean; + /** 选中项目数量 */ + selectedCount: number; + /** 切换批量选择模式 */ + toggleSelectionMode: () => void; + /** 选择/取消选择单个项目 */ + toggleItem: (id: string) => void; + /** 全选/取消全选 */ + toggleAll: (allIds: string[]) => void; + /** 清空选择 */ + clearSelection: () => void; + /** 检查项目是否被选中 */ + isSelected: (id: string) => boolean; + /** 设置选中的项目ID列表 */ + setSelectedIds: (ids: string[]) => void; +} + +export function useBatchSelection( + options: UseBatchSelectionOptions = {} +): UseBatchSelectionReturn { + const { enabled = true, maxSelection } = options; + + const [selectedIds, setSelectedIds] = useState([]); + const [isSelectionMode, setIsSelectionMode] = useState(false); + + // 切换批量选择模式 + const toggleSelectionMode = useCallback(() => { + if (!enabled) return; + + setIsSelectionMode(prev => { + const newMode = !prev; + // 退出选择模式时清空选择 + if (!newMode) { + setSelectedIds([]); + } + return newMode; + }); + }, [enabled]); + + // 选择/取消选择单个项目 + const toggleItem = useCallback((id: string) => { + if (!enabled || !isSelectionMode) return; + + setSelectedIds(prev => { + const isCurrentlySelected = prev.includes(id); + + if (isCurrentlySelected) { + // 取消选择 + return prev.filter(selectedId => selectedId !== id); + } else { + // 选择项目 + if (maxSelection && prev.length >= maxSelection) { + console.warn(`最多只能选择 ${maxSelection} 个项目`); + return prev; + } + return [...prev, id]; + } + }); + }, [enabled, isSelectionMode, maxSelection]); + + // 全选/取消全选 + const toggleAll = useCallback((allIds: string[]) => { + if (!enabled || !isSelectionMode) return; + + setSelectedIds(prev => { + const isAllSelected = allIds.length > 0 && allIds.every(id => prev.includes(id)); + + if (isAllSelected) { + // 取消全选 + return []; + } else { + // 全选(考虑最大选择数量限制) + const idsToSelect = maxSelection ? allIds.slice(0, maxSelection) : allIds; + return idsToSelect; + } + }); + }, [enabled, isSelectionMode, maxSelection]); + + // 清空选择 + const clearSelection = useCallback(() => { + setSelectedIds([]); + }, []); + + // 检查项目是否被选中 + const isSelected = useCallback((id: string) => { + return selectedIds.includes(id); + }, [selectedIds]); + + // 计算全选状态 + const { isAllSelected, isIndeterminate } = useMemo(() => { + return { + isAllSelected: false, // 这里需要外部传入allIds才能计算 + isIndeterminate: selectedIds.length > 0 + }; + }, [selectedIds]); + + return { + selectedIds, + isSelectionMode, + isAllSelected, + isIndeterminate, + selectedCount: selectedIds.length, + toggleSelectionMode, + toggleItem, + toggleAll, + clearSelection, + isSelected, + setSelectedIds + }; +} + +/** + * 计算全选状态的辅助函数 + */ +export function calculateSelectionState(selectedIds: string[], allIds: string[]) { + const selectedCount = selectedIds.length; + const totalCount = allIds.length; + + return { + isAllSelected: totalCount > 0 && selectedCount === totalCount, + isIndeterminate: selectedCount > 0 && selectedCount < totalCount, + selectedCount, + totalCount + }; +} diff --git a/apps/desktop/src/pages/ProjectDetails.tsx b/apps/desktop/src/pages/ProjectDetails.tsx index ecc47f0..2ba5757 100644 --- a/apps/desktop/src/pages/ProjectDetails.tsx +++ b/apps/desktop/src/pages/ProjectDetails.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, FolderOpen, Upload, FileVideo, FileAudio, FileImage, HardDrive, Brain, Loader2, Link, Layers, Calendar, MapPin, Users, CheckCircle, Filter, Shuffle, Download } from 'lucide-react'; +import { ArrowLeft, FolderOpen, Upload, FileVideo, FileAudio, FileImage, HardDrive, Brain, Loader2, Link, Layers, Calendar, MapPin, Users, CheckCircle, Filter, Shuffle, Download, Trash2, Square, CheckSquare, MinusSquare } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { useProjectStore } from '../store/projectStore'; import { useMaterialStore } from '../store/materialStore'; import { useVideoClassificationStore } from '../store/videoClassificationStore'; import { Project } from '../types/project'; -import { Material, MaterialImportResult } from '../types/material'; +import { Material, MaterialImportResult, BatchDeleteResult } from '../types/material'; import { ProjectBatchClassificationRequest, ProjectBatchClassificationResponse } from '../types/videoClassification'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { ErrorMessage } from '../components/ErrorMessage'; @@ -42,6 +42,8 @@ import { TemplateMatchingResultManager } from '../components/TemplateMatchingRes import { useNotifications } from '../components/NotificationSystem'; import { ProjectMaterialUsageOverviewComponent } from '../components/ProjectMaterialUsageOverview'; import { useMaterialUsage } from '../hooks/useMaterialUsage'; +import { useBatchSelection, calculateSelectionState } from '../hooks/useBatchSelection'; +import { BatchDeleteConfirmDialog } from '../components/BatchDeleteConfirmDialog'; // 格式化时间 const formatTime = (dateString: string) => { @@ -76,8 +78,10 @@ export const ProjectDetails: React.FC = () => { loadMaterials, loadMaterialStats, deleteMaterial, + batchDeleteMaterials, processMaterials, - isLoading: materialsLoading + isLoading: materialsLoading, + isBatchDeleting } = useMaterialStore(); const { startProjectBatchClassification, @@ -148,6 +152,16 @@ export const ProjectDetails: React.FC = () => { const [materialClassificationRecords, setMaterialClassificationRecords] = useState<{ [materialId: string]: any[] }>({}); const [modelsMap, setModelsMap] = useState<{ [modelId: string]: any }>({}); + // 批量删除状态 + const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false); + const [batchDeleteResult, setBatchDeleteResult] = useState(null); + + // 批量选择Hook + const batchSelection = useBatchSelection({ + enabled: true, + maxSelection: 50 // 限制最多选择50个素材 + }); + // 用于跟踪分类统计是否已加载的ref const classificationStatsLoadedRef = useRef(null); @@ -651,6 +665,51 @@ export const ProjectDetails: React.FC = () => { } }; + // 批量删除处理函数 + const handleBatchDelete = async () => { + if (batchSelection.selectedIds.length === 0) { + addNotification('请先选择要删除的素材', 'warning'); + return; + } + + setShowBatchDeleteDialog(true); + }; + + // 确认批量删除 + const handleConfirmBatchDelete = async () => { + try { + const result = await batchDeleteMaterials(batchSelection.selectedIds); + setBatchDeleteResult(result); + + // 清空选择 + batchSelection.clearSelection(); + + // 重新加载素材列表 + if (project) { + loadMaterials(project.id); + } + + // 显示通知 + if (result.failed_count === 0) { + addNotification(`成功删除 ${result.success_count} 个素材`, 'success'); + } else { + addNotification( + `删除完成:成功 ${result.success_count} 个,失败 ${result.failed_count} 个`, + 'warning' + ); + } + } catch (error) { + console.error('批量删除失败:', error); + addNotification('批量删除失败', 'error'); + } + }; + + // 关闭批量删除对话框 + const handleCloseBatchDeleteDialog = () => { + setShowBatchDeleteDialog(false); + setBatchDeleteResult(null); + }; + // 素材重新处理函数 const handleReprocessMaterial = async (materialId: string) => { try { @@ -800,6 +859,17 @@ export const ProjectDetails: React.FC = () => { }); }, [materials, materialClassificationFilter, materialModelFilter, materialUsageFilter, materialClassificationRecords]); + // 计算选择状态 + const selectionState = useMemo(() => { + const allIds = filteredMaterials.map(m => m.id); + return calculateSelectionState(batchSelection.selectedIds, allIds); + }, [batchSelection.selectedIds, filteredMaterials]); + + // 获取选中的素材列表 + const selectedMaterials = useMemo(() => { + return filteredMaterials.filter(material => batchSelection.selectedIds.includes(material.id)); + }, [filteredMaterials, batchSelection.selectedIds]); + if (isLoading) { return (
@@ -1264,14 +1334,85 @@ export const ProjectDetails: React.FC = () => { {/* 素材列表 */}
-

项目素材

- +
+

项目素材

+ + {/* 批量选择控制 */} + {filteredMaterials.length > 0 && ( +
+ + + {/* 批量选择模式下的控制按钮 */} + {batchSelection.isSelectionMode && ( + <> + + + {batchSelection.selectedCount > 0 && ( + + 已选择 {batchSelection.selectedCount} 个素材 + + )} + + )} +
+ )} +
+ +
+ {/* 批量删除按钮 */} + {batchSelection.isSelectionMode && batchSelection.selectedCount > 0 && ( + + )} + + +
{materialsLoading ? ( @@ -1288,6 +1429,9 @@ export const ProjectDetails: React.FC = () => { onDelete={handleDeleteMaterial} onReprocess={handleReprocessMaterial} onUsageReset={() => project && loadUsageOverview(project.id)} + isSelectionMode={batchSelection.isSelectionMode} + isSelected={batchSelection.isSelected(material.id)} + onToggleSelection={batchSelection.toggleItem} /> ))}
@@ -1571,6 +1715,17 @@ export const ProjectDetails: React.FC = () => { progress={batchMatchingProgress} canCancel={batchMatchingLoading} /> + + {/* 批量删除确认对话框 */} +
); }; diff --git a/apps/desktop/src/services/modelDynamicService.ts b/apps/desktop/src/services/modelDynamicService.ts index 729ebbd..0d12dd5 100644 --- a/apps/desktop/src/services/modelDynamicService.ts +++ b/apps/desktop/src/services/modelDynamicService.ts @@ -191,7 +191,7 @@ export const modelDynamicService = { }, // 模拟统计数据 - 仅用于开发测试 - getMockStats(modelId: string): ModelDynamicStats { + getMockStats(_modelId: string): ModelDynamicStats { return { total_dynamics: 2, published_dynamics: 2, diff --git a/apps/desktop/src/store/materialStore.ts b/apps/desktop/src/store/materialStore.ts index dcf285e..fb18d76 100644 --- a/apps/desktop/src/store/materialStore.ts +++ b/apps/desktop/src/store/materialStore.ts @@ -6,7 +6,8 @@ import { CreateMaterialRequest, MaterialImportResult, ProcessingStatus, - MaterialSegment + MaterialSegment, + BatchDeleteResult } from '../types/material'; /** @@ -21,6 +22,7 @@ interface MaterialState { isLoading: boolean; isImporting: boolean; isProcessing: boolean; + isBatchDeleting: boolean; error: string | null; importProgress: { current_file: string; @@ -38,6 +40,7 @@ interface MaterialState { getMaterialById: (id: string) => Promise; getMaterialSegments: (materialId: string) => Promise; deleteMaterial: (id: string) => Promise; + batchDeleteMaterials: (materialIds: string[]) => Promise; processMaterials: (materialIds: string[]) => Promise; updateMaterialStatus: (id: string, status: ProcessingStatus, errorMessage?: string) => Promise; @@ -74,6 +77,7 @@ export const useMaterialStore = create((set, get) => ({ isLoading: false, isImporting: false, isProcessing: false, + isBatchDeleting: false, error: null, importProgress: null, @@ -210,11 +214,11 @@ export const useMaterialStore = create((set, get) => ({ deleteMaterial: async (id: string) => { try { await invoke('delete_material', { id }); - + // 从状态中移除 const { materials } = get(); set({ materials: materials.filter(m => m.id !== id) }); - + // 如果删除的是当前素材,清空当前素材 const { currentMaterial } = get(); if (currentMaterial?.id === id) { @@ -226,6 +230,32 @@ export const useMaterialStore = create((set, get) => ({ } }, + // 批量删除素材 + batchDeleteMaterials: async (materialIds: string[]) => { + set({ isBatchDeleting: true, error: null }); + try { + const result = await invoke('batch_delete_materials', { materialIds }); + + // 从状态中移除成功删除的素材 + const { materials } = get(); + const remainingMaterials = materials.filter(m => !result.successful_ids.includes(m.id)); + set({ materials: remainingMaterials }); + + // 如果当前素材被删除,清空当前素材 + const { currentMaterial } = get(); + if (currentMaterial && result.successful_ids.includes(currentMaterial.id)) { + set({ currentMaterial: null }); + } + + return result; + } catch (error) { + set({ error: error as string }); + throw error; + } finally { + set({ isBatchDeleting: false }); + } + }, + // 批量处理素材 processMaterials: async (materialIds: string[]) => { set({ isProcessing: true, error: null }); diff --git a/apps/desktop/src/types/material.ts b/apps/desktop/src/types/material.ts index 63f0a51..84baf4f 100644 --- a/apps/desktop/src/types/material.ts +++ b/apps/desktop/src/types/material.ts @@ -135,6 +135,20 @@ export interface MaterialStats { processing_status_counts: Record; } +// 批量删除相关类型 +export interface BatchDeleteFailedItem { + id: string; + error_message: string; +} + +export interface BatchDeleteResult { + total_count: number; + success_count: number; + failed_count: number; + successful_ids: string[]; + failed_items: BatchDeleteFailedItem[]; +} + export interface FileInfo { name: string; path: string; @@ -154,6 +168,7 @@ export interface MaterialCommands { get_project_materials(project_id: string): Promise; get_material_by_id(id: string): Promise; delete_material(id: string): Promise; + batch_delete_materials(material_ids: string[]): Promise; get_project_material_stats(project_id: string): Promise; batch_process_materials(material_ids: string[]): Promise; update_material_status(id: string, status: string, error_message?: string): Promise;