558 lines
21 KiB
TypeScript
558 lines
21 KiB
TypeScript
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>
|
||
);
|
||
};
|