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

431 lines
14 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, { useEffect, useState, useMemo } from 'react';
import {
Filter,
Tag,
Users,
Video,
RefreshCw,
Eye,
Edit,
Trash2
} 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;
};
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;
};
}
/**
* 素材片段管理组件 - 多条件检索标签页风格
*/
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 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">
{/* 缩略图 */}
<div className="w-20 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Video className="w-8 h-8 text-gray-400" />
</div>
{/* 内容信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 truncate">
{segment.material_name || '未知素材'}
</h4>
<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 className="p-1 text-gray-400 hover:text-gray-600 transition-colors">
<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>
);
};