- 修复SimilaritySearchCard中外部链接按钮点击时触发卡片选择的事件冒泡问题 - 增强外部链接按钮事件处理,添加preventDefault和stopPropagation - 改进卡片点击事件处理,检查点击目标避免按钮触发卡片选择 - 修复outfit_search_commands.rs中的lifetime错误,重构描述字段解析逻辑 - 移除相关性阈值过滤逻辑,返回所有搜索结果 - 优化相似度检索工具UI布局和详情模态框功能 - 添加详细的搜索结果展示和外部链接处理功能
415 lines
17 KiB
TypeScript
415 lines
17 KiB
TypeScript
import React, { useEffect, useCallback, useState } from 'react';
|
||
import {
|
||
Search,
|
||
Sparkles,
|
||
RotateCcw,
|
||
TrendingUp,
|
||
Zap,
|
||
ArrowLeft,
|
||
Loader2,
|
||
X,
|
||
ExternalLink,
|
||
Copy,
|
||
Download
|
||
} from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
useSimilaritySearchStore,
|
||
useSimilaritySearchSelectors,
|
||
useSimilaritySearchActions
|
||
} from '../../store/similaritySearchStore';
|
||
import { SimilaritySearchPanel } from '../../components/similarity/SimilaritySearchPanel';
|
||
import SimilaritySearchResults from '../../components/similarity/SimilaritySearchResults';
|
||
import { SimilaritySearchRequest } from '../../types/similaritySearch';
|
||
import { CustomSelect } from '../../components/CustomSelect';
|
||
|
||
/**
|
||
* 相似度检索工具页面
|
||
* 遵循 Tauri 开发规范和 UI/UX 设计标准
|
||
*/
|
||
const SimilaritySearchTool: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
|
||
// 本地状态
|
||
const [selectedResult, setSelectedResult] = useState<any>(null);
|
||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||
|
||
// 状态管理
|
||
const {
|
||
query,
|
||
selectedThreshold,
|
||
setQuery,
|
||
setThreshold,
|
||
executeSearch,
|
||
searchWithPagination,
|
||
changePageSize,
|
||
loadConfig,
|
||
clearError,
|
||
setShowSuggestions,
|
||
} = useSimilaritySearchStore();
|
||
|
||
// 选择器
|
||
const { useSearchState, useConfigState, useSuggestionsState } = useSimilaritySearchSelectors();
|
||
const { quickSearch, resetAll, selectSuggestionAndSearch } = useSimilaritySearchActions();
|
||
|
||
// 获取状态
|
||
const searchState = useSearchState();
|
||
const configState = useConfigState();
|
||
const suggestionsState = useSuggestionsState();
|
||
|
||
// 页面初始化
|
||
useEffect(() => {
|
||
loadConfig();
|
||
clearError();
|
||
}, [loadConfig, clearError]);
|
||
|
||
// 恢复持久化的查询内容
|
||
useEffect(() => {
|
||
const persistedData = localStorage.getItem('similarity-search-storage');
|
||
if (persistedData) {
|
||
try {
|
||
const parsed = JSON.parse(persistedData);
|
||
const persistedQuery = parsed.state?.query;
|
||
|
||
if (persistedQuery && persistedQuery.trim() && persistedQuery !== query) {
|
||
console.log('恢复持久化的查询内容:', persistedQuery);
|
||
setQuery(persistedQuery);
|
||
}
|
||
} catch (e) {
|
||
console.warn('无法解析持久化的查询内容:', e);
|
||
}
|
||
}
|
||
}, []); // 只在组件挂载时执行一次
|
||
|
||
// 处理搜索
|
||
const handleSearch = useCallback((request: SimilaritySearchRequest) => {
|
||
executeSearch(request);
|
||
}, [executeSearch]);
|
||
|
||
// 处理建议选择
|
||
const handleSuggestionSelect = useCallback((suggestion: string) => {
|
||
selectSuggestionAndSearch(suggestion);
|
||
}, [selectSuggestionAndSearch]);
|
||
|
||
// 处理快速搜索标签点击
|
||
const handleQuickTagClick = useCallback((tag: string) => {
|
||
quickSearch(tag);
|
||
}, [quickSearch]);
|
||
|
||
// 处理重置
|
||
const handleReset = useCallback(() => {
|
||
// 清除持久化数据
|
||
localStorage.removeItem('similarity-search-storage');
|
||
// 重置所有状态
|
||
resetAll();
|
||
}, [resetAll]);
|
||
|
||
// 处理分页
|
||
const handlePageChange = useCallback((page: number) => {
|
||
searchWithPagination(page);
|
||
}, [searchWithPagination]);
|
||
|
||
// 处理每页显示数量变化
|
||
const handlePageSizeChange = useCallback((pageSize: number) => {
|
||
changePageSize(pageSize);
|
||
}, [changePageSize]);
|
||
|
||
// 处理结果选择
|
||
const handleResultSelect = useCallback((result: any) => {
|
||
console.log('Selected result:', result);
|
||
setSelectedResult(result);
|
||
setShowDetailModal(true);
|
||
}, []);
|
||
|
||
// 处理外部链接打开
|
||
const handleOpenExternal = useCallback((url: string) => {
|
||
// 直接使用 window.open 打开外部链接
|
||
window.open(url, '_blank', 'noopener,noreferrer');
|
||
}, []);
|
||
|
||
// 关闭详细信息模态框
|
||
const handleCloseDetailModal = useCallback(() => {
|
||
setShowDetailModal(false);
|
||
setSelectedResult(null);
|
||
}, []);
|
||
|
||
// 监听相关性阈值变化,立即重新搜索
|
||
useEffect(() => {
|
||
const request: SimilaritySearchRequest = {
|
||
query: query.trim(),
|
||
relevance_threshold: selectedThreshold,
|
||
page_size: configState.config?.max_results_per_page || 12,
|
||
page_offset: (searchState.currentPage - 1) * (configState.config?.max_results_per_page || 12),
|
||
};
|
||
executeSearch(request);
|
||
}, [selectedThreshold]); // 只监听阈值变化
|
||
|
||
// 返回工具列表
|
||
const handleBackToTools = useCallback(() => {
|
||
navigate('/tools');
|
||
}, [navigate]);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
|
||
{/* 页面头部 */}
|
||
<div className="bg-white border-b border-gray-100 shadow-sm">
|
||
<div className="mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
onClick={handleBackToTools}
|
||
className="flex items-center gap-2 py-2 text-gray-600 hover:text-primary-600 hover:bg-gray-50 rounded-lg transition-all duration-200"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
<span className="text-sm font-medium">返回工具</span>
|
||
</button>
|
||
|
||
<div className="h-6 w-px bg-gray-200"></div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="icon-container primary w-10 h-10">
|
||
<Search className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-high-emphasis">相似度检索工具</h1>
|
||
<p className="text-sm text-medium-emphasis">基于AI的智能相似度搜索</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={handleReset}
|
||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all duration-200"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
<span className="text-sm font-medium">重置</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 全局搜索状态指示器 */}
|
||
{searchState.isSearching && (
|
||
<div className="bg-primary-50 border-b border-primary-200">
|
||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||
<div className="flex items-center justify-center gap-3">
|
||
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
|
||
<span className="text-sm font-medium text-primary-900">正在搜索相似内容...</span>
|
||
<span className="text-xs text-primary-700">请稍候,AI正在为您分析匹配结果</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主要内容 */}
|
||
<div className="mx-auto py-6 lg:py-8">
|
||
{/* 搜索面板和结果 */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6 lg:gap-8">
|
||
{/* 搜索面板 */}
|
||
<div className="space-y-6 order-2 lg:order-1">
|
||
<SimilaritySearchPanel
|
||
query={query}
|
||
selectedThreshold={selectedThreshold}
|
||
suggestions={suggestionsState.suggestions}
|
||
showSuggestions={suggestionsState.showSuggestions}
|
||
isSearching={searchState.isSearching}
|
||
onQueryChange={setQuery}
|
||
onSearch={handleSearch}
|
||
onSuggestionSelect={handleSuggestionSelect}
|
||
onSuggestionsToggle={setShowSuggestions}
|
||
/>
|
||
</div>
|
||
|
||
{/* 搜索结果 */}
|
||
<div className="min-h-[400px] sm:min-h-[600px] order-1 lg:order-2">
|
||
{searchState.results.length > 0 ? (
|
||
<SimilaritySearchResults
|
||
results={searchState.results}
|
||
totalResults={searchState.totalResults}
|
||
currentPage={searchState.currentPage}
|
||
maxResultsPerPage={configState.config?.max_results_per_page || 12}
|
||
isLoading={searchState.isSearching}
|
||
onPageChange={handlePageChange}
|
||
onPageSizeChange={handlePageSizeChange}
|
||
onResultSelect={handleResultSelect}
|
||
onExternalLinkClick={handleOpenExternal}
|
||
/>
|
||
) : searchState.error ? (
|
||
<div className="card h-full flex flex-col items-center justify-center text-center p-6 sm:p-12 animate-fade-in bg-gradient-to-br from-red-50 to-pink-50 border border-red-200">
|
||
<div className="icon-container red w-16 h-16 mb-6 shadow-md">
|
||
<Search className="w-8 h-8" />
|
||
</div>
|
||
<h3 className="text-2xl font-bold text-red-900 mb-3">❌ 搜索出错了</h3>
|
||
<p className="text-red-700 mb-6 max-w-md leading-relaxed">{searchState.error}</p>
|
||
<button
|
||
onClick={handleReset}
|
||
className="btn btn-primary hover:shadow-md transition-all duration-200"
|
||
>
|
||
🔄 重新开始
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="card h-full flex flex-col items-center justify-center text-center p-6 sm:p-12 animate-fade-in bg-gradient-to-br from-purple-50 to-indigo-50 border border-purple-200">
|
||
<div className="icon-container purple w-16 h-16 mb-6 shadow-md">
|
||
<TrendingUp className="w-8 h-8" />
|
||
</div>
|
||
<h3 className="text-2xl font-bold text-purple-900 mb-3">✨ 准备就绪</h3>
|
||
<div className="space-y-2 text-purple-700 max-w-md">
|
||
<p className="flex items-center justify-center gap-2">
|
||
<Search className="w-4 h-4" />
|
||
输入关键词开始搜索
|
||
</p>
|
||
<p className="flex items-center justify-center gap-2">
|
||
<Zap className="w-4 h-4" />
|
||
或选择快速标签快速开始
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 详细信息模态框 */}
|
||
{showDetailModal && selectedResult && (
|
||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||
{/* 模态框头部 */}
|
||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||
<h3 className="text-xl font-semibold text-gray-900">搜索结果详情</h3>
|
||
<button
|
||
onClick={handleCloseDetailModal}
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5 text-gray-500" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 模态框内容 */}
|
||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||
<div className="space-y-6">
|
||
{/* 图片展示 */}
|
||
<div className="relative">
|
||
<img
|
||
src={selectedResult.image_url}
|
||
alt={selectedResult.style_description}
|
||
className="w-full h-64 object-cover rounded-lg"
|
||
/>
|
||
<div className="absolute top-3 right-3">
|
||
<div className="px-3 py-1 bg-white/90 backdrop-blur-sm rounded-full text-sm font-medium">
|
||
相关性: {(selectedResult.relevance_score * 100).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 基本信息 */}
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h4 className="text-sm font-medium text-gray-700 mb-2">风格描述</h4>
|
||
<p className="text-gray-900 bg-gray-50 p-3 rounded-lg">
|
||
{selectedResult.style_description || '暂无描述'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 环境标签 */}
|
||
{selectedResult.environment_tags && selectedResult.environment_tags.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-medium text-gray-700 mb-2">环境标签</h4>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedResult.environment_tags.map((tag: string, index: number) => (
|
||
<span
|
||
key={index}
|
||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 产品信息 */}
|
||
{selectedResult.products && selectedResult.products.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-medium text-gray-700 mb-2">产品信息</h4>
|
||
<div className="space-y-3">
|
||
{selectedResult.products.map((product: any, index: number) => (
|
||
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||
{/* 产品类别 */}
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-sm font-semibold text-gray-900">{product.category}</span>
|
||
{product.color_pattern?.rgb_hex && (
|
||
<div
|
||
className="w-4 h-4 rounded-full border border-gray-300"
|
||
style={{ backgroundColor: product.color_pattern.rgb_hex }}
|
||
title={`颜色: ${product.color_pattern.rgb_hex}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 产品描述 */}
|
||
{product.description && product.description !== "时尚单品" && (
|
||
<div className="text-sm text-gray-700 mb-2">{product.description}</div>
|
||
)}
|
||
|
||
{/* 设计风格 */}
|
||
{product.design_styles && product.design_styles.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{product.design_styles.map((style: string, styleIndex: number) => (
|
||
<span
|
||
key={styleIndex}
|
||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||
>
|
||
{style}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 颜色信息详细 */}
|
||
{product.color_pattern && (
|
||
<div className="mt-2 text-xs text-gray-500">
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<span>色相: {(product.color_pattern.hue * 360).toFixed(0)}°</span>
|
||
<span>饱和度: {(product.color_pattern.saturation * 100).toFixed(0)}%</span>
|
||
<span>明度: {(product.color_pattern.value * 100).toFixed(0)}%</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||
<button
|
||
onClick={() => handleOpenExternal(selectedResult.image_url)}
|
||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||
>
|
||
<ExternalLink className="w-4 h-4" />
|
||
在新窗口中打开
|
||
</button>
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(selectedResult.image_url)}
|
||
className="flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||
>
|
||
<Copy className="w-4 h-4" />
|
||
复制链接
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SimilaritySearchTool;
|