feat: 为模板匹配片段添加缩略图功能

- 创建SegmentThumbnail组件,支持懒加载和缓存
- 修改TemplateMatchingResultDetailModal,集成缩略图显示
- 添加get_material_segment_by_id API命令获取片段详细信息
- 优化片段信息布局,简化显示内容(只显示片段名称和匹配原因)
- 支持通过material_segment_id获取和显示实际的片段文件名
This commit is contained in:
imeepos 2025-07-18 12:19:36 +08:00
parent 66f50a80c6
commit 822bfe6e9c
4 changed files with 208 additions and 16 deletions

View File

@ -79,6 +79,7 @@ pub fn run() {
commands::material_commands::get_segment_thumbnail_base64,
commands::material_commands::test_scene_detection,
commands::material_commands::get_material_segments,
commands::material_commands::get_material_segment_by_id,
commands::material_commands::test_video_split,
commands::material_commands::associate_material_to_model,
commands::material_commands::disassociate_material_from_model,

View File

@ -1467,6 +1467,22 @@ pub async fn get_material_segments(
.map_err(|e| e.to_string())
}
/// 根据片段ID获取片段详细信息命令
#[command]
pub async fn get_material_segment_by_id(
state: State<'_, AppState>,
segment_id: String,
) -> Result<Option<crate::data::models::material::MaterialSegment>, String> {
let repository_guard = state.get_material_repository()
.map_err(|e| format!("获取素材仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("素材仓库未初始化")?;
repository.get_segment_by_id_sync(&segment_id)
.map_err(|e| e.to_string())
}
/// 测试视频切分命令(用于调试不同切分模式)
#[command]
pub async fn test_video_split(

View File

@ -0,0 +1,123 @@
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Loader2, Image as ImageIcon } from 'lucide-react';
import { useLazyLoad } from '../hooks/useLazyLoad';
interface SegmentThumbnailProps {
segmentId: string;
size?: 'small' | 'medium' | 'large';
className?: string;
thumbnailCache?: Map<string, string>;
setThumbnailCache?: React.Dispatch<React.SetStateAction<Map<string, string>>>;
}
/**
*
*
* Tauri开发规范的组件设计模式
*
*/
export const SegmentThumbnail: React.FC<SegmentThumbnailProps> = ({
segmentId,
size = 'medium',
className = '',
thumbnailCache = new Map(),
setThumbnailCache = () => {},
}) => {
const [loading, setLoading] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
// 使用懒加载Hook当缩略图容器可见时才开始加载
const { isVisible, elementRef } = useLazyLoad(0.1, '100px');
// 根据size确定尺寸
const getSizeClasses = () => {
switch (size) {
case 'small':
return 'w-12 h-12';
case 'medium':
return 'w-16 h-16';
case 'large':
return 'w-24 h-24';
default:
return 'w-16 h-16';
}
};
// 获取图标尺寸
const getIconSize = () => {
switch (size) {
case 'small':
return 'w-3 h-3';
case 'medium':
return 'w-4 h-4';
case 'large':
return 'w-6 h-6';
default:
return 'w-4 h-4';
}
};
useEffect(() => {
// 只有当元素可见时才加载缩略图
if (!isVisible || !segmentId) return;
const loadThumbnail = async () => {
// 检查缓存
if (thumbnailCache.has(segmentId)) {
const cachedUrl = thumbnailCache.get(segmentId);
setThumbnailUrl(cachedUrl || null);
return;
}
// 加载缩略图
setLoading(true);
setError(false);
try {
console.log('获取片段缩略图:', segmentId);
const dataUrl = await invoke<string>('get_segment_thumbnail_base64', {
segmentId: segmentId
});
console.log('获取缩略图成功');
setThumbnailUrl(dataUrl);
// 更新缓存
const newCache = new Map(thumbnailCache);
newCache.set(segmentId, dataUrl);
setThumbnailCache(newCache);
} catch (error) {
console.error('获取缩略图失败:', error);
setError(true);
} finally {
setLoading(false);
}
};
loadThumbnail();
}, [isVisible, segmentId, thumbnailCache, setThumbnailCache]);
return (
<div
ref={elementRef}
className={`${getSizeClasses()} bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden ${className}`}
>
{loading ? (
<Loader2 className={`${getIconSize()} animate-spin text-blue-600`} />
) : thumbnailUrl && !error ? (
<img
src={thumbnailUrl}
alt="片段缩略图"
className="w-full h-full object-cover rounded-lg"
onError={() => {
setError(true);
setThumbnailUrl(null);
}}
/>
) : (
<ImageIcon className={`${getIconSize()} text-gray-400`} />
)}
</div>
);
};

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
import {
TemplateMatchingResultDetail,
MatchingResultStatus
} from '../types/templateMatchingResult';
@ -8,6 +8,7 @@ import { Modal } from './Modal';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
import { TabNavigation } from './TabNavigation';
import { SegmentThumbnail } from './SegmentThumbnail';
interface TemplateMatchingResultDetailModalProps {
resultId: string;
@ -24,6 +25,8 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('overview');
const [thumbnailCache, setThumbnailCache] = useState<Map<string, string>>(new Map());
const [segmentDetails, setSegmentDetails] = useState<Map<string, any>>(new Map());
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({
result_name: '',
@ -104,6 +107,49 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
return minutes > 0 ? `${minutes}:${remainingSeconds.padStart(4, '0')}` : `${remainingSeconds}s`;
};
// 从文件路径提取文件名
const extractFileName = (filePath: string): string => {
if (!filePath) return '';
return filePath.split(/[/\\]/).pop() || filePath;
};
// 获取片段详细信息
const getSegmentDetails = async (segmentId: string) => {
if (segmentDetails.has(segmentId)) {
return segmentDetails.get(segmentId);
}
try {
const segmentInfo = await invoke('get_material_segment_by_id', { segmentId });
setSegmentDetails(prev => new Map(prev.set(segmentId, segmentInfo)));
return segmentInfo;
} catch (error) {
console.error('获取片段详情失败:', error);
return null;
}
};
// 渲染片段信息组件
const SegmentInfo: React.FC<{ segment: any }> = ({ segment }) => {
const [segmentDetail, setSegmentDetail] = useState<any>(null);
useEffect(() => {
const loadSegmentDetail = async () => {
const detail = await getSegmentDetails(segment.material_segment_id);
setSegmentDetail(detail);
};
loadSegmentDetail();
}, [segment.material_segment_id]);
return (
<div className="text-sm text-gray-600">
<div>
<span className="font-medium">:</span> {segmentDetail ? extractFileName(segmentDetail.file_path) : segment.material_segment_id}
</div>
</div>
);
};
// 获取状态样式
const getStatusStyle = (status: MatchingResultStatus) => {
switch (status) {
@ -383,22 +429,28 @@ export const TemplateMatchingResultDetailModal: React.FC<TemplateMatchingResultD
<div className="space-y-3">
{segment_results.map((segment) => (
<div key={segment.id} className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{segment.track_segment_name}</h4>
<p className="text-sm text-gray-600 mt-1">
: {segment.material_name}
{segment.model_name && ` | 模特: ${segment.model_name}`}
</p>
<p className="text-sm text-gray-500 mt-1">
: {formatTime(segment.segment_duration)} |
: {formatTime(segment.start_time)} - {formatTime(segment.end_time)}
</p>
<p className="text-sm text-gray-500 mt-1">
: {segment.match_reason}
</p>
<div className="flex items-start gap-4">
{/* 缩略图 */}
<SegmentThumbnail
segmentId={segment.material_segment_id}
size="large"
thumbnailCache={thumbnailCache}
setThumbnailCache={setThumbnailCache}
/>
{/* 片段信息 */}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 mb-2">{segment.track_segment_name}</h4>
<SegmentInfo segment={segment} />
<div className="mt-2 text-sm text-gray-500">
<span className="font-medium">:</span> {segment.match_reason}
</div>
</div>
<div className="text-right">
{/* 匹配度 */}
<div className="text-right flex-shrink-0">
<div className="text-lg font-bold text-green-600">
{(segment.match_score * 100).toFixed(1)}%
</div>