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

682 lines
25 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, 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';
import { MaterialThumbnail } from './MaterialThumbnail';
import { MaterialUsageBadge } from './MaterialUsageStatus';
import { useMaterialUsage } from '../hooks/useMaterialUsage';
interface MaterialCardProps {
material: Material;
onEdit?: (material: Material) => void;
onDelete?: (materialId: string, materialName: string) => void;
onReprocess?: (materialId: string) => void;
onUsageReset?: () => 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, onUsageReset }) => {
const { getMaterialSegments } = useMaterialStore();
const { startClassification, isLoading: classificationLoading } = useVideoClassificationStore();
const { usageStats } = useMaterialUsage();
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 [thumbnailCache, setThumbnailCache] = useState<Map<string, string>>(new Map());
const [showDetails, setShowDetails] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isReprocessing, setIsReprocessing] = useState(false);
const [isResettingUsage, setIsResettingUsage] = useState(false);
// 获取素材的使用统计
const materialUsageStats = usageStats.find(stats => stats.material_id === material.id);
// 获取状态颜色
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(() => {
let isMounted = true; // 用于防止组件卸载后设置状态
const fetchAssociatedModel = async () => {
if (!material.model_id) {
if (isMounted) {
setAssociatedModel(null);
setLoadingModel(false);
}
return;
}
if (isMounted) {
setLoadingModel(true);
}
try {
const model = await invoke<Model>('get_model_by_id', { id: material.model_id });
if (isMounted) {
setAssociatedModel(model);
}
} catch (error) {
console.error('获取关联模特失败:', error);
if (isMounted) {
setAssociatedModel(null);
}
} finally {
if (isMounted) {
setLoadingModel(false);
}
}
};
fetchAssociatedModel();
// 清理函数
return () => {
isMounted = false;
};
}, [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);
}
};
// 处理重置使用状态
const handleResetUsage = async () => {
if (!materialUsageStats || materialUsageStats.used_segments === 0) return;
setIsResettingUsage(true);
try {
// 获取素材的所有片段ID
const segmentIds = segments.map(segment => segment.id);
// 如果没有加载片段,先加载
if (segmentIds.length === 0) {
await loadSegments();
// 重新获取片段ID
const updatedSegments = await getMaterialSegments(material.id);
const updatedSegmentIds = updatedSegments.map(segment => segment.id);
// 调用重置API
await invoke('reset_material_segment_usage', {
segmentIds: updatedSegmentIds
});
} else {
// 调用重置API
await invoke('reset_material_segment_usage', {
segmentIds
});
}
console.log('素材使用状态重置成功');
// 通知父组件刷新使用状态数据
if (onUsageReset) {
onUsageReset();
}
} catch (error) {
console.error('重置素材使用状态失败:', error);
} finally {
setIsResettingUsage(false);
}
};
return (
<div className="border border-gray-200 rounded-lg p-3 hover:shadow-md transition-shadow">
{/* 素材基本信息 */}
<div className="flex items-start gap-3 mb-3">
{/* 缩略图 */}
<MaterialThumbnail
material={material}
size="medium"
thumbnailCache={thumbnailCache}
setThumbnailCache={setThumbnailCache}
/>
{/* 素材信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 truncate text-sm">{material.name}</h4>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
{formatFileSize(material.file_size)}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(material.created_at)}
</span>
{/* 使用状态显示 */}
{materialUsageStats && (
<MaterialUsageBadge
usageCount={materialUsageStats.total_usage_count}
isUsed={materialUsageStats.used_segments > 0}
size="small"
/>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center space-x-1 ml-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 p-1"
title="重新处理"
>
{isReprocessing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
</button>
)}
{onEdit && (
<button
onClick={() => onEdit(material)}
className="text-gray-400 hover:text-blue-500 transition-colors p-1"
title="编辑素材"
>
<Edit2 className="w-3 h-3" />
</button>
)}
{onDelete && (
<button
onClick={handleDeleteClick}
className="text-gray-400 hover:text-red-500 transition-colors p-1"
title="删除素材"
>
<Trash2 className="w-3 h-3" />
</button>
)}
<span className={`px-2 py-1 text-xs rounded-full ${getStatusColor(material.processing_status)}`}>
{material.processing_status}
</span>
</div>
</div>
</div>
</div>
{/* 快速操作区域 */}
<div className="flex items-center justify-between pt-2">
<div className="flex items-center space-x-2">
{/* AI分类按钮 */}
{material.material_type === 'Video' && material.processing_status === 'Completed' && (
<button
onClick={handleStartClassification}
disabled={isClassifying || classificationLoading}
className="text-xs px-2 py-1 bg-blue-50 text-blue-600 rounded hover:bg-blue-100 disabled:opacity-50 flex items-center gap-1"
>
{isClassifying ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Brain className="w-3 h-3" />
)}
AI分类
</button>
)}
{/* 重置使用状态按钮 - 仅在有使用记录时显示 */}
{materialUsageStats && materialUsageStats.used_segments > 0 && (
<button
onClick={handleResetUsage}
disabled={isResettingUsage}
className="text-xs px-2 py-1 bg-orange-50 text-orange-600 rounded hover:bg-orange-100 disabled:opacity-50 flex items-center gap-1"
title="重置使用状态"
>
{isResettingUsage ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
使
</button>
)}
</div>
{/* 展开/折叠详细信息按钮 */}
<button
onClick={() => setShowDetails(!showDetails)}
className="text-gray-400 hover:text-gray-600 transition-colors text-xs flex items-center gap-1"
>
{showDetails ? (
<>
<span></span>
<ChevronUp className="w-3 h-3" />
</>
) : (
<>
<span></span>
<ChevronDown className="w-3 h-3" />
</>
)}
</button>
</div>
{/* 素材详细信息 - 可折叠 */}
{showDetails && (
<div className="space-y-3 pt-3 border-t border-gray-100">
{/* 关联模特信息 */}
{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>
</div>
{/* 切分片段列表 */}
{showSegments && segments.length > 0 && (
<div className="mt-3">
<div className="text-xs text-gray-500 mb-2">
{segments.length} {formatTime(segments.reduce((total, seg) => total + seg.duration, 0))}
</div>
<div className="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto">
{segments.map((segment) => (
<div key={segment.id} className="flex items-center justify-between p-1.5 bg-gray-50 rounded text-xs">
<div className="flex items-center space-x-1">
<span className="font-medium">#{segment.segment_index + 1}</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>
);
};