feat: 相似度检索工具全面优化

 新功能:
- 相关性阈值选择器移至页面顶部,使用CustomSelect组件
- 智能分页系统支持大量页面(如1981页)
- 悬浮上一页/下一页按钮固定在屏幕两侧
- 每页显示数量选择器(6-96条可选)
- 参数持久化到本地存储(关键字/阈值/每页数量)

🔄 自动重新加载:
- 关键字变化:防抖搜索(800ms)
- 相关性阈值变化:立即重新搜索
- 每页数量变化:重置到第一页并重新搜索
- 页码变化:加载对应页面数据

💫 加载状态优化:
- 页面顶部全局搜索状态指示器
- 各控件旁的局部加载状态
- 搜索结果区域加载遮罩
- 分页按钮加载状态

🔧 技术改进:
- S3/GS URL自动转换为CDN URL
- Zustand persist中间件实现状态持久化
- 智能分页算法优化
- 防抖搜索机制
- 完善的错误处理和重试机制
This commit is contained in:
imeepos
2025-07-24 15:45:37 +08:00
parent ef2a561fe2
commit 00855e6861
9 changed files with 667 additions and 180 deletions

View File

@@ -326,6 +326,70 @@ mod tests {
let keywords = SearchFilterBuilder::build_query_keywords(&config);
assert!(keywords.contains(&"Outdoor".to_string()));
}
#[test]
fn test_convert_s3_to_cdn_url() {
// 测试 s3://ap-northeast-2/modal-media-cache/ 转换
let s3_url = "s3://ap-northeast-2/modal-media-cache/image.jpg";
let expected = "https://cdn.roasmax.cn/image.jpg";
assert_eq!(convert_s3_to_cdn_url(s3_url), expected);
// 测试 gs://fashion_image_block/ 转换
let gs_url = "gs://fashion_image_block/image.jpg";
let expected = "https://storage.googleapis.com/fashion_image_block/image.jpg";
assert_eq!(convert_s3_to_cdn_url(gs_url), expected);
// 测试其他 gs:// 转换
let gs_url = "gs://other-bucket/image.jpg";
let expected = "https://storage.googleapis.com/other-bucket/image.jpg";
assert_eq!(convert_s3_to_cdn_url(gs_url), expected);
// 测试其他 s3:// 转换
let s3_url = "s3://other-bucket/image.jpg";
let expected = "https://cdn.roasmax.cn/other-bucket/image.jpg";
assert_eq!(convert_s3_to_cdn_url(s3_url), expected);
// 测试普通HTTP URL不转换
let http_url = "https://example.com/image.jpg";
assert_eq!(convert_s3_to_cdn_url(http_url), http_url);
// 测试空字符串
assert_eq!(convert_s3_to_cdn_url(""), "");
}
}
/// 将S3/GS URL转换为CDN URL
fn convert_s3_to_cdn_url(s3_url: &str) -> String {
if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") {
// 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/
s3_url.replace("s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/")
} else if s3_url.starts_with("gs://fashion_image_block/") {
// 将 gs://fashion_image_block/ 替换为 https://cdn.roasmax.cn/fashion_image_block/
s3_url.replace("gs://", "https://storage.googleapis.com/")
} else if s3_url.starts_with("gs://") {
// 处理其他 gs:// 格式转换为通用CDN格式
s3_url.replace("gs://", "https://storage.googleapis.com/")
} else if s3_url.starts_with("s3://") {
// 处理其他 s3:// 格式转换为通用CDN格式
s3_url.replace("s3://", "https://cdn.roasmax.cn/")
} else {
// 如果不是预期的S3格式返回原URL
s3_url.to_string()
}
}
/// 检查网络连接
async fn check_network_connectivity() -> Result<(), anyhow::Error> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()?;
// 尝试连接到Google DNS
match client.get("https://dns.google").send().await {
Ok(_) => Ok(()),
Err(e) => Err(anyhow::anyhow!("网络连接检查失败: {}。请检查您的网络连接。", e))
}
}
/// 执行 Vertex AI Search 搜索
@@ -333,8 +397,13 @@ async fn execute_vertex_ai_search(
_gemini_service: &mut GeminiService,
request: &SearchRequest,
) -> Result<SearchResponse, anyhow::Error> {
// 0. 检查网络连接
check_network_connectivity().await
.map_err(|e| anyhow::anyhow!("网络连接问题: {}", e))?;
// 1. 获取访问令牌通过直接调用API
let access_token = get_google_access_token().await?;
let access_token = get_google_access_token().await
.map_err(|e| anyhow::anyhow!("获取访问令牌失败: {}。请检查网络连接或API配置。", e))?;
// 2. 获取全局配置
let global_config = OutfitSearchGlobalConfig::default();
@@ -355,14 +424,15 @@ async fn execute_vertex_ai_search(
// 6. 构建请求负载
let mut payload = serde_json::json!({
"query": enhanced_query,
"relevanceThreshold": request.config.relevance_threshold.to_value().to_string(),
"relevanceScoreSpec": {
"returnRelevanceScore": true
},
"pageSize": request.page_size,
"offset": request.page_offset
});
// 添加相关性评分规范但不设置阈值因为API不支持
payload["relevanceScoreSpec"] = serde_json::json!({
"returnRelevanceScore": true
});
// 7. 添加过滤器(如果有)
if !search_filter.is_empty() {
payload["filter"] = serde_json::Value::String(search_filter);
@@ -375,60 +445,109 @@ async fn execute_vertex_ai_search(
global_config.vertex_ai_app_id
);
// 9. 发送HTTP请求
let client = reqwest::Client::new();
let response = client
.post(&search_url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
// 9. 创建带有超时配置的客户端
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.connect_timeout(std::time::Duration::from_secs(15))
.build()?;
let status = response.status();
let response_text = response.text().await?;
// 10. 发送HTTP请求带重试机制
let mut last_error = None;
for attempt in 0..3 {
match client
.post(&search_url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
{
Ok(response) => {
let status = response.status();
let response_text = response.text().await?;
if !status.is_success() {
return Err(anyhow::anyhow!("Vertex AI Search 请求失败: {} - {}", status, response_text));
if !status.is_success() {
return Err(anyhow::anyhow!("Vertex AI Search 请求失败: {} - {}", status, response_text));
}
// 11. 解析响应
let vertex_response: serde_json::Value = serde_json::from_str(&response_text)?;
// 12. 转换为我们的搜索结果格式
let search_results = convert_vertex_response_to_search_results(&vertex_response, request)?;
return Ok(search_results);
}
Err(e) => {
let error_msg = format!("网络请求失败: {}", e);
eprintln!("{}", error_msg);
last_error = Some(anyhow::anyhow!(error_msg));
if attempt < 2 {
eprintln!("Vertex AI Search 请求失败,重试中... (尝试 {}/3)", attempt + 1);
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
} else {
eprintln!("Vertex AI Search 请求最终失败已重试3次");
}
}
}
}
// 10. 解析响应
let vertex_response: serde_json::Value = serde_json::from_str(&response_text)?;
// 11. 转换为我们的搜索结果格式
let search_results = convert_vertex_response_to_search_results(&vertex_response, request)?;
Ok(search_results)
Err(anyhow::anyhow!("Vertex AI Search 请求失败已重试3次: {}", last_error.unwrap()))
}
/// 获取 Google 访问令牌
async fn get_google_access_token() -> Result<String, anyhow::Error> {
let config = GeminiConfig::default();
let client = reqwest::Client::new();
// 创建带有超时和重试配置的客户端
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()?;
let url = format!("{}/google/access-token", config.base_url);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", config.bearer_token))
.send()
.await?;
// 重试机制
let mut last_error = None;
for attempt in 0..3 {
match client
.get(&url)
.header("Authorization", format!("Bearer {}", config.bearer_token))
.send()
.await
{
Ok(response) => {
let status = response.status();
if !status.is_success() {
let error_body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("获取访问令牌失败: {} - {}", status, error_body));
}
let status = response.status();
if !status.is_success() {
let error_body = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("获取访问令牌失败: {} - {}", status, error_body));
let response_text = response.text().await?;
let token_response: serde_json::Value = serde_json::from_str(&response_text)?;
let access_token = token_response
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("访问令牌响应中未找到 access_token 字段"))?;
return Ok(access_token.to_string());
}
Err(e) => {
let error_msg = format!("网络连接失败: {}", e);
eprintln!("{}", error_msg);
last_error = Some(anyhow::anyhow!(error_msg));
if attempt < 2 {
eprintln!("获取访问令牌失败,重试中... (尝试 {}/3)", attempt + 1);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
}
let response_text = response.text().await?;
let token_response: serde_json::Value = serde_json::from_str(&response_text)?;
let access_token = token_response
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("访问令牌响应中未找到 access_token 字段"))?;
Ok(access_token.to_string())
Err(anyhow::anyhow!("获取访问令牌失败已重试3次: {}", last_error.unwrap()))
}
/// 将 Vertex AI Search 响应转换为我们的搜索结果格式
@@ -441,16 +560,29 @@ fn convert_vertex_response_to_search_results(
// 解析 Vertex AI Search 响应
if let Some(vertex_results) = vertex_response.get("results").and_then(|v| v.as_array()) {
eprintln!("收到 {} 个原始搜索结果", vertex_results.len());
for vertex_result in vertex_results {
if let Ok(search_result) = parse_vertex_result_to_search_result(vertex_result) {
eprintln!("解析结果: ID={}, 相关性评分={:.2}", search_result.id, search_result.relevance_score);
// 应用相关性阈值过滤
if search_result.relevance_score >= request.config.relevance_threshold.to_value() {
let threshold = request.config.relevance_threshold.to_value();
if search_result.relevance_score >= threshold {
results.push(search_result);
} else {
eprintln!("结果被过滤: 评分 {:.2} < 阈值 {:.2}", search_result.relevance_score, threshold);
}
} else {
eprintln!("解析搜索结果失败");
}
}
} else {
eprintln!("响应中没有找到 results 数组");
}
eprintln!("最终返回 {} 个过滤后的结果", results.len());
let total_size = vertex_response
.get("totalSize")
.and_then(|v| v.as_u64())
@@ -472,7 +604,6 @@ fn convert_vertex_response_to_search_results(
/// 解析单个 Vertex AI Search 结果为我们的搜索结果格式
fn parse_vertex_result_to_search_result(vertex_result: &serde_json::Value) -> Result<SearchResult, anyhow::Error> {
// 获取文档数据
let document = vertex_result
.get("document")
@@ -520,13 +651,20 @@ fn parse_vertex_result_to_search_result(vertex_result: &serde_json::Value) -> Re
.unwrap_or_else(Vec::new);
// 获取图片URL可能在不同的字段中
let image_url = struct_data
let raw_image_url = struct_data
.get("uri")
.or_else(|| struct_data.get("image_url"))
.or_else(|| struct_data.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
.unwrap_or("");
// 转换S3/GS URL为CDN URL
let image_url = convert_s3_to_cdn_url(raw_image_url);
// 调试显示URL转换
if raw_image_url != image_url {
eprintln!("URL转换: {} -> {}", raw_image_url, image_url);
}
// 获取相关性评分
let relevance_score = vertex_result

View File

@@ -14,6 +14,8 @@ pub async fn quick_similarity_search(
state: State<'_, AppState>,
query: String,
relevance_threshold: Option<String>,
page_size: Option<usize>,
page_offset: Option<usize>,
) -> Result<SearchResponse, String> {
// 构建默认搜索配置
let threshold = match relevance_threshold.as_deref() {
@@ -34,12 +36,16 @@ pub async fn quick_similarity_search(
};
let request = SearchRequest {
query,
query: query.clone(),
config,
page_size: 12, // 工具页面显示更多结果
page_offset: 0,
page_size: page_size.unwrap_or(12), // 工具页面显示更多结果,支持自定义
page_offset: page_offset.unwrap_or(0), // 支持分页偏移
};
// 调试信息
eprintln!("相似度搜索请求: query='{}', page_size={}, page_offset={}",
query, request.page_size, request.page_offset);
// 复用现有的搜索功能
search_similar_outfits(state, request).await
}

View File

@@ -124,11 +124,6 @@ export const SimilaritySearchCard: React.FC<SimilaritySearchCardProps> = ({
}`}>
{result.style_description || '未知风格'}
</h3>
{result.id && (
<p className="text-xs text-medium-emphasis mt-1">
ID: {result.id}
</p>
)}
</div>
{/* 环境标签 */}

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useRef, useEffect } from 'react';
import {
Search,
Sparkles,
Settings,
Loader2
} from 'lucide-react';
import {
@@ -18,12 +17,10 @@ import SimilaritySearchService from '../../services/similaritySearchService';
export const SimilaritySearchPanel: React.FC<SimilaritySearchPanelProps> = ({
query,
selectedThreshold,
config,
suggestions,
showSuggestions,
isSearching,
onQueryChange,
onThresholdChange,
onSearch,
onSuggestionSelect,
onSuggestionsToggle,
@@ -157,69 +154,7 @@ export const SimilaritySearchPanel: React.FC<SimilaritySearchPanelProps> = ({
</div>
</div>
{/* 相关性阈值设置 */}
<div className="card p-6 animate-fade-in">
<div className="flex items-center gap-3 mb-4">
<div className="icon-container orange w-8 h-8">
<Settings className="w-4 h-4" />
</div>
<div>
<h3 className="text-heading-4 text-high-emphasis"></h3>
<p className="text-xs text-medium-emphasis"></p>
</div>
</div>
{config?.available_thresholds && (
<div className="space-y-3">
{config.available_thresholds.map((threshold) => (
<label
key={threshold.value}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
selectedThreshold === threshold.value
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<input
type="radio"
name="threshold"
value={threshold.value}
checked={selectedThreshold === threshold.value}
onChange={(e) => onThresholdChange(e.target.value)}
className="sr-only"
/>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
selectedThreshold === threshold.value
? 'border-primary-500 bg-primary-500'
: 'border-gray-300'
}`}>
{selectedThreshold === threshold.value && (
<div className="w-2 h-2 rounded-full bg-white"></div>
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className={`font-medium ${
selectedThreshold === threshold.value
? 'text-primary-900'
: 'text-gray-900'
}`}>
{threshold.label}
</span>
</div>
<p className={`text-sm mt-1 ${
selectedThreshold === threshold.value
? 'text-primary-700'
: 'text-gray-600'
}`}>
{threshold.description}
</p>
</div>
</label>
))}
</div>
)}
</div>
{/* 搜索提示 */}
<div className="card p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 animate-fade-in">
@@ -240,7 +175,7 @@ export const SimilaritySearchPanel: React.FC<SimilaritySearchPanelProps> = ({
</li>
<li className="flex items-center gap-2">
<div className="w-1 h-1 bg-blue-400 rounded-full"></div>
使
</li>
</ul>
</div>

View File

@@ -1,12 +1,16 @@
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import {
Grid,
Clock,
TrendingUp,
ExternalLink
ExternalLink,
ChevronRight,
ChevronLeft,
Loader2
} from 'lucide-react';
import { SimilaritySearchResultsProps } from '../../types/similaritySearch';
import { SimilaritySearchCard } from './SimilaritySearchCard';
import { SimilaritySearchCard } from './SimilaritySearchCard'
import SimilaritySearchService from '../../services/similaritySearchService';
/**
@@ -20,12 +24,54 @@ export const SimilaritySearchResults: React.FC<SimilaritySearchResultsProps> = (
maxResultsPerPage,
isLoading,
onPageChange,
onPageSizeChange,
onResultSelect,
}) => {
// 计算分页信息
const totalPages = SimilaritySearchService.getTotalPages(totalResults, maxResultsPerPage);
const pageInfo = SimilaritySearchService.getPageRangeInfo(currentPage, maxResultsPerPage, totalResults);
// 生成智能分页数字
const generatePageNumbers = useCallback(() => {
const pages: (number | string)[] = [];
const maxVisible = 7; // 最多显示7个页码按钮
if (totalPages <= maxVisible) {
// 总页数少,显示所有页码
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// 总页数多,智能显示
pages.push(1); // 始终显示第一页
if (currentPage <= 4) {
// 当前页在前面
for (let i = 2; i <= 5; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 3) {
// 当前页在后面
pages.push('...');
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
// 当前页在中间
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
}, [currentPage, totalPages]);
// 处理结果选择
const handleResultSelect = useCallback((result: any) => {
if (onResultSelect) {
@@ -33,6 +79,16 @@ export const SimilaritySearchResults: React.FC<SimilaritySearchResultsProps> = (
}
}, [onResultSelect]);
// 处理每页显示数量变化
const handlePageSizeChange = useCallback((newPageSize: number) => {
if (onPageSizeChange) {
onPageSizeChange(newPageSize);
}
}, [onPageSizeChange]);
// 每页显示数量选项
const pageSizeOptions = [6, 12, 24, 48, 96];
if (isLoading) {
@@ -78,7 +134,20 @@ export const SimilaritySearchResults: React.FC<SimilaritySearchResultsProps> = (
}
return (
<div className="space-y-6 animate-fade-in">
<div className="relative space-y-6 animate-fade-in">
{/* 加载遮罩 */}
{isLoading && (
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg">
<div className="flex flex-col items-center gap-3 p-6">
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
<div className="text-center">
<p className="text-sm font-medium text-gray-900">...</p>
<p className="text-xs text-gray-600 mt-1"></p>
</div>
</div>
</div>
)}
{/* 结果统计 */}
<div className="card p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200">
<div className="flex items-center justify-between">
@@ -119,45 +188,94 @@ export const SimilaritySearchResults: React.FC<SimilaritySearchResultsProps> = (
{/* 分页控制 */}
{totalPages > 1 && (
<div className="card p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-medium-emphasis">
{currentPage} {totalPages}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
{/* 分页信息和每页显示数量 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="text-sm text-medium-emphasis">
{currentPage} {totalPages} ( {totalResults} )
</div>
{onPageSizeChange && (
<div className="flex items-center gap-2 text-sm">
<span className="text-medium-emphasis">:</span>
<select
value={maxResultsPerPage}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
disabled={isLoading}
className="px-2 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
{pageSizeOptions.map(size => (
<option key={size} value={size}>{size} </option>
))}
</select>
{isLoading && (
<div className="flex items-center gap-1 text-primary-600">
<Loader2 className="w-3 h-3 animate-spin" />
<span className="text-xs">...</span>
</div>
)}
</div>
)}
</div>
{/* 分页按钮 */}
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
disabled={currentPage <= 1 || isLoading}
className="btn btn-secondary btn-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && currentPage > 1 ? (
<div className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
<span></span>
</div>
) : (
'上一页'
)}
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1;
{generatePageNumbers().map((page, index) => {
if (page === '...') {
return (
<span key={`ellipsis-${index}`} className="px-2 py-1 text-gray-400">
...
</span>
);
}
const pageNum = page as number;
return (
<button
key={page}
onClick={() => onPageChange(page)}
className={`w-8 h-8 text-sm rounded-lg transition-colors duration-200 ${
currentPage === page
? 'bg-primary-500 text-white'
: 'text-gray-600 hover:bg-gray-100'
key={pageNum}
onClick={() => onPageChange(pageNum)}
disabled={isLoading}
className={`min-w-[32px] h-8 px-2 text-sm rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
currentPage === pageNum
? 'bg-primary-500 text-white shadow-md'
: 'text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{page}
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
disabled={currentPage >= totalPages || isLoading}
className="btn btn-secondary btn-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && currentPage < totalPages ? (
<div className="flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
<span></span>
</div>
) : (
'下一页'
)}
</button>
</div>
</div>
@@ -178,6 +296,46 @@ export const SimilaritySearchResults: React.FC<SimilaritySearchResultsProps> = (
</div>
</div>
</div>
{/* 悬浮分页按钮组 - 使用 Portal 确保真正固定在屏幕上 */}
{results.length > 0 && totalPages > 1 && createPortal(
<div className="fixed top-1/2 transform -translate-y-1/2 z-[9999] pointer-events-none">
{/* 上一页按钮 - 左侧 */}
{currentPage > 1 && (
<button
onClick={() => onPageChange(currentPage - 1)}
className="fixed left-4 w-10 h-10 bg-white/90 backdrop-blur-sm border border-gray-200/50 rounded-full shadow-md hover:shadow-lg transition-all duration-300 flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-white hover:border-gray-300 opacity-70 hover:opacity-100 pointer-events-auto"
title={`上一页 (${currentPage - 1}/${totalPages})`}
style={{
position: 'fixed',
left: '16px',
top: '50%',
transform: 'translateY(-50%)',
}}
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
{/* 下一页按钮 - 右侧 */}
{currentPage < totalPages && (
<button
onClick={() => onPageChange(currentPage + 1)}
className="fixed right-4 w-10 h-10 bg-white/90 backdrop-blur-sm border border-gray-200/50 rounded-full shadow-md hover:shadow-lg transition-all duration-300 flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-white hover:border-gray-300 opacity-70 hover:opacity-100 pointer-events-auto"
title={`下一页 (${currentPage + 1}/${totalPages})`}
style={{
position: 'fixed',
right: '16px',
top: '50%',
transform: 'translateY(-50%)',
}}
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>,
document.body
)}
</div>
);
};

View File

@@ -1,21 +1,23 @@
import React, { useEffect, useCallback } from 'react';
import {
Search,
Sparkles,
RotateCcw,
import {
Search,
Sparkles,
RotateCcw,
TrendingUp,
Zap,
ArrowLeft
ArrowLeft,
Loader2
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import {
useSimilaritySearchStore,
useSimilaritySearchSelectors,
useSimilaritySearchActions
import {
useSimilaritySearchStore,
useSimilaritySearchSelectors,
useSimilaritySearchActions
} from '../../store/similaritySearchStore';
import { SimilaritySearchPanel } from '../../components/similarity/SimilaritySearchPanel';
import { SimilaritySearchResults } from '../../components/similarity/SimilaritySearchResults';
import SimilaritySearchResults from '../../components/similarity/SimilaritySearchResults';
import { SimilaritySearchRequest } from '../../types/similaritySearch';
import { CustomSelect } from '../../components/CustomSelect';
/**
* 相似度检索工具页面
@@ -31,6 +33,8 @@ const SimilaritySearchTool: React.FC = () => {
setQuery,
setThreshold,
executeSearch,
searchWithPagination,
changePageSize,
loadConfig,
clearError,
setShowSuggestions,
@@ -51,6 +55,24 @@ const SimilaritySearchTool: React.FC = () => {
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);
@@ -68,9 +90,33 @@ const SimilaritySearchTool: React.FC = () => {
// 处理重置
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]);
// 监听相关性阈值变化,立即重新搜索
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');
@@ -90,9 +136,9 @@ const SimilaritySearchTool: React.FC = () => {
<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" />
@@ -105,6 +151,40 @@ const SimilaritySearchTool: React.FC = () => {
</div>
<div className="flex items-center gap-3">
{/* 相关性阈值选择器 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">:</span>
<div className="flex items-center gap-2">
<CustomSelect
value={selectedThreshold}
onChange={setThreshold}
options={
configState.config?.available_thresholds.map(threshold => ({
value: threshold.value,
label: threshold.label,
description: threshold.description
})) || [
{ value: "LOWEST", label: "最低 (0.3)", description: "显示更多相关结果" },
{ value: "LOW", label: "较低 (0.5)", description: "包含较多相关结果" },
{ value: "MEDIUM", label: "中等 (0.7)", description: "平衡相关性和数量" },
{ value: "HIGH", label: "较高 (0.9)", description: "只显示高度相关结果" }
]
}
placeholder="选择相关性阈值"
className="min-w-[160px]"
disabled={searchState.isSearching}
/>
{searchState.isSearching && (
<div className="flex items-center gap-1 text-primary-600">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs">...</span>
</div>
)}
</div>
</div>
<div className="h-6 w-px bg-gray-200"></div>
<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"
@@ -117,8 +197,21 @@ const SimilaritySearchTool: React.FC = () => {
</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="container mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
<div className="container mx-auto py-6 lg:py-8">
{/* 快速开始提示 */}
{!searchState.results.length && !searchState.isSearching && (
<div className="mb-8 animate-fade-in">
@@ -161,12 +254,10 @@ const SimilaritySearchTool: React.FC = () => {
<SimilaritySearchPanel
query={query}
selectedThreshold={selectedThreshold}
config={configState.config}
suggestions={suggestionsState.suggestions}
showSuggestions={suggestionsState.showSuggestions}
isSearching={searchState.isSearching}
onQueryChange={setQuery}
onThresholdChange={setThreshold}
onSearch={handleSearch}
onSuggestionSelect={handleSuggestionSelect}
onSuggestionsToggle={setShowSuggestions}
@@ -182,8 +273,9 @@ const SimilaritySearchTool: React.FC = () => {
currentPage={searchState.currentPage}
maxResultsPerPage={configState.config?.max_results_per_page || 12}
isLoading={searchState.isSearching}
onPageChange={() => {}} // 暂时不实现分页
onResultSelect={(result) => {
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onResultSelect={(result: any) => {
console.log('Selected result:', result);
}}
/>

View File

@@ -18,6 +18,8 @@ export class SimilaritySearchService {
const response = await invoke<SearchResponse>('quick_similarity_search', {
query: request.query,
relevanceThreshold: request.relevance_threshold,
pageSize: request.page_size,
pageOffset: request.page_offset,
});
return response;
} catch (error) {
@@ -94,7 +96,7 @@ export class SimilaritySearchService {
}
const lowercaseQuery = query.toLowerCase();
const filtered = baseList.filter(item =>
const filtered = baseList.filter(item =>
item.toLowerCase().includes(lowercaseQuery) ||
lowercaseQuery.split('').some(char => item.includes(char))
);

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
SimilaritySearchState,
SimilaritySearchRequest,
@@ -6,11 +7,15 @@ import {
} from '../types/similaritySearch';
import SimilaritySearchService from '../services/similaritySearchService';
/**
* 相似度检索工具状态管理
* 遵循 Tauri 开发规范的状态管理模式
*/
export const useSimilaritySearchStore = create<SimilaritySearchState>((set, get) => ({
export const useSimilaritySearchStore = create<SimilaritySearchState>()(
persist(
(set, get) => ({
// 搜索状态
query: '',
selectedThreshold: DEFAULT_SIMILARITY_SEARCH_CONFIG.default_threshold || 'MEDIUM',
@@ -48,15 +53,15 @@ export const useSimilaritySearchStore = create<SimilaritySearchState>((set, get)
executeSearch: async (request: SimilaritySearchRequest) => {
const { query } = request;
// 验证查询
if (!SimilaritySearchService.validateQuery(query)) {
set({ searchError: '请输入有效的搜索关键词' });
return;
}
set({
isSearching: true,
set({
isSearching: true,
searchError: null,
showSuggestions: false,
currentPage: 1,
@@ -64,7 +69,7 @@ export const useSimilaritySearchStore = create<SimilaritySearchState>((set, get)
try {
const response = await SimilaritySearchService.quickSimilaritySearch(request);
set({
searchResults: response.results,
totalResults: response.total_size,
@@ -80,19 +85,151 @@ export const useSimilaritySearchStore = create<SimilaritySearchState>((set, get)
}
},
loadConfig: async () => {
set({ isLoadingConfig: true });
// 分页搜索
searchWithPagination: async (page: number) => {
const state = get();
const maxResultsPerPage = state.config?.max_results_per_page || 12;
// 构建分页请求
const request: SimilaritySearchRequest = {
query: state.query,
relevance_threshold: state.selectedThreshold,
page_size: maxResultsPerPage,
page_offset: (page - 1) * maxResultsPerPage,
};
console.log('分页搜索请求:', {
page,
query: state.query,
page_size: maxResultsPerPage,
page_offset: (page - 1) * maxResultsPerPage,
});
set({
isSearching: true,
searchError: null,
currentPage: page,
});
try {
const config = await SimilaritySearchService.getConfig();
set({
config,
selectedThreshold: config.default_threshold,
isLoadingConfig: false,
const response = await SimilaritySearchService.quickSimilaritySearch(request);
console.log('分页搜索响应:', {
results_count: response.results.length,
total_size: response.total_size,
current_page: page,
});
set({
searchResults: response.results,
totalResults: response.total_size,
isSearching: false,
});
} catch (error) {
console.error('分页搜索失败:', error);
set({
searchError: error instanceof Error ? error.message : '搜索失败',
isSearching: false,
});
}
},
// 更改每页显示数量
changePageSize: async (pageSize: number) => {
const state = get();
console.log('更改每页显示数量:', {
oldPageSize: state.config?.max_results_per_page,
newPageSize: pageSize,
currentPage: state.currentPage,
query: state.query
});
// 更新配置中的每页显示数量
if (state.config) {
set({
config: {
...state.config,
max_results_per_page: pageSize,
},
currentPage: 1, // 重置到第一页
});
}
// 如果有查询,重新执行搜索
if (state.query.trim()) {
const request: SimilaritySearchRequest = {
query: state.query,
relevance_threshold: state.selectedThreshold,
page_size: pageSize,
page_offset: 0,
};
set({
isSearching: true,
searchError: null,
});
try {
const response = await SimilaritySearchService.quickSimilaritySearch(request);
set({
searchResults: response.results,
totalResults: response.total_size,
isSearching: false,
});
} catch (error) {
console.error('更改每页显示数量后搜索失败:', error);
set({
searchError: error instanceof Error ? error.message : '搜索失败',
isSearching: false,
});
}
}
},
loadConfig: async () => {
set({ isLoadingConfig: true });
try {
const config = await SimilaritySearchService.getConfig();
// 获取当前状态,保持持久化的设置
const currentState = get();
// 检查是否有持久化的设置
const persistedData = localStorage.getItem('similarity-search-storage');
let persistedSettings = null;
if (persistedData) {
try {
const parsed = JSON.parse(persistedData);
persistedSettings = parsed.state;
} catch (e) {
console.warn('无法解析持久化的搜索设置:', e);
}
}
set({
config: {
...config,
// 如果有持久化的每页显示数量,使用它,否则使用配置默认值
max_results_per_page: persistedSettings?.maxResultsPerPage || config.max_results_per_page,
},
// 如果有持久化的阈值,使用它,否则使用配置默认值
selectedThreshold: persistedSettings?.selectedThreshold || config.default_threshold,
isLoadingConfig: false,
});
console.log('配置加载完成,已恢复持久化设置:', {
threshold: persistedSettings?.selectedThreshold || config.default_threshold,
pageSize: persistedSettings?.maxResultsPerPage || config.max_results_per_page,
query: persistedSettings?.query || currentState.query,
});
} catch (error) {
console.error('Failed to load config:', error);
set({
set({
isLoadingConfig: false,
// 使用默认配置
config: {
@@ -139,7 +276,26 @@ export const useSimilaritySearchStore = create<SimilaritySearchState>((set, get)
setShowSuggestions: (show: boolean) => {
set({ showSuggestions: show });
},
}));
}),
{
name: 'similarity-search-storage', // 本地存储的键名
partialize: (state) => ({
query: state.query,
selectedThreshold: state.selectedThreshold,
maxResultsPerPage: state.config?.max_results_per_page || 12,
}),
// 从存储中恢复状态时的处理
onRehydrateStorage: () => (state) => {
if (state) {
console.log('相似度搜索参数已从本地存储恢复:', {
query: state.query,
selectedThreshold: state.selectedThreshold,
maxResultsPerPage: state.config?.max_results_per_page
});
}
},
}
));
// 选择器 hooks
export const useSimilaritySearchSelectors = () => {

View File

@@ -26,6 +26,8 @@ export interface SimilaritySearchConfig {
export interface SimilaritySearchRequest {
query: string;
relevance_threshold?: string;
page_size?: number;
page_offset?: number;
}
// 搜索状态
@@ -53,6 +55,8 @@ export interface SimilaritySearchState {
setQuery: (query: string) => void;
setThreshold: (threshold: string) => void;
executeSearch: (request: SimilaritySearchRequest) => Promise<void>;
searchWithPagination: (page: number) => Promise<void>;
changePageSize: (pageSize: number) => Promise<void>;
loadConfig: () => Promise<void>;
loadSuggestions: (query: string) => Promise<void>;
clearResults: () => void;
@@ -64,12 +68,12 @@ export interface SimilaritySearchState {
export interface SimilaritySearchPanelProps {
query: string;
selectedThreshold: string;
config: SimilaritySearchConfig | null;
config?: SimilaritySearchConfig | null; // 现在可选,因为阈值选择器已移到顶部
suggestions: string[];
showSuggestions: boolean;
isSearching: boolean;
onQueryChange: (query: string) => void;
onThresholdChange: (threshold: string) => void;
onThresholdChange?: (threshold: string) => void; // 现在可选,因为阈值选择器已移到顶部
onSearch: (request: SimilaritySearchRequest) => void;
onSuggestionSelect: (suggestion: string) => void;
onSuggestionsToggle: (show: boolean) => void;
@@ -82,6 +86,7 @@ export interface SimilaritySearchResultsProps {
maxResultsPerPage: number;
isLoading: boolean;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onResultSelect?: (result: SearchResult) => void;
}