578 lines
18 KiB
TypeScript
578 lines
18 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import {
|
||
Filter,
|
||
Tag,
|
||
Users,
|
||
Video,
|
||
RefreshCw,
|
||
Eye,
|
||
Edit,
|
||
Trash2,
|
||
FolderOpen
|
||
} from 'lucide-react';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import { SearchInput } from './InteractiveInput';
|
||
import { InteractiveButton } from './InteractiveButton';
|
||
|
||
interface MaterialSegmentViewProps {
|
||
projectId: string;
|
||
}
|
||
|
||
interface SegmentWithDetails {
|
||
segment: {
|
||
id: string;
|
||
material_id: string;
|
||
start_time: number;
|
||
end_time: number;
|
||
duration: number;
|
||
file_path: string;
|
||
thumbnail_path?: string;
|
||
};
|
||
material_name: string;
|
||
material_type: string;
|
||
classification?: {
|
||
category: string;
|
||
confidence: number;
|
||
reasoning: string;
|
||
features: string[];
|
||
product_match: boolean;
|
||
quality_score: number;
|
||
};
|
||
model?: {
|
||
id: string;
|
||
name: string;
|
||
model_type: string;
|
||
};
|
||
}
|
||
|
||
interface ClassificationGroup {
|
||
category: string;
|
||
segment_count: number;
|
||
total_duration: number;
|
||
segments: SegmentWithDetails[];
|
||
}
|
||
|
||
interface ModelGroup {
|
||
model_id: string;
|
||
model_name: string;
|
||
segment_count: number;
|
||
total_duration: number;
|
||
segments: SegmentWithDetails[];
|
||
}
|
||
|
||
interface MaterialSegmentView {
|
||
project_id: string;
|
||
by_classification: ClassificationGroup[];
|
||
by_model: ModelGroup[];
|
||
stats: {
|
||
total_segments: number;
|
||
classified_segments: number;
|
||
unclassified_segments: number;
|
||
classification_coverage: number;
|
||
classification_counts: Record<string, number>;
|
||
model_counts: Record<string, number>;
|
||
total_duration: number;
|
||
};
|
||
}
|
||
|
||
// 提取文件名的工具函数
|
||
const extractFileName = (filePath: string): string => {
|
||
if (!filePath) return '未知文件';
|
||
|
||
// 处理Windows路径格式,包括长路径前缀
|
||
const cleanPath = filePath.replace(/^\\\\\?\\/, '');
|
||
const parts = cleanPath.split(/[\\\/]/);
|
||
return parts[parts.length - 1] || '未知文件';
|
||
};
|
||
|
||
// 打开文件所在目录
|
||
const openFileDirectory = async (filePath: string) => {
|
||
try {
|
||
await invoke('open_file_directory', { filePath });
|
||
} catch (error) {
|
||
console.error('打开目录失败:', error);
|
||
}
|
||
};
|
||
|
||
// 播放视频片段
|
||
const playVideoSegment = async (filePath: string, startTime: number, endTime: number) => {
|
||
try {
|
||
await invoke('play_video_segment', {
|
||
filePath,
|
||
startTime,
|
||
endTime
|
||
});
|
||
} catch (error) {
|
||
console.error('播放视频失败:', error);
|
||
}
|
||
};
|
||
|
||
// 生成片段缩略图(使用首帧)
|
||
const generateSegmentThumbnail = async (segment: SegmentWithDetails): Promise<string | null> => {
|
||
try {
|
||
const result = await invoke<string | null>('generate_and_save_segment_thumbnail', {
|
||
segmentId: segment.segment.id
|
||
});
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.error('生成缩略图失败:', error);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// 缩略图显示组件
|
||
interface ThumbnailDisplayProps {
|
||
segment: SegmentWithDetails;
|
||
thumbnailCache: Map<string, string>;
|
||
setThumbnailCache: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||
generateSegmentThumbnail: (segment: SegmentWithDetails) => Promise<string | null>;
|
||
}
|
||
|
||
const ThumbnailDisplay: React.FC<ThumbnailDisplayProps> = ({
|
||
segment,
|
||
thumbnailCache,
|
||
setThumbnailCache,
|
||
generateSegmentThumbnail
|
||
}) => {
|
||
const [loading, setLoading] = useState(false);
|
||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const loadThumbnail = async () => {
|
||
const segmentId = segment.segment.id;
|
||
|
||
// 检查缓存
|
||
if (thumbnailCache.has(segmentId)) {
|
||
setThumbnailUrl(thumbnailCache.get(segmentId) || null);
|
||
return;
|
||
}
|
||
|
||
// 首先检查数据库中是否已有缩略图
|
||
if (segment.segment.thumbnail_path) {
|
||
const thumbnailUrl = `file://${segment.segment.thumbnail_path}`;
|
||
setThumbnailUrl(thumbnailUrl);
|
||
// 更新缓存
|
||
setThumbnailCache(prev => new Map(prev.set(segmentId, thumbnailUrl)));
|
||
return;
|
||
}
|
||
|
||
// 如果数据库中没有缩略图,则生成新的
|
||
setLoading(true);
|
||
try {
|
||
const thumbnailPath = await generateSegmentThumbnail(segment);
|
||
if (thumbnailPath) {
|
||
// 转换为可访问的URL
|
||
const thumbnailUrl = `file://${thumbnailPath}`;
|
||
setThumbnailUrl(thumbnailUrl);
|
||
|
||
// 更新缓存
|
||
setThumbnailCache(prev => new Map(prev.set(segmentId, thumbnailUrl)));
|
||
}
|
||
} catch (error) {
|
||
console.error('加载缩略图失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
loadThumbnail();
|
||
}, [segment.segment.id, segment.segment.thumbnail_path, thumbnailCache, setThumbnailCache, generateSegmentThumbnail]);
|
||
|
||
return (
|
||
<div className="w-20 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||
{loading ? (
|
||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||
) : thumbnailUrl ? (
|
||
<img
|
||
src={thumbnailUrl}
|
||
alt="视频缩略图"
|
||
className="w-full h-full object-cover rounded-lg"
|
||
onError={() => setThumbnailUrl(null)}
|
||
/>
|
||
) : (
|
||
<Video className="w-8 h-8 text-gray-400" />
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 素材片段管理组件 - 多条件检索标签页风格
|
||
*/
|
||
export const MaterialSegmentView: React.FC<MaterialSegmentViewProps> = ({ projectId }) => {
|
||
const [segmentView, setSegmentView] = useState<MaterialSegmentView | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [selectedClassification, setSelectedClassification] = useState<string>('全部');
|
||
const [selectedModel, setSelectedModel] = useState<string>('全部');
|
||
const [thumbnailCache, setThumbnailCache] = useState<Map<string, string>>(new Map());
|
||
|
||
// 加载数据
|
||
const loadSegmentView = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const data = await invoke('get_project_segment_view', { projectId }) as MaterialSegmentView;
|
||
setSegmentView(data);
|
||
} catch (err) {
|
||
console.error('Failed to load segment view:', err);
|
||
setError('加载片段数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 初始加载
|
||
useEffect(() => {
|
||
if (projectId) {
|
||
loadSegmentView();
|
||
}
|
||
}, [projectId]);
|
||
|
||
// 格式化时长
|
||
const formatDuration = (seconds: number): string => {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = Math.floor(seconds % 60);
|
||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||
};
|
||
|
||
// 获取分类选项
|
||
const classificationOptions = useMemo(() => {
|
||
if (!segmentView) return [{ label: '全部', value: '全部', count: 0 }];
|
||
|
||
const options = [
|
||
{
|
||
label: '全部',
|
||
value: '全部',
|
||
count: segmentView.stats.total_segments
|
||
}
|
||
];
|
||
|
||
Object.entries(segmentView.stats.classification_counts).forEach(([category, count]) => {
|
||
options.push({
|
||
label: category || '未分类',
|
||
value: category || '未分类',
|
||
count
|
||
});
|
||
});
|
||
|
||
return options;
|
||
}, [segmentView]);
|
||
|
||
// 获取模特选项
|
||
const modelOptions = useMemo(() => {
|
||
if (!segmentView) return [{ label: '全部', value: '全部', count: 0 }];
|
||
|
||
const options = [
|
||
{
|
||
label: '全部',
|
||
value: '全部',
|
||
count: segmentView.stats.total_segments
|
||
}
|
||
];
|
||
|
||
Object.entries(segmentView.stats.model_counts).forEach(([modelName, count]) => {
|
||
options.push({
|
||
label: modelName || '未指定',
|
||
value: modelName || '未指定',
|
||
count
|
||
});
|
||
});
|
||
|
||
return options;
|
||
}, [segmentView]);
|
||
|
||
// 获取过滤后的片段
|
||
const filteredSegments = useMemo(() => {
|
||
if (!segmentView) return [];
|
||
|
||
// 获取所有片段
|
||
let segments: SegmentWithDetails[] = [];
|
||
segmentView.by_classification.forEach(group => {
|
||
segments.push(...group.segments);
|
||
});
|
||
|
||
// 应用分类过滤
|
||
if (selectedClassification !== '全部') {
|
||
segments = segments.filter(segment =>
|
||
segment.classification?.category === selectedClassification
|
||
);
|
||
}
|
||
|
||
// 应用模特过滤
|
||
if (selectedModel !== '全部') {
|
||
segments = segments.filter(segment =>
|
||
segment.model?.name === selectedModel
|
||
);
|
||
}
|
||
|
||
// 应用搜索过滤
|
||
if (searchTerm.trim()) {
|
||
const searchLower = searchTerm.toLowerCase();
|
||
segments = segments.filter(segment =>
|
||
segment.material_name.toLowerCase().includes(searchLower) ||
|
||
segment.classification?.category?.toLowerCase().includes(searchLower) ||
|
||
segment.model?.name?.toLowerCase().includes(searchLower)
|
||
);
|
||
}
|
||
|
||
return segments;
|
||
}, [segmentView, selectedClassification, selectedModel, searchTerm]);
|
||
|
||
// 渲染片段卡片
|
||
const renderSegmentCard = (segment: SegmentWithDetails) => {
|
||
// 安全检查
|
||
if (!segment || !segment.segment || !segment.material_name) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div key={segment.segment.id} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
||
<div className="flex items-start gap-4">
|
||
{/* 缩略图 */}
|
||
<ThumbnailDisplay
|
||
segment={segment}
|
||
thumbnailCache={thumbnailCache}
|
||
setThumbnailCache={setThumbnailCache}
|
||
generateSegmentThumbnail={generateSegmentThumbnail}
|
||
/>
|
||
|
||
{/* 内容信息 */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||
{extractFileName(segment.segment.file_path)}
|
||
</h4>
|
||
{segment.segment.file_path && (
|
||
<button
|
||
onClick={() => openFileDirectory(segment.segment.file_path)}
|
||
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||
title="打开文件所在目录"
|
||
>
|
||
<FolderOpen size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{formatDuration(segment.segment.start_time)} - {formatDuration(segment.segment.end_time)}
|
||
<span className="ml-2">时长: {formatDuration(segment.segment.duration)}</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="flex items-center gap-1 ml-2">
|
||
<button
|
||
onClick={() => playVideoSegment(
|
||
segment.segment.file_path,
|
||
segment.segment.start_time,
|
||
segment.segment.end_time
|
||
)}
|
||
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||
title="播放视频片段"
|
||
>
|
||
<Eye size={16} />
|
||
</button>
|
||
<button className="p-1 text-gray-400 hover:text-gray-600 transition-colors">
|
||
<Edit size={16} />
|
||
</button>
|
||
<button className="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 标签信息 */}
|
||
<div className="flex items-center gap-2 mt-3">
|
||
{segment.classification && (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||
<Tag size={12} className="mr-1" />
|
||
{segment.classification.category}
|
||
</span>
|
||
)}
|
||
|
||
{segment.model && (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||
<Users size={12} className="mr-1" />
|
||
{segment.model.name}
|
||
</span>
|
||
)}
|
||
|
||
{segment.classification?.confidence && (
|
||
<span className="text-xs text-gray-500">
|
||
置信度: {Math.round(segment.classification.confidence * 100)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-center py-12">
|
||
<div className="flex items-center gap-3">
|
||
<RefreshCw className="w-6 h-6 animate-spin text-primary-600" />
|
||
<span className="text-gray-600">加载片段数据中...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="text-center py-12">
|
||
<p className="text-red-600 mb-4">{error}</p>
|
||
<InteractiveButton
|
||
variant="primary"
|
||
onClick={loadSegmentView}
|
||
icon={<RefreshCw size={16} />}
|
||
>
|
||
重新加载
|
||
</InteractiveButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!segmentView) {
|
||
return (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500">暂无片段数据</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 搜索和刷新 */}
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="flex-1 max-w-md">
|
||
<SearchInput
|
||
value={searchTerm}
|
||
onChange={setSearchTerm}
|
||
placeholder="搜索片段、分类或模特..."
|
||
/>
|
||
</div>
|
||
<InteractiveButton
|
||
variant="secondary"
|
||
onClick={loadSegmentView}
|
||
icon={<RefreshCw size={16} />}
|
||
>
|
||
刷新
|
||
</InteractiveButton>
|
||
</div>
|
||
|
||
{/* 筛选条件 */}
|
||
<div className="space-y-4">
|
||
{/* AI分类筛选 - 单行显示 */}
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<Tag size={16} className="text-gray-600" />
|
||
<span className="text-sm font-medium text-gray-700">AI分类:</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{classificationOptions.map(option => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => setSelectedClassification(option.value)}
|
||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||
selectedClassification === option.value
|
||
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
|
||
}`}
|
||
>
|
||
<span>{option.label}</span>
|
||
<span className="ml-1.5 px-1.5 py-0.5 bg-white rounded-full text-xs">
|
||
{option.count}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 模特筛选 - 单行显示 */}
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<Users size={16} className="text-gray-600" />
|
||
<span className="text-sm font-medium text-gray-700">模特:</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{modelOptions.map(option => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => setSelectedModel(option.value)}
|
||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||
selectedModel === option.value
|
||
? 'bg-green-100 text-green-800 border border-green-200'
|
||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
|
||
}`}
|
||
>
|
||
<span>{option.label}</span>
|
||
<span className="ml-1.5 px-1.5 py-0.5 bg-white rounded-full text-xs">
|
||
{option.count}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 当前筛选条件显示 */}
|
||
{(selectedClassification !== '全部' || selectedModel !== '全部') && (
|
||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||
<Filter size={16} className="text-gray-500" />
|
||
<span className="text-sm text-gray-600">当前筛选:</span>
|
||
{selectedClassification !== '全部' && (
|
||
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||
AI分类: {selectedClassification}
|
||
</span>
|
||
)}
|
||
{selectedClassification !== '全部' && selectedModel !== '全部' && (
|
||
<span className="text-xs text-gray-500">AND</span>
|
||
)}
|
||
{selectedModel !== '全部' && (
|
||
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||
模特: {selectedModel}
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
setSelectedClassification('全部');
|
||
setSelectedModel('全部');
|
||
}}
|
||
className="ml-auto text-xs text-gray-500 hover:text-gray-700"
|
||
>
|
||
清除筛选
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 片段列表 */}
|
||
<div className="space-y-4">
|
||
{filteredSegments.length > 0 ? (
|
||
filteredSegments.map(segment => renderSegmentCard(segment))
|
||
) : (
|
||
<div className="text-center py-12">
|
||
<div className="flex flex-col items-center">
|
||
<Video className="w-12 h-12 text-gray-400 mb-4" />
|
||
<p className="text-gray-500 mb-2">没有找到匹配的片段</p>
|
||
<p className="text-sm text-gray-400">
|
||
{searchTerm ? '尝试调整搜索条件' : '选择不同的分类或模特'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|