feat: 重构MaterialSegmentView为多条件检索标签页风格
核心功能重构: - 将MaterialSegmentView改为多条件检索的标签页风格 - 实现AI分类和模特两个主要检索维度 - 移除折叠/展开功能,改为直接平铺Card卡片风格 标签页检索系统: - AI分类标签页:显示所有分类选项及对应数量 - 模特标签页:显示所有模特选项及对应数量 - 全部选项:显示总片段数量 - 动态数量显示:每个选项显示对应的片段数量 UI/UX优化: - Card卡片风格:每个片段使用独立卡片展示 - 缩略图显示:支持视频缩略图预览 - 标签信息:显示分类、模特、置信度等信息 - 操作按钮:查看、编辑、删除等快捷操作 - 搜索功能:支持按片段名称、分类、模特搜索 数据展示优化: - 时长格式化:MM:SS格式显示时间 - 置信度显示:百分比形式显示AI分类置信度 - 状态标签:不同颜色区分分类和模特信息 - 响应式布局:适配不同屏幕尺寸 技术实现: - 使用get_project_segment_view API获取数据 - useMemo优化性能,避免不必要的重新计算 - TypeScript类型安全,完整的接口定义 - 错误处理和加载状态管理 交互体验: - 标签页切换:平滑的标签页切换动画 - 过滤选择:点击标签即可过滤对应内容 - 实时搜索:输入即时过滤结果 - 空状态处理:友好的空数据提示 性能优化: - 智能过滤:基于选中标签和搜索词的组合过滤 - 数据缓存:避免重复API调用 - 组件优化:使用React hooks优化渲染性能 这个重构完全满足了用户的需求: 1. 检索风格改为标签页 2. AI分类和模特维度,显示全部/具体选项+数量 3. 内容直接平铺Card卡片风格,无折叠/展开 4. 合理规划内容布局,美观易用
This commit is contained in:
parent
19a05f4e8a
commit
49bc9211d6
|
|
@ -0,0 +1,439 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Clock,
|
||||
Tag,
|
||||
Users,
|
||||
Play,
|
||||
Image,
|
||||
Video,
|
||||
RefreshCw,
|
||||
MoreHorizontal,
|
||||
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;
|
||||
};
|
||||
classification_info?: {
|
||||
category: string;
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
features: string[];
|
||||
product_match: boolean;
|
||||
quality_score: number;
|
||||
};
|
||||
model_info?: {
|
||||
id: string;
|
||||
name: string;
|
||||
model_type: string;
|
||||
};
|
||||
material_info: {
|
||||
id: string;
|
||||
name: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
duration: number;
|
||||
thumbnail_path?: 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 [activeTab, setActiveTab] = useState<'classification' | 'model'>('classification');
|
||||
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[] = [];
|
||||
|
||||
if (activeTab === 'classification') {
|
||||
if (selectedClassification === '全部') {
|
||||
// 获取所有片段
|
||||
segmentView.by_classification.forEach(group => {
|
||||
segments.push(...group.segments);
|
||||
});
|
||||
} else {
|
||||
// 获取特定分类的片段
|
||||
const group = segmentView.by_classification.find(g => g.category === selectedClassification);
|
||||
if (group) {
|
||||
segments = group.segments;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (selectedModel === '全部') {
|
||||
// 获取所有片段
|
||||
segmentView.by_model.forEach(group => {
|
||||
segments.push(...group.segments);
|
||||
});
|
||||
} else {
|
||||
// 获取特定模特的片段
|
||||
const group = segmentView.by_model.find(g => g.model_name === selectedModel);
|
||||
if (group) {
|
||||
segments = group.segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 应用搜索过滤
|
||||
if (searchTerm.trim()) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
segments = segments.filter(segment =>
|
||||
segment.material_info.name.toLowerCase().includes(searchLower) ||
|
||||
segment.classification_info?.category?.toLowerCase().includes(searchLower) ||
|
||||
segment.model_info?.name?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}, [segmentView, activeTab, selectedClassification, selectedModel, searchTerm]);
|
||||
|
||||
// 渲染片段卡片
|
||||
const renderSegmentCard = (segment: SegmentWithDetails) => (
|
||||
<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">
|
||||
{segment.material_info.thumbnail_path ? (
|
||||
<img
|
||||
src={segment.material_info.thumbnail_path}
|
||||
alt="缩略图"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<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_info.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_info && (
|
||||
<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_info.category}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{segment.model_info && (
|
||||
<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_info.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{segment.classification_info?.confidence && (
|
||||
<span className="text-xs text-gray-500">
|
||||
置信度: {Math.round(segment.classification_info.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="border-b border-gray-200">
|
||||
<nav className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('classification')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'classification'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag size={16} />
|
||||
<span>AI分类</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('model')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'model'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
<span>模特</span>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 过滤选项 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeTab === 'classification' ? (
|
||||
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-primary-100 text-primary-800 border border-primary-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>
|
||||
))
|
||||
) : (
|
||||
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-primary-100 text-primary-800 border border-primary-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 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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue