feat: 相似度检索工具全面优化
✨ 新功能: - 相关性阈值选择器移至页面顶部,使用CustomSelect组件 - 智能分页系统支持大量页面(如1981页) - 悬浮上一页/下一页按钮固定在屏幕两侧 - 每页显示数量选择器(6-96条可选) - 参数持久化到本地存储(关键字/阈值/每页数量) 🔄 自动重新加载: - 关键字变化:防抖搜索(800ms) - 相关性阈值变化:立即重新搜索 - 每页数量变化:重置到第一页并重新搜索 - 页码变化:加载对应页面数据 💫 加载状态优化: - 页面顶部全局搜索状态指示器 - 各控件旁的局部加载状态 - 搜索结果区域加载遮罩 - 分页按钮加载状态 🔧 技术改进: - S3/GS URL自动转换为CDN URL - Zustand persist中间件实现状态持久化 - 智能分页算法优化 - 防抖搜索机制 - 完善的错误处理和重试机制
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 环境标签 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user