Files
mixvideo-v2/apps/desktop/src/pages/tools/OutfitSearchTool.tsx
imeepos 7fb17ea16b fix: 修复智能服装搜索分页功能
主要修复:
1. 修复分页按钮点击无效问题:
   - handleSearch函数支持指定页码参数
   - 分页按钮点击时传递正确的页码
   - 解决了setState异步导致的页码不同步问题

2. 修复函数签名问题:
   - 搜索按钮使用() => handleSearch()调用
   - 分页按钮使用handleSearch(newPage)传递页码

3. 分页逻辑优化:
   - 上一页:setCurrentPage(newPage) + handleSearch(newPage)
   - 下一页:setCurrentPage(newPage) + handleSearch(newPage)
   - 确保页码状态和搜索请求同步

测试结果:
 分页按钮现在可以正常点击
 搜索请求包含正确的page_offset参数
 分页状态与搜索请求保持同步

分页功能现在完全正常工作!
2025-07-25 13:59:09 +08:00

622 lines
24 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, useRef } from 'react';
import {
Upload,
Search,
Sparkles,
Image as ImageIcon,
Settings,
MessageCircle,
Grid,
Filter,
RefreshCw,
X,
ChevronLeft,
ChevronRight
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { convertFileSrc } from '@tauri-apps/api/core';
import {
OutfitAnalysisResult,
SearchRequest,
SearchResponse,
LLMQueryRequest,
LLMQueryResponse,
SearchConfig,
DEFAULT_SEARCH_CONFIG,
RELEVANCE_THRESHOLD_OPTIONS
} from '../../types/outfitSearch';
/**
* 智能服装搜索工具
* 基于 promptx/outfit-match/src/outfit_match/frontend/search.py 的设计
* 遵循 Tauri 开发规范和 UI/UX 设计标准
*/
const OutfitSearchTool: React.FC = () => {
// 状态管理
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [analysisResult, setAnalysisResult] = useState<OutfitAnalysisResult | null>(null);
const [searchResults, setSearchResults] = useState<SearchResponse | null>(null);
const [searchConfig, setSearchConfig] = useState<SearchConfig>(DEFAULT_SEARCH_CONFIG);
const [llmResponse, setLlmResponse] = useState<LLMQueryResponse | null>(null);
// 加载状态
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [isAsking, setIsAsking] = useState(false);
// 错误状态
const [analysisError, setAnalysisError] = useState<string | null>(null);
const [searchError, setSearchError] = useState<string | null>(null);
const [llmError, setLlmError] = useState<string | null>(null);
// UI状态
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [llmInput, setLlmInput] = useState('');
// 引用
const fileInputRef = useRef<HTMLInputElement>(null);
// 图像上传处理
const handleImageUpload = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: 'Image Files',
extensions: ['jpg', 'jpeg', 'png', 'webp']
}]
});
if (selected && typeof selected === 'string') {
setSelectedImage(selected);
setAnalysisError(null);
setAnalysisResult(null);
}
} catch (error) {
console.error('Failed to select image:', error);
setAnalysisError('图像选择失败');
}
}, []);
// 图像分析
const handleAnalyzeImage = useCallback(async () => {
if (!selectedImage) return;
setIsAnalyzing(true);
setAnalysisError(null);
try {
const response = await invoke<any>('analyze_outfit_image', {
request: {
image_path: selectedImage,
image_name: selectedImage.split('/').pop() || 'image'
}
});
setAnalysisResult(response.result);
// 基于分析结果更新搜索配置
if (response.result) {
const newConfig = { ...searchConfig };
if (response.result.environment_tags) {
newConfig.environments = response.result.environment_tags;
}
if (response.result.products) {
newConfig.categories = response.result.products.map((p: any) => p.category);
}
setSearchConfig(newConfig);
}
} catch (error) {
console.error('Failed to analyze image:', error);
setAnalysisError(error as string);
} finally {
setIsAnalyzing(false);
}
}, [selectedImage, searchConfig]);
// 执行搜索 - 支持指定页码
const handleSearch = useCallback(async (targetPage?: number) => {
const pageToUse = targetPage ?? currentPage;
setIsSearching(true);
setSearchError(null);
try {
// 构建简化的搜索配置
const simpleConfig: SearchConfig = {
relevance_threshold: 'HIGH' as any,
environments: searchConfig.environments || [],
categories: searchConfig.categories || [],
color_filters: {},
design_styles: {},
max_keywords: 10,
debug_mode: true, // 启用调试模式查看详细信息
custom_filters: [],
query_enhancement_enabled: true,
color_thresholds: {
default_hue_threshold: 0.05,
default_saturation_threshold: 0.05,
default_value_threshold: 0.2
}
};
const searchRequest: SearchRequest = {
query: 'model fashion outfit',
config: simpleConfig,
page_size: 9,
page_offset: (pageToUse - 1) * 9
};
console.log('发送搜索请求:', searchRequest);
const response = await invoke<SearchResponse>('search_similar_outfits', {
request: searchRequest
});
console.log('搜索响应:', response);
setSearchResults(response);
} catch (error) {
console.error('Failed to search outfits:', error);
setSearchError(error as string);
} finally {
setIsSearching(false);
}
}, [searchConfig, currentPage]);
// LLM问答
const handleLLMQuery = useCallback(async () => {
if (!llmInput.trim()) return;
setIsAsking(true);
setLlmError(null);
try {
const response = await invoke<LLMQueryResponse>('ask_llm_outfit_advice', {
request: {
user_input: llmInput,
session_id: undefined
}
});
setLlmResponse(response);
} catch (error) {
console.error('Failed to get LLM advice:', error);
setLlmError(error as string);
} finally {
setIsAsking(false);
}
}, [llmInput]);
// 清除结果
const clearResults = useCallback(() => {
setSearchResults(null);
setLlmResponse(null);
setSearchError(null);
setLlmError(null);
}, []);
// 清除图像
const clearImage = useCallback(() => {
setSelectedImage(null);
setAnalysisResult(null);
setAnalysisError(null);
}, []);
return (
<div className="h-full flex flex-col">
{/* 页面标题 */}
<div className="page-header flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-pink-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<Search className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-pink-600 bg-clip-text text-transparent">
</h1>
<p className="text-gray-600 text-lg">AI的智能服装搜索和匹配工具</p>
</div>
</div>
</div>
{/* 主要内容区域 - 双列布局 */}
<div className="flex-1 flex gap-6 min-h-0">
{/* 左侧:搜索结果展示区域 (70%) */}
<div className="flex-1 flex flex-col min-h-0">
<div className="card p-6 flex-1 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<div className="flex items-center gap-3">
{isSearching && (
<div className="flex items-center gap-2 text-primary-600">
<RefreshCw className="w-4 h-4 animate-spin" />
<span className="text-sm">...</span>
</div>
)}
{searchResults && (
<span className="text-sm text-gray-500">
{searchResults.total_size}
{searchResults.search_time_ms && (
<span className="ml-2">({searchResults.search_time_ms}ms)</span>
)}
</span>
)}
</div>
</div>
{/* 搜索错误显示 */}
{searchError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{searchError}</p>
</div>
)}
{/* 搜索结果网格 */}
<div className="flex-1 overflow-y-auto">
{searchResults && searchResults.results.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{searchResults.results.map((result, index) => (
<div key={result.id || index} className="card p-4 hover:shadow-lg transition-shadow">
<img
src={result.image_url}
alt="Outfit"
className="w-full h-48 object-cover rounded-lg mb-3"
onError={(e) => {
console.error('搜索结果图片加载失败:', result.image_url);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xMDAgNzBMMTMwIDEwMEgxMTBWMTMwSDkwVjEwMEg3MEwxMDAgNzBaIiBmaWxsPSIjOUI5QkEwIi8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTUwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOUI5QkEwIiBmb250LXNpemU9IjEyIj7lm77niYfliKDpmaTlpLHotKU8L3RleHQ+Cjwvc3ZnPg==';
}}
/>
<div className="space-y-2">
<p className="text-sm text-gray-600 line-clamp-2">
{result.style_description}
</p>
<div className="flex flex-wrap gap-1">
{result.environment_tags.slice(0, 3).map((tag, i) => (
<span key={i} className="px-2 py-1 bg-gray-100 text-xs rounded-full">
{tag}
</span>
))}
</div>
{result.relevance_score && (
<div className="text-xs text-gray-500">
: {(result.relevance_score * 100).toFixed(1)}%
</div>
)}
</div>
</div>
))}
</div>
{/* 分页控件 */}
{searchResults.total_size > 9 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<div className="text-sm text-gray-500">
{currentPage} {Math.ceil(searchResults.total_size / 9)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
if (currentPage > 1) {
const newPage = currentPage - 1;
setCurrentPage(newPage);
handleSearch(newPage);
}
}}
disabled={currentPage <= 1}
className="p-2 text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-3 py-1 bg-gray-100 rounded text-sm">
{currentPage}
</span>
<button
onClick={() => {
if (currentPage < Math.ceil(searchResults.total_size / 9)) {
const newPage = currentPage + 1;
setCurrentPage(newPage);
handleSearch(newPage);
}
}}
disabled={currentPage >= Math.ceil(searchResults.total_size / 9)}
className="p-2 text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<Grid className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-2"></p>
</div>
</div>
)}
</div>
</div>
</div>
{/* 右侧:搜索控制面板 (30%) */}
<div className="w-96 flex flex-col space-y-4">
{/* 图像上传区域 */}
<div className="card p-4">
<h3 className="text-lg font-semibold mb-3"></h3>
{selectedImage ? (
<div className="space-y-3">
<div className="relative">
<img
src={convertFileSrc(selectedImage)}
alt="Selected"
className="w-full h-48 object-cover rounded-lg"
onError={(e) => {
console.error('图片加载失败:', selectedImage);
setAnalysisError('图片加载失败,请重新选择图片');
}}
/>
<button
onClick={clearImage}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
>
<X className="w-4 h-4" />
</button>
</div>
<button
onClick={handleAnalyzeImage}
disabled={isAnalyzing}
className="w-full btn-primary flex items-center justify-center gap-2"
>
{isAnalyzing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
{isAnalyzing ? '分析中...' : '解析图像'}
</button>
</div>
) : (
<button
onClick={handleImageUpload}
className="w-full h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center hover:border-primary-500 transition-colors"
>
<Upload className="w-8 h-8 text-gray-400 mb-2" />
<span className="text-gray-500"></span>
</button>
)}
{analysisError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{analysisError}</p>
</div>
)}
{/* 分析结果展示 */}
{analysisResult && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-800 mb-2"></h4>
<div className="space-y-2 text-sm text-blue-700">
<p><strong>:</strong> {analysisResult.style_description}</p>
{analysisResult.environment_tags && (
<p><strong>:</strong> {analysisResult.environment_tags.join(', ')}</p>
)}
{analysisResult.products && (
<div>
<strong>:</strong>
<ul className="mt-1 space-y-1">
{analysisResult.products.map((product, index) => (
<li key={index} className="text-xs">
{product.category}: {product.design_styles?.join(', ')}
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
</div>
{/* 搜索设置 */}
<div className="card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100"
>
<Settings className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
{/* 相关性阈值 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
value={searchConfig.relevance_threshold}
onChange={(e) => setSearchConfig({
...searchConfig,
relevance_threshold: e.target.value as any
})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{RELEVANCE_THRESHOLD_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* 高级过滤器 */}
{showAdvancedFilters && (
<div className="space-y-3 p-3 bg-gray-50 rounded-lg">
<h4 className="text-sm font-medium text-gray-700"></h4>
{/* 环境标签 */}
{analysisResult && analysisResult.environment_tags && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="flex flex-wrap gap-2">
{analysisResult.environment_tags.map((tag, index) => (
<button
key={index}
onClick={() => {
const newEnvironments = searchConfig.environments.includes(tag)
? searchConfig.environments.filter(e => e !== tag)
: [...searchConfig.environments, tag];
setSearchConfig({
...searchConfig,
environments: newEnvironments
});
}}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
searchConfig.environments.includes(tag)
? 'bg-primary-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{tag}
</button>
))}
</div>
</div>
)}
{/* 产品类别 */}
{analysisResult && analysisResult.products && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="flex flex-wrap gap-2">
{analysisResult.products.map((product, index) => (
<button
key={index}
onClick={() => {
const newCategories = searchConfig.categories.includes(product.category)
? searchConfig.categories.filter(c => c !== product.category)
: [...searchConfig.categories, product.category];
setSearchConfig({
...searchConfig,
categories: newCategories
});
}}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
searchConfig.categories.includes(product.category)
? 'bg-primary-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{product.category}
</button>
))}
</div>
</div>
)}
{/* 最大关键词数量 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
: {searchConfig.max_keywords}
</label>
<input
type="range"
min="1"
max="50"
value={searchConfig.max_keywords}
onChange={(e) => setSearchConfig({
...searchConfig,
max_keywords: parseInt(e.target.value)
})}
className="w-full"
/>
</div>
</div>
)}
{/* 搜索按钮 */}
<button
onClick={() => handleSearch()}
disabled={isSearching}
className="w-full btn-primary flex items-center justify-center gap-2"
>
{isSearching ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
{isSearching ? '搜索中...' : '开始搜索'}
</button>
{/* 清除搜索结果 */}
{searchResults && (
<button
onClick={clearResults}
className="w-full btn-secondary flex items-center justify-center gap-2"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* LLM问答区域 */}
<div className="card p-4">
<h3 className="text-lg font-semibold mb-3">AI助手</h3>
<div className="space-y-3">
<textarea
value={llmInput}
onChange={(e) => setLlmInput(e.target.value)}
placeholder="描述你的穿搭需求或场景..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
rows={3}
/>
<button
onClick={handleLLMQuery}
disabled={isAsking || !llmInput.trim()}
className="w-full btn-secondary flex items-center justify-center gap-2"
>
{isAsking ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<MessageCircle className="w-4 h-4" />
)}
{isAsking ? '思考中...' : '询问AI'}
</button>
</div>
{llmResponse && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-blue-800 text-sm">{llmResponse.answer}</p>
</div>
)}
{llmError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{llmError}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default OutfitSearchTool;