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:
@@ -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<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(
|
||||
repository: &MaterialRepository,
|
||||
|
||||
@@ -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<String>,
|
||||
pub failed_items: Vec<BatchDeleteFailedItem>,
|
||||
}
|
||||
|
||||
@@ -193,6 +193,61 @@ impl MaterialRepository {
|
||||
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<()> {
|
||||
let conn = self.database.get_connection();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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]
|
||||
pub async fn get_project_material_stats(
|
||||
|
||||
45
apps/desktop/src-tauri/src/tests/batch_delete_test.rs
Normal file
45
apps/desktop/src-tauri/src/tests/batch_delete_test.rs
Normal 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, "测试错误消息");
|
||||
}
|
||||
}
|
||||
206
apps/desktop/src/components/BatchDeleteConfirmDialog.tsx
Normal file
206
apps/desktop/src/components/BatchDeleteConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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<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 { startClassification, isLoading: classificationLoading } = useVideoClassificationStore();
|
||||
const { usageStats } = useMaterialUsage();
|
||||
@@ -312,7 +325,21 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, on
|
||||
};
|
||||
|
||||
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">
|
||||
{/* 缩略图 */}
|
||||
|
||||
146
apps/desktop/src/hooks/useBatchSelection.ts
Normal file
146
apps/desktop/src/hooks/useBatchSelection.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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<BatchDeleteResult | null>(null);
|
||||
|
||||
// 批量选择Hook
|
||||
const batchSelection = useBatchSelection({
|
||||
enabled: true,
|
||||
maxSelection: 50 // 限制最多选择50个素材
|
||||
});
|
||||
|
||||
// 用于跟踪分类统计是否已加载的ref
|
||||
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) => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -1264,14 +1334,85 @@ export const ProjectDetails: React.FC = () => {
|
||||
{/* 素材列表 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">项目素材</h3>
|
||||
<button
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
导入素材
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">项目素材</h3>
|
||||
|
||||
{/* 批量选择控制 */}
|
||||
{filteredMaterials.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1571,6 +1715,17 @@ export const ProjectDetails: React.FC = () => {
|
||||
progress={batchMatchingProgress}
|
||||
canCancel={batchMatchingLoading}
|
||||
/>
|
||||
|
||||
{/* 批量删除确认对话框 */}
|
||||
<BatchDeleteConfirmDialog
|
||||
isOpen={showBatchDeleteDialog}
|
||||
materials={selectedMaterials}
|
||||
deleting={isBatchDeleting}
|
||||
deleteResult={batchDeleteResult}
|
||||
onConfirm={handleConfirmBatchDelete}
|
||||
onCancel={handleCloseBatchDeleteDialog}
|
||||
onCloseResult={handleCloseBatchDeleteDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -191,7 +191,7 @@ export const modelDynamicService = {
|
||||
},
|
||||
|
||||
// 模拟统计数据 - 仅用于开发测试
|
||||
getMockStats(modelId: string): ModelDynamicStats {
|
||||
getMockStats(_modelId: string): ModelDynamicStats {
|
||||
return {
|
||||
total_dynamics: 2,
|
||||
published_dynamics: 2,
|
||||
|
||||
@@ -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<Material | null>;
|
||||
getMaterialSegments: (materialId: string) => Promise<MaterialSegment[]>;
|
||||
deleteMaterial: (id: string) => Promise<void>;
|
||||
batchDeleteMaterials: (materialIds: string[]) => Promise<BatchDeleteResult>;
|
||||
processMaterials: (materialIds: 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,
|
||||
isImporting: false,
|
||||
isProcessing: false,
|
||||
isBatchDeleting: false,
|
||||
error: null,
|
||||
importProgress: null,
|
||||
|
||||
@@ -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[]) => {
|
||||
set({ isProcessing: true, error: null });
|
||||
|
||||
@@ -135,6 +135,20 @@ export interface MaterialStats {
|
||||
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 {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -154,6 +168,7 @@ export interface MaterialCommands {
|
||||
get_project_materials(project_id: string): Promise<Material[]>;
|
||||
get_material_by_id(id: string): Promise<Material | null>;
|
||||
delete_material(id: string): Promise<void>;
|
||||
batch_delete_materials(material_ids: string[]): Promise<BatchDeleteResult>;
|
||||
get_project_material_stats(project_id: string): Promise<MaterialStats>;
|
||||
batch_process_materials(material_ids: string[]): Promise<string[]>;
|
||||
update_material_status(id: string, status: string, error_message?: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user