feat: 方案对比页面集成ImageCard组件并实现本地下载功能

- 将方案对比页面的素材展示替换为ImageCard组件
- 创建MaterialSearchResult到GroundingSource的数据适配器
- 实现本地文件下载功能,支持用户选择保存目录
- 添加文件扩展名自动识别和文件类型过滤
- 集成Tauri的文件保存对话框和下载命令
- 优化用户体验,提供完整的图片展示和下载功能
- 支持查看大图和本地保存功能
- 保持下拉加载更多和刷新功能的完整性
This commit is contained in:
imeepos
2025-07-28 16:40:18 +08:00
parent 8a5988b3de
commit 63e40091ac

View File

@@ -2,6 +2,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Heart, Search, Loader2, ArrowLeftRight, X, RefreshCw, RotateCcw } from 'lucide-react';
import { OutfitFavorite, CompareOutfitFavoritesResponse } from '../../types/outfitFavorite';
import { OutfitFavoriteService } from '../../services/outfitFavoriteService';
import ImageCard from '../ImageCard';
import { GroundingSource } from '../../types/ragGrounding';
import { save } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
interface OutfitComparisonViewProps {
/** 第一个收藏方案 */
@@ -31,6 +36,20 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 加载更多和刷新状态
const [isLoadingMore1, setIsLoadingMore1] = useState(false);
const [isLoadingMore2, setIsLoadingMore2] = useState(false);
const [isRefreshing1, setIsRefreshing1] = useState(false);
const [isRefreshing2, setIsRefreshing2] = useState(false);
const [hasMoreData1, setHasMoreData1] = useState(true);
const [hasMoreData2, setHasMoreData2] = useState(true);
const [currentPage1, setCurrentPage1] = useState(1);
const [currentPage2, setCurrentPage2] = useState(1);
// 滚动容器引用
const scrollContainer1Ref = useRef<HTMLDivElement>(null);
const scrollContainer2Ref = useRef<HTMLDivElement>(null);
// 执行对比
const performComparison = useCallback(async () => {
setIsLoading(true);
@@ -64,6 +83,12 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
setComparisonData(response);
// 设置分页状态
setHasMoreData1(response.materials_1.current_page < response.materials_1.total_pages);
setHasMoreData2(response.materials_2.current_page < response.materials_2.total_pages);
setCurrentPage1(response.materials_1.current_page);
setCurrentPage2(response.materials_2.current_page);
// 清理临时收藏(可选,或者可以在组件卸载时清理)
// 这里暂时保留,让用户可以看到完整的对比结果
} catch (tempError) {
@@ -77,6 +102,12 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
favorite2.id
);
setComparisonData(response);
// 设置分页状态
setHasMoreData1(response.materials_1.current_page < response.materials_1.total_pages);
setHasMoreData2(response.materials_2.current_page < response.materials_2.total_pages);
setCurrentPage1(response.materials_1.current_page);
setCurrentPage2(response.materials_2.current_page);
}
} catch (err) {
console.error('对比失败:', err);
@@ -86,6 +117,220 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
}
}, [favorite1, favorite2, isTemporary]);
// 加载更多素材 - 左侧
const loadMoreMaterials1 = useCallback(async () => {
if (!comparisonData || isLoadingMore1 || !hasMoreData1) return;
setIsLoadingMore1(true);
try {
const nextPage = currentPage1 + 1;
const response = await OutfitFavoriteService.searchMaterialsByFavorite(
comparisonData.favorite_1.id,
nextPage,
9
);
setComparisonData(prev => prev ? {
...prev,
materials_1: {
...response,
results: [...prev.materials_1.results, ...response.results]
}
} : null);
setCurrentPage1(nextPage);
setHasMoreData1(nextPage < response.total_pages);
} catch (error) {
console.error('加载更多素材失败 (左侧):', error);
} finally {
setIsLoadingMore1(false);
}
}, [comparisonData, isLoadingMore1, hasMoreData1, currentPage1]);
// 加载更多素材 - 右侧
const loadMoreMaterials2 = useCallback(async () => {
if (!comparisonData || isLoadingMore2 || !hasMoreData2) return;
setIsLoadingMore2(true);
try {
const nextPage = currentPage2 + 1;
const response = await OutfitFavoriteService.searchMaterialsByFavorite(
comparisonData.favorite_2.id,
nextPage,
9
);
setComparisonData(prev => prev ? {
...prev,
materials_2: {
...response,
results: [...prev.materials_2.results, ...response.results]
}
} : null);
setCurrentPage2(nextPage);
setHasMoreData2(nextPage < response.total_pages);
} catch (error) {
console.error('加载更多素材失败 (右侧):', error);
} finally {
setIsLoadingMore2(false);
}
}, [comparisonData, isLoadingMore2, hasMoreData2, currentPage2]);
// 刷新素材 - 左侧
const refreshMaterials1 = useCallback(async () => {
if (!comparisonData || isRefreshing1) return;
setIsRefreshing1(true);
try {
const response = await OutfitFavoriteService.searchMaterialsByFavorite(
comparisonData.favorite_1.id,
1,
9
);
setComparisonData(prev => prev ? {
...prev,
materials_1: response
} : null);
setCurrentPage1(1);
setHasMoreData1(1 < response.total_pages);
} catch (error) {
console.error('刷新素材失败 (左侧):', error);
} finally {
setIsRefreshing1(false);
}
}, [comparisonData, isRefreshing1]);
// 刷新素材 - 右侧
const refreshMaterials2 = useCallback(async () => {
if (!comparisonData || isRefreshing2) return;
setIsRefreshing2(true);
try {
const response = await OutfitFavoriteService.searchMaterialsByFavorite(
comparisonData.favorite_2.id,
1,
9
);
setComparisonData(prev => prev ? {
...prev,
materials_2: response
} : null);
setCurrentPage2(1);
setHasMoreData2(1 < response.total_pages);
} catch (error) {
console.error('刷新素材失败 (右侧):', error);
} finally {
setIsRefreshing2(false);
}
}, [comparisonData, isRefreshing2]);
// 滚动监听 - 左侧
useEffect(() => {
const container = scrollContainer1Ref.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100;
if (isNearBottom && hasMoreData1 && !isLoadingMore1 && !isLoading) {
loadMoreMaterials1();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasMoreData1, isLoadingMore1, isLoading, loadMoreMaterials1]);
// 滚动监听 - 右侧
useEffect(() => {
const container = scrollContainer2Ref.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100;
if (isNearBottom && hasMoreData2 && !isLoadingMore2 && !isLoading) {
loadMoreMaterials2();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasMoreData2, isLoadingMore2, isLoading, loadMoreMaterials2]);
// 将MaterialSearchResult转换为GroundingSource的适配器函数
const convertToGroundingSource = useCallback((material: any): GroundingSource => {
return {
uri: material.image_url,
title: material.style_description || '素材图片',
content: {
text: material.style_description || '',
id: material.id,
material_type: material.material_type,
relevance_score: material.relevance_score,
environment_tags: material.environment_tags || [],
style_tags: material.style_tags || [],
occasion_tags: material.occasion_tags || [],
color_info: material.color_info,
// 为ImageCard提供必要的显示信息
description: material.style_description,
models: [],
categories: material.style_tags || [],
environmentTags: material.environment_tags || [],
releaseDate: new Date().toISOString()
}
};
}, []);
// 下载素材到本地
const downloadMaterial = useCallback(async (source: GroundingSource, materialId: string) => {
try {
if (!source.uri) {
console.error('图片URL不存在');
return;
}
// 获取文件扩展名
const url = new URL(source.uri);
const pathname = url.pathname;
const extension = pathname.split('.').pop() || 'jpg';
// 打开保存对话框
const filePath = await save({
defaultPath: `material_${materialId}.${extension}`,
filters: [
{
name: '图片文件',
extensions: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
}
]
});
if (!filePath) {
// 用户取消了保存
return;
}
// 使用Tauri命令下载文件
await invoke('download_image_from_uri', {
uri: source.uri,
filePath: filePath
});
console.log('文件下载成功:', filePath);
} catch (error) {
console.error('下载文件失败:', error);
// 可以添加错误提示
}
}, []);
// 初始加载
useEffect(() => {
performComparison();
@@ -135,7 +380,7 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
);
};
// 渲染素材网格
// 渲染素材网格 - 使用ImageCard组件
const renderMaterialGrid = (materials: any[], _side: 'left' | 'right') => {
if (materials.length === 0) {
return (
@@ -149,36 +394,27 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{materials.map((material, index) => (
<div key={material.id || index} className="bg-white rounded-lg border border-gray-200 p-3 hover:shadow-md transition-shadow">
<img
src={material.image_url}
alt="Material"
className="w-full h-32 object-cover rounded-lg mb-2"
onError={(e) => {
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg==';
<div className="grid grid-cols-3 gap-4">
{materials.map((material, index) => {
const groundingSource = convertToGroundingSource(material);
return (
<ImageCard
key={material.id || index}
source={groundingSource}
showDetails={true}
className="w-full"
onViewLarge={(source) => {
// 可以添加查看大图的逻辑
if (source.uri) {
window.open(source.uri, '_blank', 'noopener,noreferrer');
}
}}
onDownload={(source) => {
downloadMaterial(source, material.id || `${index}`);
}}
/>
<div className="space-y-1">
<p className="text-xs text-gray-600 line-clamp-2">
{material.style_description}
</p>
<div className="flex flex-wrap gap-1">
{material.environment_tags?.slice(0, 2).map((tag: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 bg-gray-100 text-xs rounded">
{tag}
</span>
))}
</div>
{material.relevance_score && (
<div className="text-xs text-gray-500">
: {(material.relevance_score * 100).toFixed(1)}%
</div>
)}
</div>
</div>
))}
);
})}
</div>
);
};
@@ -249,12 +485,51 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
<div className="flex-1 min-h-0">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700"></h3>
<span className="text-xs text-gray-500">
{comparisonData.materials_1.total_size}
</span>
<div className="flex items-center gap-2">
{/* 刷新按钮 */}
<button
onClick={refreshMaterials1}
disabled={isRefreshing1 || isLoading}
className="p-1 text-gray-500 hover:text-primary-600 rounded hover:bg-primary-50 transition-colors disabled:opacity-50"
title="刷新数据"
>
<RotateCcw className={`w-4 h-4 ${isRefreshing1 ? 'animate-spin' : ''}`} />
</button>
<span className="text-xs text-gray-500">
{comparisonData.materials_1.results.length} / {comparisonData.materials_1.total_size}
</span>
</div>
</div>
<div className="h-full overflow-y-auto">
<div ref={scrollContainer1Ref} className="h-full overflow-y-auto">
{renderMaterialGrid(comparisonData.materials_1.results, 'left')}
{/* 加载更多指示器 */}
{isLoadingMore1 && (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 text-primary-500 animate-spin mr-2" />
<span className="text-sm text-gray-600">...</span>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMoreData1 && comparisonData.materials_1.results.length > 0 && (
<div className="text-center py-4 text-gray-500 text-xs">
</div>
)}
{/* 手动加载更多按钮(备用) */}
{hasMoreData1 && !isLoadingMore1 && comparisonData.materials_1.results.length > 0 && (
<div className="text-center py-4">
<button
onClick={loadMoreMaterials1}
className="px-4 py-2 text-sm text-primary-600 border border-primary-300 rounded-lg hover:bg-primary-50 transition-colors"
>
</button>
</div>
)}
</div>
</div>
</div>
@@ -268,12 +543,51 @@ export const OutfitComparisonView: React.FC<OutfitComparisonViewProps> = ({
<div className="flex-1 min-h-0">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700"></h3>
<span className="text-xs text-gray-500">
{comparisonData.materials_2.total_size}
</span>
<div className="flex items-center gap-2">
{/* 刷新按钮 */}
<button
onClick={refreshMaterials2}
disabled={isRefreshing2 || isLoading}
className="p-1 text-gray-500 hover:text-primary-600 rounded hover:bg-primary-50 transition-colors disabled:opacity-50"
title="刷新数据"
>
<RotateCcw className={`w-4 h-4 ${isRefreshing2 ? 'animate-spin' : ''}`} />
</button>
<span className="text-xs text-gray-500">
{comparisonData.materials_2.results.length} / {comparisonData.materials_2.total_size}
</span>
</div>
</div>
<div className="h-full overflow-y-auto">
<div ref={scrollContainer2Ref} className="h-full overflow-y-auto">
{renderMaterialGrid(comparisonData.materials_2.results, 'right')}
{/* 加载更多指示器 */}
{isLoadingMore2 && (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 text-primary-500 animate-spin mr-2" />
<span className="text-sm text-gray-600">...</span>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMoreData2 && comparisonData.materials_2.results.length > 0 && (
<div className="text-center py-4 text-gray-500 text-xs">
</div>
)}
{/* 手动加载更多按钮(备用) */}
{hasMoreData2 && !isLoadingMore2 && comparisonData.materials_2.results.length > 0 && (
<div className="text-center py-4">
<button
onClick={loadMoreMaterials2}
className="px-4 py-2 text-sm text-primary-600 border border-primary-300 rounded-lg hover:bg-primary-50 transition-colors"
>
</button>
</div>
)}
</div>
</div>
</div>