mixvideo-v2/apps/desktop/src/components/MaterialCard.tsx

558 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import {
FileVideo, FileAudio, FileImage, File, Clock, ExternalLink, ChevronDown, ChevronUp,
Monitor, Volume2, Palette, Calendar, Hash, Zap, HardDrive, Film, Eye, Brain, Loader2, User, Edit2, Trash2, RefreshCw
} from 'lucide-react';
import { Material, MaterialSegment } from '../types/material';
import { useMaterialStore } from '../store/materialStore';
import { useVideoClassificationStore } from '../store/videoClassificationStore';
import { Model } from '../types/model';
import { invoke } from '@tauri-apps/api/core';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
interface MaterialCardProps {
material: Material;
onEdit?: (material: Material) => void;
onDelete?: (materialId: string, materialName: string) => void;
onReprocess?: (materialId: string) => void;
}
// 格式化时间(秒转为 mm:ss 格式)
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 格式化比特率
const formatBitrate = (bitrate: number): string => {
if (bitrate >= 1000000) {
return `${(bitrate / 1000000).toFixed(1)} Mbps`;
} else if (bitrate >= 1000) {
return `${(bitrate / 1000).toFixed(0)} Kbps`;
}
return `${bitrate} bps`;
};
// 格式化分辨率
const formatResolution = (width: number, height: number): string => {
return `${width}×${height}`;
};
// 格式化日期
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
/**
* 素材卡片组件
* 显示素材信息和切分片段
*/
export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit, onDelete, onReprocess }) => {
const { getMaterialSegments } = useMaterialStore();
const { startClassification, isLoading: classificationLoading } = useVideoClassificationStore();
const [segments, setSegments] = useState<MaterialSegment[]>([]);
const [showSegments, setShowSegments] = useState(false);
const [loadingSegments, setLoadingSegments] = useState(false);
const [isClassifying, setIsClassifying] = useState(false);
const [associatedModel, setAssociatedModel] = useState<Model | null>(null);
const [loadingModel, setLoadingModel] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isReprocessing, setIsReprocessing] = useState(false);
// 获取素材类型图标
const getTypeIcon = (type: string) => {
switch (type) {
case 'Video':
return <FileVideo className="w-4 h-4" />;
case 'Audio':
return <FileAudio className="w-4 h-4" />;
case 'Image':
return <FileImage className="w-4 h-4" />;
default:
return <File className="w-4 h-4" />;
}
};
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'Completed':
return 'text-green-600 bg-green-50';
case 'Processing':
return 'text-blue-600 bg-blue-50';
case 'Failed':
return 'text-red-600 bg-red-50';
case 'Pending':
return 'text-yellow-600 bg-yellow-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
// 获取关联的模特信息
useEffect(() => {
const fetchAssociatedModel = async () => {
if (!material.model_id) {
setAssociatedModel(null);
return;
}
setLoadingModel(true);
try {
const model = await invoke<Model>('get_model_by_id', { id: material.model_id });
setAssociatedModel(model);
} catch (error) {
console.error('获取关联模特失败:', error);
setAssociatedModel(null);
} finally {
setLoadingModel(false);
}
};
fetchAssociatedModel();
}, [material.model_id]);
// 加载切分片段
const loadSegments = async () => {
if (segments.length > 0) {
setShowSegments(!showSegments);
return;
}
setLoadingSegments(true);
try {
const materialSegments = await getMaterialSegments(material.id);
setSegments(materialSegments);
setShowSegments(true);
} catch (error) {
console.error('加载切分片段失败:', error);
} finally {
setLoadingSegments(false);
}
};
// 打开文件所在文件夹
const openFileLocation = async (filePath: string) => {
try {
const { revealItemInDir } = await import('@tauri-apps/plugin-opener');
// 处理 Windows 路径格式,移除 \\?\ 前缀
let normalizedPath = filePath;
if (normalizedPath.startsWith('\\\\?\\')) {
normalizedPath = normalizedPath.substring(4);
}
console.log('打开文件位置:', normalizedPath);
await revealItemInDir(normalizedPath);
} catch (error) {
console.error('打开文件位置失败:', error);
// 如果 revealItemInDir 失败,尝试打开文件所在目录
try {
const { openPath } = await import('@tauri-apps/plugin-opener');
// 获取文件所在目录
const pathParts = filePath.split(/[/\\]/);
pathParts.pop(); // 移除文件名
const dirPath = pathParts.join('\\');
let normalizedDirPath = dirPath;
if (normalizedDirPath.startsWith('\\\\?\\')) {
normalizedDirPath = normalizedDirPath.substring(4);
}
console.log('尝试打开目录:', normalizedDirPath);
await openPath(normalizedDirPath);
} catch (fallbackError) {
console.error('备用方法也失败:', fallbackError);
alert('无法打开文件位置,请检查文件是否存在');
}
}
};
// 启动AI分类
const handleStartClassification = async () => {
if (!material.project_id) {
console.error('缺少项目ID');
return;
}
setIsClassifying(true);
try {
const request = {
material_id: material.id,
project_id: material.project_id,
overwrite_existing: false,
priority: 1,
};
const taskIds = await startClassification(request);
console.log(`已创建 ${taskIds.length} 个分类任务`);
// 可以在这里显示成功消息或打开进度对话框
} catch (error) {
console.error('启动AI分类失败:', error);
// 可以在这里显示错误消息
} finally {
setIsClassifying(false);
}
};
// 处理删除素材
const handleDeleteClick = () => {
setShowDeleteConfirm(true);
};
const handleDeleteConfirm = async () => {
if (!onDelete) return;
setIsDeleting(true);
try {
await onDelete(material.id, material.name);
setShowDeleteConfirm(false);
} catch (error) {
console.error('删除素材失败:', error);
} finally {
setIsDeleting(false);
}
};
const handleDeleteCancel = () => {
setShowDeleteConfirm(false);
};
// 处理重新处理素材
const handleReprocessClick = async () => {
if (!onReprocess) return;
setIsReprocessing(true);
try {
await onReprocess(material.id);
} catch (error) {
console.error('重新处理素材失败:', error);
} finally {
setIsReprocessing(false);
}
};
return (
<div className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
{/* 素材基本信息 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{getTypeIcon(material.material_type)}
<h4 className="font-medium text-gray-900 truncate">{material.name}</h4>
</div>
<div className="flex items-center space-x-2">
{/* 重新处理按钮 - 仅在状态为 Pending 时显示 */}
{material.processing_status === 'Pending' && onReprocess && (
<button
onClick={handleReprocessClick}
disabled={isReprocessing}
className="text-gray-400 hover:text-green-500 transition-colors disabled:opacity-50"
title="重新处理"
>
{isReprocessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</button>
)}
{onEdit && (
<button
onClick={() => onEdit(material)}
className="text-gray-400 hover:text-blue-500 transition-colors"
title="编辑素材"
>
<Edit2 className="w-4 h-4" />
</button>
)}
{onDelete && (
<button
onClick={handleDeleteClick}
className="text-gray-400 hover:text-red-500 transition-colors"
title="删除素材"
>
<Trash2 className="w-4 h-4" />
</button>
)}
<span className={`px-2 py-1 text-xs rounded-full ${getStatusColor(material.processing_status)}`}>
{material.processing_status}
</span>
</div>
</div>
{/* 素材详细信息 */}
<div className="space-y-3">
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex items-center space-x-1 text-gray-600">
<HardDrive className="w-3 h-3" />
<span>{formatFileSize(material.file_size)}</span>
</div>
<div className="flex items-center space-x-1 text-gray-600">
<Calendar className="w-3 h-3" />
<span>{formatDate(material.created_at)}</span>
</div>
</div>
{/* 关联模特信息 */}
{material.model_id && (
<div className="bg-purple-50 rounded-lg p-3">
<div className="flex items-center space-x-2">
<User className="w-4 h-4 text-purple-600" />
<span className="text-sm font-medium text-purple-700"></span>
</div>
{loadingModel ? (
<div className="flex items-center space-x-2 mt-2">
<Loader2 className="w-3 h-3 animate-spin text-purple-600" />
<span className="text-xs text-purple-600">...</span>
</div>
) : associatedModel ? (
<div className="mt-2 space-y-1">
<p className="text-sm font-medium text-gray-900">{associatedModel.name}</p>
{associatedModel.stage_name && (
<p className="text-xs text-gray-600">: {associatedModel.stage_name}</p>
)}
{associatedModel.description && (
<p className="text-xs text-gray-600">{associatedModel.description}</p>
)}
</div>
) : (
<p className="text-xs text-red-600 mt-2"></p>
)}
</div>
)}
{/* 元数据信息 */}
{material.metadata !== 'None' && (
<div className="space-y-2">
{/* 视频元数据 */}
{material.metadata && typeof material.metadata === 'object' && 'Video' in material.metadata && (
<div className="bg-blue-50 rounded-lg p-3 space-y-2">
<div className="flex items-center space-x-1 text-blue-700 font-medium text-sm">
<FileVideo className="w-4 h-4" />
<span></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div className="flex items-center space-x-1">
<Clock className="w-3 h-3" />
<span>{formatTime(material.metadata.Video.duration)}</span>
</div>
<div className="flex items-center space-x-1">
<Monitor className="w-3 h-3" />
<span>{formatResolution(material.metadata.Video.width, material.metadata.Video.height)}</span>
</div>
<div className="flex items-center space-x-1">
<Zap className="w-3 h-3" />
<span>{formatBitrate(material.metadata.Video.bitrate)}</span>
</div>
<div className="flex items-center space-x-1">
<Film className="w-3 h-3" />
<span>{material.metadata.Video.fps} fps</span>
</div>
<div className="flex items-center space-x-1">
<Hash className="w-3 h-3" />
<span>{material.metadata.Video.codec}</span>
</div>
{material.metadata.Video.has_audio && (
<div className="flex items-center space-x-1">
<Volume2 className="w-3 h-3" />
<span>{material.metadata.Video.audio_codec || 'Audio'}</span>
</div>
)}
</div>
</div>
)}
{/* 音频元数据 */}
{material.metadata && typeof material.metadata === 'object' && 'Audio' in material.metadata && (
<div className="bg-green-50 rounded-lg p-3 space-y-2">
<div className="flex items-center space-x-1 text-green-700 font-medium text-sm">
<FileAudio className="w-4 h-4" />
<span></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div className="flex items-center space-x-1">
<Clock className="w-3 h-3" />
<span>{formatTime(material.metadata.Audio.duration)}</span>
</div>
<div className="flex items-center space-x-1">
<Zap className="w-3 h-3" />
<span>{formatBitrate(material.metadata.Audio.bitrate)}</span>
</div>
<div className="flex items-center space-x-1">
<Volume2 className="w-3 h-3" />
<span>{material.metadata.Audio.sample_rate} Hz</span>
</div>
<div className="flex items-center space-x-1">
<Hash className="w-3 h-3" />
<span>{material.metadata.Audio.codec}</span>
</div>
</div>
</div>
)}
{/* 图片元数据 */}
{material.metadata && typeof material.metadata === 'object' && 'Image' in material.metadata && (
<div className="bg-purple-50 rounded-lg p-3 space-y-2">
<div className="flex items-center space-x-1 text-purple-700 font-medium text-sm">
<FileImage className="w-4 h-4" />
<span></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div className="flex items-center space-x-1">
<Monitor className="w-3 h-3" />
<span>{formatResolution(material.metadata.Image.width, material.metadata.Image.height)}</span>
</div>
<div className="flex items-center space-x-1">
<Palette className="w-3 h-3" />
<span>{material.metadata.Image.format}</span>
</div>
{material.metadata.Image.dpi && (
<div className="flex items-center space-x-1">
<Eye className="w-3 h-3" />
<span>{material.metadata.Image.dpi} DPI</span>
</div>
)}
</div>
</div>
)}
</div>
)}
{/* 处理统计信息 */}
{(material.scene_detection || material.segments.length > 0) && (
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
<div className="flex items-center space-x-1 text-gray-700 font-medium text-sm">
<Film className="w-4 h-4" />
<span></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
{material.scene_detection && (
<div className="flex items-center space-x-1">
<Eye className="w-3 h-3" />
<span>{material.scene_detection.total_scenes} </span>
</div>
)}
{material.segments.length > 0 && (
<div className="flex items-center space-x-1">
<Hash className="w-3 h-3" />
<span>{material.segments.length} </span>
</div>
)}
{material.processed_at && (
<div className="flex items-center space-x-1">
<Calendar className="w-3 h-3" />
<span></span>
</div>
)}
</div>
</div>
)}
</div>
{/* 切分片段控制 */}
{material.material_type === 'Video' && material.processing_status === 'Completed' && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex items-center justify-between">
<button
onClick={loadSegments}
disabled={loadingSegments}
className="flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
{loadingSegments ? (
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
) : showSegments ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
<span>{loadingSegments ? '加载中...' : showSegments ? '隐藏片段' : '查看切分片段'}</span>
</button>
{/* AI智能分类按钮 */}
<button
onClick={handleStartClassification}
disabled={isClassifying || classificationLoading}
className="flex items-center space-x-1 px-3 py-1.5 text-xs font-medium text-white bg-gradient-to-r from-purple-500 to-pink-500 rounded-md hover:from-purple-600 hover:to-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md"
title="使用AI自动分类视频片段"
>
{isClassifying ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Brain className="w-3 h-3" />
)}
<span>{isClassifying ? '分类中...' : 'AI分类'}</span>
</button>
</div>
{/* 切分片段列表 */}
{showSegments && segments.length > 0 && (
<div className="mt-3 space-y-2">
<h5 className="text-sm font-medium text-gray-900"> ({segments.length})</h5>
<div className="space-y-1 max-h-40 overflow-y-auto">
{segments.map((segment) => (
<div key={segment.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
<div className="flex items-center space-x-2">
<span className="font-medium">#{segment.segment_index + 1}</span>
<Clock className="w-3 h-3" />
<span>{formatTime(segment.start_time)} - {formatTime(segment.end_time)}</span>
<span className="text-gray-500">({formatTime(segment.duration)})</span>
</div>
<button
onClick={() => openFileLocation(segment.file_path)}
className="text-blue-600 hover:text-blue-800"
title="打开文件位置"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{showSegments && segments.length === 0 && (
<div className="mt-3 text-sm text-gray-500">
</div>
)}
</div>
)}
{/* 删除确认对话框 */}
<DeleteConfirmDialog
isOpen={showDeleteConfirm}
title="删除素材"
message="确定要删除这个素材吗?此操作不可撤销。"
itemName={material.name}
deleting={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
</div>
);
};