682 lines
25 KiB
TypeScript
682 lines
25 KiB
TypeScript
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>
|
||
);
|
||
};
|