feat: 方案对比页面集成ImageCard组件并实现本地下载功能
- 将方案对比页面的素材展示替换为ImageCard组件 - 创建MaterialSearchResult到GroundingSource的数据适配器 - 实现本地文件下载功能,支持用户选择保存目录 - 添加文件扩展名自动识别和文件类型过滤 - 集成Tauri的文件保存对话框和下载命令 - 优化用户体验,提供完整的图片展示和下载功能 - 支持查看大图和本地保存功能 - 保持下拉加载更多和刷新功能的完整性
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user