feat: add Outfit Comparison Tool and Outfit Favorites Tool

- Implemented OutfitComparisonTool for comparing two favorite outfits side by side.
- Added OutfitFavoritesTool for managing and searching favorite outfits.
- Created OutfitFavoriteService for handling API interactions related to outfit favorites.
- Defined types for material search and outfit favorites to ensure type safety.
- Enhanced UI components for better user experience in selecting and displaying outfits.
This commit is contained in:
imeepos
2025-07-28 15:53:20 +08:00
parent 5c019b48df
commit 8a5988b3de
36 changed files with 4262 additions and 148 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import {
Sparkles,
Wand2,
@@ -7,7 +7,9 @@ import {
Copy,
RefreshCw,
Settings,
Info
Info,
Heart,
ArrowLeftRight
} from 'lucide-react';
import OutfitRecommendationService from '../../services/outfitRecommendationService';
import {
@@ -22,6 +24,23 @@ import OutfitRecommendationList from '../../components/outfit/OutfitRecommendati
import { CustomSelect } from '../../components/CustomSelect';
import { MaterialSearchPanel, MaterialDetailModal } from '../../components/material';
// localStorage 键名
const STORAGE_KEY = 'outfit-recommendation-state';
// 状态接口
interface SavedState {
query: string;
targetStyle: string;
occasions: string[];
season: string;
colorPreferences: string[];
count: number;
groups: OutfitRecommendationGroup[];
groupingStrategy: GroupingStrategy | null;
recommendations: OutfitRecommendation[];
showAdvanced: boolean;
}
/**
* AI穿搭方案推荐小工具
* 遵循 Tauri 开发规范和 UI/UX 设计标准
@@ -51,6 +70,110 @@ const OutfitRecommendationTool: React.FC = () => {
const [showMaterialDetail, setShowMaterialDetail] = useState(false);
const [selectedMaterial, setSelectedMaterial] = useState<any>(null);
// 方案对比状态
const [selectedForComparison, setSelectedForComparison] = useState<OutfitRecommendation[]>([]);
// 状态恢复提示
const [showRestoredTip, setShowRestoredTip] = useState(false);
// 保存状态到localStorage
const saveState = useCallback(() => {
try {
const state: SavedState = {
query,
targetStyle,
occasions,
season,
colorPreferences,
count,
groups,
groupingStrategy,
recommendations,
showAdvanced,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('保存状态失败:', error);
}
}, [query, targetStyle, occasions, season, colorPreferences, count, groups, groupingStrategy, recommendations, showAdvanced]);
// 从localStorage恢复状态
const restoreState = useCallback(() => {
try {
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState) {
const state: SavedState = JSON.parse(savedState);
setQuery(state.query || '');
setTargetStyle(state.targetStyle || '');
setOccasions(state.occasions || []);
setSeason(state.season || '');
setColorPreferences(state.colorPreferences || []);
setCount(state.count || 3);
setGroups(state.groups || []);
setGroupingStrategy(state.groupingStrategy || null);
setRecommendations(state.recommendations || []);
setShowAdvanced(state.showAdvanced || false);
return true; // 表示成功恢复了状态
}
} catch (error) {
console.warn('恢复状态失败:', error);
}
return false; // 表示没有恢复状态
}, []);
// 组件加载时恢复状态
useEffect(() => {
const restored = restoreState();
if (restored) {
setShowRestoredTip(true);
// 3秒后自动隐藏提示
setTimeout(() => {
setShowRestoredTip(false);
}, 3000);
}
}, [restoreState]);
// 状态变化时自动保存(防抖)
useEffect(() => {
const timeoutId = setTimeout(() => {
if (recommendations.length > 0) {
saveState();
}
}, 1000); // 1秒防抖
return () => clearTimeout(timeoutId);
}, [recommendations, saveState]);
// 监听对比选择当选择了2个方案时自动跳转到对比页面
useEffect(() => {
if (selectedForComparison.length === 2) {
// 将选中的方案保存到localStorage供对比页面使用
try {
localStorage.setItem('outfit-comparison-data', JSON.stringify({
leftFavorite: {
id: selectedForComparison[0].id,
recommendation_data: selectedForComparison[0],
custom_name: selectedForComparison[0].title,
created_at: new Date().toISOString()
},
rightFavorite: {
id: selectedForComparison[1].id,
recommendation_data: selectedForComparison[1],
custom_name: selectedForComparison[1].title,
created_at: new Date().toISOString()
}
}));
// 短暂延迟后跳转,让用户看到选择反馈
setTimeout(() => {
window.location.href = '/tools/outfit-comparison';
}, 500);
} catch (error) {
console.error('保存对比数据失败:', error);
}
}
}, [selectedForComparison]);
// 生成穿搭方案
const handleGenerate = useCallback(async () => {
if (!query.trim()) {
@@ -83,6 +206,11 @@ const OutfitRecommendationTool: React.FC = () => {
setGroupingStrategy(null);
setRecommendations(response.recommendations || []);
}
// 生成成功后立即保存状态
setTimeout(() => {
saveState();
}, 100);
} catch (err) {
console.error('穿搭方案生成失败:', err);
setError(err instanceof Error ? err.message : '穿搭方案生成失败');
@@ -167,6 +295,14 @@ const OutfitRecommendationTool: React.FC = () => {
setGroupingStrategy(null);
setRecommendations([]);
setError(null);
setShowRestoredTip(false);
// 清除保存的状态
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.warn('清除保存状态失败:', error);
}
}, []);
// 导出结果
@@ -203,6 +339,34 @@ const OutfitRecommendationTool: React.FC = () => {
URL.revokeObjectURL(url);
}, [recommendations, query]);
// 打开收藏管理
const handleOpenFavoriteManagement = useCallback(() => {
// 使用路由跳转到收藏管理页面
window.location.href = '/tools/outfit-favorites';
}, []);
// 打开方案对比
const handleOpenComparison = useCallback(() => {
// 使用路由跳转到方案对比页面
window.location.href = '/tools/outfit-comparison';
}, []);
// 添加到对比
const handleAddToComparison = useCallback((recommendation: OutfitRecommendation) => {
setSelectedForComparison(prev => {
if (prev.find(r => r.id === recommendation.id)) {
// 如果已存在,则移除
return prev.filter(r => r.id !== recommendation.id);
} else if (prev.length < 2) {
// 如果少于2个则添加
return [...prev, recommendation];
} else {
// 如果已有2个替换第一个
return [prev[1], recommendation];
}
});
}, []);
// 复制结果
const handleCopy = useCallback(() => {
if (recommendations.length === 0) {
@@ -255,13 +419,54 @@ const OutfitRecommendationTool: React.FC = () => {
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">AI穿搭方案推荐</h1>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-gray-900">AI穿搭方案推荐</h1>
{showRestoredTip && (
<div className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full animate-fade-in">
</div>
)}
</div>
<p className="text-sm text-gray-600">TikTok视觉趋势的智能穿搭建议</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* 收藏管理入口 */}
<button
onClick={handleOpenFavoriteManagement}
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors duration-200"
title="收藏管理"
>
<Heart className="w-4 h-4" />
<span className="text-sm font-medium"></span>
</button>
{/* 方案对比入口 */}
<button
onClick={handleOpenComparison}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors duration-200 relative ${
selectedForComparison.length > 0
? 'text-blue-600 bg-blue-50 hover:bg-blue-100'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
}`}
title={`方案对比 ${selectedForComparison.length > 0 ? `(已选择${selectedForComparison.length}个)` : ''}`}
>
<ArrowLeftRight className="w-4 h-4" />
<span className="text-sm font-medium"></span>
{selectedForComparison.length > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-white text-xs rounded-full flex items-center justify-center">
{selectedForComparison.length}
</span>
)}
</button>
{/* 分隔线 */}
{recommendations.length > 0 && (
<div className="h-6 w-px bg-gray-300 mx-1"></div>
)}
{recommendations.length > 0 && (
<>
<button
@@ -433,10 +638,39 @@ const OutfitRecommendationTool: React.FC = () => {
<li> </li>
<li> TikTok优化建议</li>
<li> </li>
<li> "对比"</li>
</ul>
</div>
</div>
</div>
{/* 对比状态面板 */}
{selectedForComparison.length > 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ArrowLeftRight className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-green-800 flex-1">
<p className="font-medium mb-1"></p>
<p className="text-xs mb-2">
{selectedForComparison.length}/2
{selectedForComparison.length === 2 && ',即将自动跳转到对比页面...'}
</p>
<div className="space-y-1">
{selectedForComparison.map((rec, index) => (
<div key={rec.id} className="text-xs bg-white bg-opacity-50 rounded px-2 py-1">
{index + 1}. {rec.title}
</div>
))}
</div>
{selectedForComparison.length === 1 && (
<p className="text-xs mt-2 opacity-75">
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -452,6 +686,8 @@ const OutfitRecommendationTool: React.FC = () => {
onRegenerate={handleRegenerate}
onMaterialSearch={handleMaterialSearch}
onLoadMoreForGroup={handleLoadMoreForGroup}
onAddToComparison={handleAddToComparison}
selectedForComparison={selectedForComparison}
className="min-h-[600px]"
/>
</div>