Files
mixvideo-v2/apps/desktop/src/pages/tools/OutfitRecommendationTool.tsx
imeepos 8a5988b3de 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.
2025-07-28 15:53:20 +08:00

720 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback, useEffect } from 'react';
import {
Sparkles,
Wand2,
Shirt,
Download,
Copy,
RefreshCw,
Settings,
Info,
Heart,
ArrowLeftRight
} from 'lucide-react';
import OutfitRecommendationService from '../../services/outfitRecommendationService';
import {
OutfitRecommendation,
OutfitRecommendationGroup,
GroupingStrategy,
STYLE_OPTIONS,
OCCASION_OPTIONS,
SEASON_OPTIONS
} from '../../types/outfitRecommendation';
import OutfitRecommendationList from '../../components/outfit/OutfitRecommendationList';
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 设计标准
*/
const OutfitRecommendationTool: React.FC = () => {
// 状态管理
const [query, setQuery] = useState('');
const [targetStyle, setTargetStyle] = useState<string>('');
const [occasions, setOccasions] = useState<string[]>([]);
const [season, setSeason] = useState<string>('');
const [colorPreferences, setColorPreferences] = useState<string[]>([]);
const [count, setCount] = useState(3);
// 分组相关状态
const [groups, setGroups] = useState<OutfitRecommendationGroup[]>([]);
const [groupingStrategy, setGroupingStrategy] = useState<GroupingStrategy | null>(null);
const [recommendations, setRecommendations] = useState<OutfitRecommendation[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
// 素材检索状态
const [showMaterialSearch, setShowMaterialSearch] = useState(false);
const [selectedRecommendation, setSelectedRecommendation] = useState<OutfitRecommendation | null>(null);
// 素材详情状态
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()) {
setError('请输入穿搭关键词或描述');
return;
}
setIsGenerating(true);
setError(null);
try {
const response = await OutfitRecommendationService.generateRecommendations({
query: query.trim(),
target_style: targetStyle || undefined,
occasions: occasions.length > 0 ? occasions : undefined,
season: season || undefined,
color_preferences: colorPreferences.length > 0 ? colorPreferences : undefined,
count,
});
// 更新分组和方案数据
if (response.groups && response.groups.length > 0) {
setGroups(response.groups);
setGroupingStrategy(response.grouping_strategy);
// 为向后兼容也设置recommendations
setRecommendations(response.recommendations || []);
} else {
// 向后兼容模式
setGroups([]);
setGroupingStrategy(null);
setRecommendations(response.recommendations || []);
}
// 生成成功后立即保存状态
setTimeout(() => {
saveState();
}, 100);
} catch (err) {
console.error('穿搭方案生成失败:', err);
setError(err instanceof Error ? err.message : '穿搭方案生成失败');
} finally {
setIsGenerating(false);
}
}, [query, targetStyle, occasions, season, colorPreferences, count]);
// 重新生成
const handleRegenerate = useCallback(() => {
handleGenerate();
}, [handleGenerate]);
// 获取更多同类方案
const handleLoadMoreForGroup = useCallback(async (groupId: string, styleKeywords: string[]) => {
try {
console.log(`🎨 获取分组 ${groupId} 的更多方案,关键词:`, styleKeywords);
// 构建增强的查询
const enhancedQuery = `${query} ${styleKeywords.join(' ')}`;
const response = await OutfitRecommendationService.generateRecommendations({
query: enhancedQuery,
target_style: targetStyle || undefined,
occasions: occasions.length > 0 ? occasions : undefined,
season: season || undefined,
color_preferences: colorPreferences.length > 0 ? colorPreferences : undefined,
count: 3, // 每次获取3个新方案
});
// 找到对应的分组并添加新方案
if (response.groups && response.groups.length > 0) {
// 如果返回了分组,合并到现有分组中
setGroups(prevGroups => {
return prevGroups.map(group => {
if (group.group_id === groupId) {
// 找到匹配的新分组并合并方案
const newGroup = response.groups.find(g =>
g.style_keywords.some(keyword =>
styleKeywords.includes(keyword)
)
);
if (newGroup) {
return {
...group,
children: [...group.children, ...newGroup.children]
};
}
}
return group;
});
});
} else if (response.recommendations) {
// 向后兼容:直接添加到对应分组
setGroups(prevGroups => {
return prevGroups.map(group => {
if (group.group_id === groupId) {
return {
...group,
children: [...group.children, ...(response.recommendations || [])]
};
}
return group;
});
});
}
} catch (err) {
console.error('获取更多方案失败:', err);
setError(err instanceof Error ? err.message : '获取更多方案失败');
}
}, [query, targetStyle, occasions, season, colorPreferences]);
// 清空表单
const handleClear = useCallback(() => {
setQuery('');
setTargetStyle('');
setOccasions([]);
setSeason('');
setColorPreferences([]);
setCount(3);
setGroups([]);
setGroupingStrategy(null);
setRecommendations([]);
setError(null);
setShowRestoredTip(false);
// 清除保存的状态
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.warn('清除保存状态失败:', error);
}
}, []);
// 导出结果
const handleExport = useCallback(() => {
if (recommendations.length === 0) {
return;
}
const exportData = {
query,
generated_at: new Date().toISOString(),
recommendations: recommendations.map(rec => ({
title: rec.title,
description: rec.description,
overall_style: rec.overall_style,
style_tags: rec.style_tags,
occasions: rec.occasions,
seasons: rec.seasons,
color_theme: rec.color_theme,
items: rec.items,
tiktok_tips: rec.tiktok_tips,
styling_tips: rec.styling_tips,
}))
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `outfit-recommendations-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
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) {
return;
}
const text = recommendations.map(rec =>
`${rec.title}\n${rec.description}\n风格: ${rec.style_tags.join(', ')}\n\n`
).join('');
navigator.clipboard.writeText(text);
}, [recommendations]);
// 处理素材检索
const handleMaterialSearch = useCallback((recommendation: OutfitRecommendation) => {
setSelectedRecommendation(recommendation);
setShowMaterialSearch(true);
}, []);
// 关闭素材检索面板
const handleCloseMaterialSearch = useCallback(() => {
setShowMaterialSearch(false);
setSelectedRecommendation(null);
}, []);
// 处理素材选择
const handleMaterialSelect = useCallback((material: any) => {
console.log('选择素材:', material);
setSelectedMaterial(material);
setShowMaterialDetail(true);
}, []);
// 关闭素材详情
const handleCloseMaterialDetail = useCallback(() => {
setShowMaterialDetail(false);
setSelectedMaterial(null);
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white">
{/* 顶部导航 */}
<div className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b border-gray-200">
<div className="mx-auto px-6 py-4">
<div className="page-header flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="h-6 w-px bg-gray-300"></div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center shadow-lg">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<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
onClick={handleCopy}
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
title="复制结果"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
title="导出结果"
>
<Download className="w-4 h-4" />
</button>
</>
)}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors duration-200 ${
showAdvanced
? 'text-primary-600 bg-primary-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
title="高级设置"
>
<Settings className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
{/* 主要内容 */}
<div className="mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 左侧输入区域 */}
<div className="lg:col-span-1">
<div className="card p-6 sticky top-24">
<div className="space-y-6">
{/* 基础输入 */}
<div>
<label className="block text-sm font-semibold text-gray-900 mb-3">
<Shirt className="w-4 h-4 inline mr-2" />
穿
</label>
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="描述您想要的穿搭风格,如:休闲约会装、正式商务装、街头潮流风等..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
rows={4}
/>
</div>
{/* 高级设置 */}
{showAdvanced && (
<div className="space-y-4 animate-fade-in">
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"></h3>
</div>
{/* 目标风格 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<CustomSelect
value={targetStyle}
onChange={setTargetStyle}
options={[
{ value: '', label: '不限制' },
...STYLE_OPTIONS.map(style => ({ value: style, label: style }))
]}
placeholder="选择风格"
/>
</div>
{/* 适合场合 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<div className="grid grid-cols-2 gap-2">
{OCCASION_OPTIONS.map(occasion => (
<label key={occasion} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={occasions.includes(occasion)}
onChange={(e) => {
if (e.target.checked) {
setOccasions([...occasions, occasion]);
} else {
setOccasions(occasions.filter(o => o !== occasion));
}
}}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
{occasion}
</label>
))}
</div>
</div>
{/* 季节 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<CustomSelect
value={season}
onChange={setSeason}
options={[
{ value: '', label: '不限制' },
...SEASON_OPTIONS.map(s => ({ value: s, label: s }))
]}
placeholder="选择季节"
/>
</div>
{/* 生成数量 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<select
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={5}>5</option>
</select>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="space-y-3">
<button
onClick={handleGenerate}
disabled={!query.trim() || isGenerating}
className="w-full btn btn-primary flex items-center justify-center gap-2"
>
{isGenerating ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Wand2 className="w-4 h-4" />
穿
</>
)}
</button>
<button
onClick={handleClear}
className="w-full btn btn-outline"
>
</button>
</div>
{/* 使用提示 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">使</p>
<ul className="space-y-1 text-xs">
<li> </li>
<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>
{/* 右侧结果区域 */}
<div className="lg:col-span-3">
<OutfitRecommendationList
groups={groups}
groupingStrategy={groupingStrategy || undefined}
recommendations={recommendations}
isLoading={isGenerating}
error={error || undefined}
onRegenerate={handleRegenerate}
onMaterialSearch={handleMaterialSearch}
onLoadMoreForGroup={handleLoadMoreForGroup}
onAddToComparison={handleAddToComparison}
selectedForComparison={selectedForComparison}
className="min-h-[600px]"
/>
</div>
</div>
</div>
{/* 素材检索面板 */}
{selectedRecommendation && (
<MaterialSearchPanel
recommendation={selectedRecommendation}
isVisible={showMaterialSearch}
onClose={handleCloseMaterialSearch}
onMaterialSelect={handleMaterialSelect}
/>
)}
{/* 素材详情模态框 */}
{selectedMaterial && (
<MaterialDetailModal
material={selectedMaterial}
isVisible={showMaterialDetail}
onClose={handleCloseMaterialDetail}
/>
)}
</div>
);
};
export default OutfitRecommendationTool;