feat: 实现项目素材批量删除功能

- 后端实现:
  * 在MaterialRepository中添加batch_delete方法,支持事务处理
  * 在MaterialService中添加batch_delete_materials业务逻辑
  * 添加BatchDeleteResult和BatchDeleteFailedItem数据结构
  * 新增batch_delete_materials Tauri命令接口
  * 实现参数验证和错误处理机制

- 前端实现:
  * 创建useBatchSelection Hook管理批量选择状态
  * 实现BatchDeleteConfirmDialog批量删除确认对话框
  * 在MaterialCard组件中添加批量选择支持
  * 在ProjectDetails页面集成批量选择和删除功能
  * 添加批量操作UI控件(全选/取消全选/批量删除按钮)

- 功能特性:
  * 支持最多50个素材的批量选择
  * 单次最多删除100个素材的限制
  * 详细的删除结果反馈(成功/失败统计)
  * 失败项目的具体错误信息显示
  * 批量选择模式的视觉反馈
  * 完善的用户确认和通知机制

- 测试:
  * 添加批量删除功能的单元测试
  * 测试数据结构创建和验证逻辑

遵循Tauri开发规范和前端UI/UX设计标准,提供安全可靠的批量删除体验。
This commit is contained in:
imeepos
2025-07-21 14:28:06 +08:00
parent 6c795a5ddf
commit 6bd12a4a63
13 changed files with 784 additions and 21 deletions

View File

@@ -6,7 +6,8 @@ use std::sync::Arc;
use crate::data::models::material::{ use crate::data::models::material::{
Material, MaterialType, ProcessingStatus, CreateMaterialRequest, Material, MaterialType, ProcessingStatus, CreateMaterialRequest,
MaterialImportResult, MaterialProcessingConfig, MaterialMetadata MaterialImportResult, MaterialProcessingConfig, MaterialMetadata,
BatchDeleteResult, BatchDeleteFailedItem
}; };
use crate::data::repositories::material_repository::MaterialRepository; use crate::data::repositories::material_repository::MaterialRepository;
use crate::infrastructure::ffmpeg::FFmpegService; use crate::infrastructure::ffmpeg::FFmpegService;
@@ -231,6 +232,50 @@ impl MaterialService {
Ok(()) Ok(())
} }
/// 批量删除素材
/// 返回删除结果统计信息
pub fn batch_delete_materials(
repository: &MaterialRepository,
material_ids: Vec<String>,
) -> Result<BatchDeleteResult> {
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( pub fn update_material_status(
repository: &MaterialRepository, repository: &MaterialRepository,

View File

@@ -411,3 +411,20 @@ impl MaterialSegment {
score 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<String>,
pub failed_items: Vec<BatchDeleteFailedItem>,
}

View File

@@ -187,12 +187,67 @@ impl MaterialRepository {
pub fn delete(&self, id: &str) -> Result<()> { pub fn delete(&self, id: &str) -> Result<()> {
let conn = self.database.get_connection(); let conn = self.database.get_connection();
let conn = conn.lock().unwrap(); let conn = conn.lock().unwrap();
conn.execute("DELETE FROM materials WHERE id = ?1", [id])?; conn.execute("DELETE FROM materials WHERE id = ?1", [id])?;
Ok(()) Ok(())
} }
/// 批量删除素材
/// 使用事务确保数据一致性返回成功删除的素材ID列表和失败的素材ID及错误信息
pub fn batch_delete(&self, material_ids: &[String]) -> Result<(Vec<String>, 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<()> { pub fn create_segment(&self, segment: &MaterialSegment) -> Result<()> {
let conn = self.database.get_connection(); let conn = self.database.get_connection();

View File

@@ -59,6 +59,7 @@ pub fn run() {
commands::material_commands::get_all_materials, commands::material_commands::get_all_materials,
commands::material_commands::get_material_by_id, commands::material_commands::get_material_by_id,
commands::material_commands::delete_material, commands::material_commands::delete_material,
commands::material_commands::batch_delete_materials,
commands::material_commands::get_project_material_stats, commands::material_commands::get_project_material_stats,
commands::material_commands::batch_process_materials, commands::material_commands::batch_process_materials,
commands::material_commands::update_material_status, commands::material_commands::update_material_status,
@@ -348,3 +349,8 @@ pub fn run() {
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
#[cfg(test)]
mod tests {
mod batch_delete_test;
}

View File

@@ -556,7 +556,7 @@ pub async fn delete_material(
) -> Result<(), String> { ) -> Result<(), String> {
let repository_guard = state.get_material_repository() let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?; .map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref() let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?; .ok_or("素材仓库未初始化")?;
@@ -564,6 +564,22 @@ pub async fn delete_material(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// 批量删除素材命令
#[command]
pub async fn batch_delete_materials(
state: State<'_, AppState>,
material_ids: Vec<String>,
) -> Result<crate::data::models::material::BatchDeleteResult, String> {
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] #[command]
pub async fn get_project_material_stats( pub async fn get_project_material_stats(

View File

@@ -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, "测试错误消息");
}
}

View File

@@ -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<BatchDeleteConfirmDialogProps> = ({
isOpen,
materials,
deleting,
deleteResult,
onConfirm,
onCancel,
onCloseResult,
}) => {
const [showDetails, setShowDetails] = useState(false);
// 如果有删除结果,显示结果对话框
if (deleteResult) {
return (
<Modal
isOpen={isOpen}
onClose={deleting ? () => {} : (onCloseResult || onCancel)}
title="批量删除结果"
icon={deleteResult.failed_count > 0 ?
<ExclamationTriangleIcon className="h-6 w-6" /> :
<CheckCircleIcon className="h-6 w-6" />
}
size="md"
variant={deleteResult.failed_count > 0 ? "warning" : "success"}
closeOnBackdropClick={!deleting}
closeOnEscape={!deleting}
>
<div className="p-6">
{/* 删除结果统计 */}
<div className="mb-6">
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-gray-900">{deleteResult.total_count}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{deleteResult.success_count}</div>
<div className="text-sm text-green-600"></div>
</div>
<div className="p-4 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{deleteResult.failed_count}</div>
<div className="text-sm text-red-600"></div>
</div>
</div>
</div>
{/* 成功消息 */}
{deleteResult.success_count > 0 && (
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start gap-2">
<CheckCircleIcon className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-green-800">
{deleteResult.success_count}
</p>
</div>
</div>
</div>
)}
{/* 失败详情 */}
{deleteResult.failed_count > 0 && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-2">
<XCircleIcon className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800 mb-2">
{deleteResult.failed_count}
</p>
<button
onClick={() => setShowDetails(!showDetails)}
className="text-sm text-red-700 hover:text-red-800 underline"
>
{showDetails ? '隐藏详情' : '查看详情'}
</button>
{showDetails && (
<div className="mt-3 space-y-2">
{deleteResult.failed_items.map((item, index) => (
<div key={index} className="text-xs bg-white p-2 rounded border">
<div className="font-medium text-gray-900 truncate" title={item.id}>
ID: {item.id}
</div>
<div className="text-red-600 mt-1">{item.error_message}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end">
<button
type="button"
onClick={onCloseResult || onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
</button>
</div>
</div>
</Modal>
);
}
// 确认删除对话框
return (
<Modal
isOpen={isOpen}
onClose={deleting ? () => {} : onCancel}
title="批量删除素材"
icon={<ExclamationTriangleIcon className="h-6 w-6" />}
size="md"
variant="danger"
closeOnBackdropClick={!deleting}
closeOnEscape={!deleting}
>
<div className="p-6">
<p className="text-gray-600 mb-4 leading-relaxed">
<span className="font-semibold text-gray-900">{materials.length}</span>
</p>
{/* 素材列表预览 */}
<div className="mb-4 p-4 bg-gray-50 rounded-lg border max-h-48 overflow-y-auto">
<p className="text-sm font-medium text-gray-700 mb-2"></p>
<div className="space-y-1">
{materials.slice(0, 10).map((material) => (
<div key={material.id} className="text-sm text-gray-600 truncate" title={material.name}>
{material.name}
</div>
))}
{materials.length > 10 && (
<div className="text-sm text-gray-500 italic">
... {materials.length - 10}
</div>
)}
</div>
</div>
<div className="p-4 bg-red-50 border border-red-200 rounded-lg mb-6">
<div className="flex items-start gap-2">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-red-800 mb-1"></p>
<p className="text-sm text-red-700">
</p>
</div>
</div>
</div>
{/* 按钮区域 */}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
disabled={deleting}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={deleting}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{deleting && <LoadingSpinner size="small" />}
{deleting ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</Modal>
);
};

View File

@@ -19,6 +19,10 @@ interface MaterialCardProps {
onDelete?: (materialId: string, materialName: string) => void; onDelete?: (materialId: string, materialName: string) => void;
onReprocess?: (materialId: string) => void; onReprocess?: (materialId: string) => void;
onUsageReset?: () => void; // 使用状态重置后的回调 onUsageReset?: () => void; // 使用状态重置后的回调
// 批量选择相关
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelection?: (materialId: string) => void;
} }
// 格式化时间(秒转为 mm:ss 格式) // 格式化时间(秒转为 mm:ss 格式)
@@ -67,7 +71,16 @@ const formatDate = (dateString: string): string => {
* 素材卡片组件 * 素材卡片组件
* 显示素材信息和切分片段 * 显示素材信息和切分片段
*/ */
export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, onDelete, onReprocess, onUsageReset }) => { export const MaterialCard: React.FC<MaterialCardProps> = ({
material,
onEdit,
onDelete,
onReprocess,
onUsageReset,
isSelectionMode = false,
isSelected = false,
onToggleSelection
}) => {
const { getMaterialSegments } = useMaterialStore(); const { getMaterialSegments } = useMaterialStore();
const { startClassification, isLoading: classificationLoading } = useVideoClassificationStore(); const { startClassification, isLoading: classificationLoading } = useVideoClassificationStore();
const { usageStats } = useMaterialUsage(); const { usageStats } = useMaterialUsage();
@@ -312,7 +325,21 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
}; };
return ( return (
<div className="border border-gray-200 rounded-lg p-3 hover:shadow-md transition-shadow"> <div className={`border border-gray-200 rounded-lg p-3 hover:shadow-md transition-shadow relative ${
isSelected ? 'ring-2 ring-blue-500 bg-blue-50' : ''
}`}>
{/* 批量选择复选框 */}
{isSelectionMode && (
<div className="absolute top-2 left-2 z-10">
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelection?.(material.id)}
className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
</div>
)}
{/* 素材基本信息 */} {/* 素材基本信息 */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
{/* 缩略图 */} {/* 缩略图 */}

View File

@@ -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<string[]>([]);
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
};
}

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; 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 { invoke } from '@tauri-apps/api/core';
import { useProjectStore } from '../store/projectStore'; import { useProjectStore } from '../store/projectStore';
import { useMaterialStore } from '../store/materialStore'; import { useMaterialStore } from '../store/materialStore';
import { useVideoClassificationStore } from '../store/videoClassificationStore'; import { useVideoClassificationStore } from '../store/videoClassificationStore';
import { Project } from '../types/project'; 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 { ProjectBatchClassificationRequest, ProjectBatchClassificationResponse } from '../types/videoClassification';
import { LoadingSpinner } from '../components/LoadingSpinner'; import { LoadingSpinner } from '../components/LoadingSpinner';
import { ErrorMessage } from '../components/ErrorMessage'; import { ErrorMessage } from '../components/ErrorMessage';
@@ -42,6 +42,8 @@ import { TemplateMatchingResultManager } from '../components/TemplateMatchingRes
import { useNotifications } from '../components/NotificationSystem'; import { useNotifications } from '../components/NotificationSystem';
import { ProjectMaterialUsageOverviewComponent } from '../components/ProjectMaterialUsageOverview'; import { ProjectMaterialUsageOverviewComponent } from '../components/ProjectMaterialUsageOverview';
import { useMaterialUsage } from '../hooks/useMaterialUsage'; import { useMaterialUsage } from '../hooks/useMaterialUsage';
import { useBatchSelection, calculateSelectionState } from '../hooks/useBatchSelection';
import { BatchDeleteConfirmDialog } from '../components/BatchDeleteConfirmDialog';
// 格式化时间 // 格式化时间
const formatTime = (dateString: string) => { const formatTime = (dateString: string) => {
@@ -76,8 +78,10 @@ export const ProjectDetails: React.FC = () => {
loadMaterials, loadMaterials,
loadMaterialStats, loadMaterialStats,
deleteMaterial, deleteMaterial,
batchDeleteMaterials,
processMaterials, processMaterials,
isLoading: materialsLoading isLoading: materialsLoading,
isBatchDeleting
} = useMaterialStore(); } = useMaterialStore();
const { const {
startProjectBatchClassification, startProjectBatchClassification,
@@ -148,6 +152,16 @@ export const ProjectDetails: React.FC = () => {
const [materialClassificationRecords, setMaterialClassificationRecords] = useState<{ [materialId: string]: any[] }>({}); const [materialClassificationRecords, setMaterialClassificationRecords] = useState<{ [materialId: string]: any[] }>({});
const [modelsMap, setModelsMap] = useState<{ [modelId: string]: any }>({}); const [modelsMap, setModelsMap] = useState<{ [modelId: string]: any }>({});
// 批量删除状态
const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false);
const [batchDeleteResult, setBatchDeleteResult] = useState<BatchDeleteResult | null>(null);
// 批量选择Hook
const batchSelection = useBatchSelection({
enabled: true,
maxSelection: 50 // 限制最多选择50个素材
});
// 用于跟踪分类统计是否已加载的ref // 用于跟踪分类统计是否已加载的ref
const classificationStatsLoadedRef = useRef<string | null>(null); const classificationStatsLoadedRef = useRef<string | null>(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) => { const handleReprocessMaterial = async (materialId: string) => {
try { try {
@@ -800,6 +859,17 @@ export const ProjectDetails: React.FC = () => {
}); });
}, [materials, materialClassificationFilter, materialModelFilter, materialUsageFilter, materialClassificationRecords]); }, [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) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex items-center justify-center min-h-[400px]">
@@ -1264,14 +1334,85 @@ export const ProjectDetails: React.FC = () => {
{/* 素材列表 */} {/* 素材列表 */}
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900"></h3> <div className="flex items-center gap-4">
<button <h3 className="text-lg font-medium text-gray-900"></h3>
onClick={() => setShowImportDialog(true)}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" {/* 批量选择控制 */}
> {filteredMaterials.length > 0 && (
<Upload className="w-4 h-4 mr-2" /> <div className="flex items-center gap-2">
<button
</button> onClick={batchSelection.toggleSelectionMode}
className={`inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
batchSelection.isSelectionMode
? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{batchSelection.isSelectionMode ? (
<>
<CheckSquare className="w-4 h-4 mr-1" />
退
</>
) : (
<>
<Square className="w-4 h-4 mr-1" />
</>
)}
</button>
{/* 批量选择模式下的控制按钮 */}
{batchSelection.isSelectionMode && (
<>
<button
onClick={() => batchSelection.toggleAll(filteredMaterials.map(m => m.id))}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
{selectionState.isAllSelected ? (
<>
<MinusSquare className="w-4 h-4 mr-1" />
</>
) : (
<>
<CheckSquare className="w-4 h-4 mr-1" />
</>
)}
</button>
{batchSelection.selectedCount > 0 && (
<span className="text-sm text-gray-600">
{batchSelection.selectedCount}
</span>
)}
</>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* 批量删除按钮 */}
{batchSelection.isSelectionMode && batchSelection.selectedCount > 0 && (
<button
onClick={handleBatchDelete}
disabled={isBatchDeleting}
className="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-4 h-4 mr-2" />
({batchSelection.selectedCount})
</button>
)}
<button
onClick={() => setShowImportDialog(true)}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-4 h-4 mr-2" />
</button>
</div>
</div> </div>
{materialsLoading ? ( {materialsLoading ? (
@@ -1288,6 +1429,9 @@ export const ProjectDetails: React.FC = () => {
onDelete={handleDeleteMaterial} onDelete={handleDeleteMaterial}
onReprocess={handleReprocessMaterial} onReprocess={handleReprocessMaterial}
onUsageReset={() => project && loadUsageOverview(project.id)} onUsageReset={() => project && loadUsageOverview(project.id)}
isSelectionMode={batchSelection.isSelectionMode}
isSelected={batchSelection.isSelected(material.id)}
onToggleSelection={batchSelection.toggleItem}
/> />
))} ))}
</div> </div>
@@ -1571,6 +1715,17 @@ export const ProjectDetails: React.FC = () => {
progress={batchMatchingProgress} progress={batchMatchingProgress}
canCancel={batchMatchingLoading} canCancel={batchMatchingLoading}
/> />
{/* 批量删除确认对话框 */}
<BatchDeleteConfirmDialog
isOpen={showBatchDeleteDialog}
materials={selectedMaterials}
deleting={isBatchDeleting}
deleteResult={batchDeleteResult}
onConfirm={handleConfirmBatchDelete}
onCancel={handleCloseBatchDeleteDialog}
onCloseResult={handleCloseBatchDeleteDialog}
/>
</div> </div>
); );
}; };

View File

@@ -191,7 +191,7 @@ export const modelDynamicService = {
}, },
// 模拟统计数据 - 仅用于开发测试 // 模拟统计数据 - 仅用于开发测试
getMockStats(modelId: string): ModelDynamicStats { getMockStats(_modelId: string): ModelDynamicStats {
return { return {
total_dynamics: 2, total_dynamics: 2,
published_dynamics: 2, published_dynamics: 2,

View File

@@ -6,7 +6,8 @@ import {
CreateMaterialRequest, CreateMaterialRequest,
MaterialImportResult, MaterialImportResult,
ProcessingStatus, ProcessingStatus,
MaterialSegment MaterialSegment,
BatchDeleteResult
} from '../types/material'; } from '../types/material';
/** /**
@@ -21,6 +22,7 @@ interface MaterialState {
isLoading: boolean; isLoading: boolean;
isImporting: boolean; isImporting: boolean;
isProcessing: boolean; isProcessing: boolean;
isBatchDeleting: boolean;
error: string | null; error: string | null;
importProgress: { importProgress: {
current_file: string; current_file: string;
@@ -38,6 +40,7 @@ interface MaterialState {
getMaterialById: (id: string) => Promise<Material | null>; getMaterialById: (id: string) => Promise<Material | null>;
getMaterialSegments: (materialId: string) => Promise<MaterialSegment[]>; getMaterialSegments: (materialId: string) => Promise<MaterialSegment[]>;
deleteMaterial: (id: string) => Promise<void>; deleteMaterial: (id: string) => Promise<void>;
batchDeleteMaterials: (materialIds: string[]) => Promise<BatchDeleteResult>;
processMaterials: (materialIds: string[]) => Promise<void>; processMaterials: (materialIds: string[]) => Promise<void>;
updateMaterialStatus: (id: string, status: ProcessingStatus, errorMessage?: string) => Promise<void>; updateMaterialStatus: (id: string, status: ProcessingStatus, errorMessage?: string) => Promise<void>;
@@ -74,6 +77,7 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
isLoading: false, isLoading: false,
isImporting: false, isImporting: false,
isProcessing: false, isProcessing: false,
isBatchDeleting: false,
error: null, error: null,
importProgress: null, importProgress: null,
@@ -210,11 +214,11 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
deleteMaterial: async (id: string) => { deleteMaterial: async (id: string) => {
try { try {
await invoke('delete_material', { id }); await invoke('delete_material', { id });
// 从状态中移除 // 从状态中移除
const { materials } = get(); const { materials } = get();
set({ materials: materials.filter(m => m.id !== id) }); set({ materials: materials.filter(m => m.id !== id) });
// 如果删除的是当前素材,清空当前素材 // 如果删除的是当前素材,清空当前素材
const { currentMaterial } = get(); const { currentMaterial } = get();
if (currentMaterial?.id === id) { if (currentMaterial?.id === id) {
@@ -226,6 +230,32 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
} }
}, },
// 批量删除素材
batchDeleteMaterials: async (materialIds: string[]) => {
set({ isBatchDeleting: true, error: null });
try {
const result = await invoke<BatchDeleteResult>('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[]) => { processMaterials: async (materialIds: string[]) => {
set({ isProcessing: true, error: null }); set({ isProcessing: true, error: null });

View File

@@ -135,6 +135,20 @@ export interface MaterialStats {
processing_status_counts: Record<string, number>; processing_status_counts: Record<string, number>;
} }
// 批量删除相关类型
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 { export interface FileInfo {
name: string; name: string;
path: string; path: string;
@@ -154,6 +168,7 @@ export interface MaterialCommands {
get_project_materials(project_id: string): Promise<Material[]>; get_project_materials(project_id: string): Promise<Material[]>;
get_material_by_id(id: string): Promise<Material | null>; get_material_by_id(id: string): Promise<Material | null>;
delete_material(id: string): Promise<void>; delete_material(id: string): Promise<void>;
batch_delete_materials(material_ids: string[]): Promise<BatchDeleteResult>;
get_project_material_stats(project_id: string): Promise<MaterialStats>; get_project_material_stats(project_id: string): Promise<MaterialStats>;
batch_process_materials(material_ids: string[]): Promise<string[]>; batch_process_materials(material_ids: string[]): Promise<string[]>;
update_material_status(id: string, status: string, error_message?: string): Promise<void>; update_material_status(id: string, status: string, error_message?: string): Promise<void>;