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::{
|
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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -193,6 +193,61 @@ impl MaterialRepository {
|
|||||||
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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;
|
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">
|
||||||
{/* 缩略图 */}
|
{/* 缩略图 */}
|
||||||
|
|||||||
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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user