feat(auto): 智能搜索
This commit is contained in:
parent
49bb95604b
commit
8af87cd9f4
12
pom.xml
12
pom.xml
@ -11,6 +11,9 @@
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
<spring.boot.version>2.7.18</spring.boot.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<maven.compiler.encoding>UTF-8</maven.compiler.encoding>
|
||||
<!-- <maven.compiler.release>8</maven.compiler.release>-->
|
||||
</properties>
|
||||
|
||||
@ -82,9 +85,12 @@
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<!--<configuration>
|
||||
<release>${maven.compiler.release}</release>
|
||||
</configuration>-->
|
||||
<configuration>
|
||||
<encoding>UTF-8</encoding>
|
||||
<source>1.8</source>
|
||||
<target>1.8</target>
|
||||
<!--<release>${maven.compiler.release}</release>-->
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
@ -9,6 +9,7 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.index.Index;
|
||||
import org.springframework.data.mongodb.core.index.IndexOperations;
|
||||
import org.springframework.data.mongodb.core.index.TextIndexDefinition;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
@ -76,7 +77,23 @@ public class MongoIndexConfig {
|
||||
.named("idx_tags")
|
||||
.background());
|
||||
|
||||
log.info("Novel索引已确保: idx_name, idx_author, idx_tags");
|
||||
// 全文搜索索引 - 支持智能搜索(可选,如果MongoDB版本支持)
|
||||
// 注意:MongoDB全文搜索需要特定版本支持,如果失败不影响基本搜索功能
|
||||
try {
|
||||
TextIndexDefinition.TextIndexDefinitionBuilder builder =
|
||||
new TextIndexDefinition.TextIndexDefinitionBuilder();
|
||||
builder.onField("name", 10.0f); // 书名权重最高
|
||||
builder.onField("author", 5.0f); // 作者权重中等
|
||||
builder.onField("synopsis", 1.0f); // 简介权重最低
|
||||
builder.onField("tags", 3.0f); // 标签权重
|
||||
TextIndexDefinition textIndex = builder.build();
|
||||
indexOps.ensureIndex(textIndex);
|
||||
log.info("全文搜索索引已创建");
|
||||
} catch (Exception e) {
|
||||
log.warn("创建全文搜索索引失败(可能MongoDB版本不支持),将使用普通搜索: {}", e.getMessage());
|
||||
}
|
||||
|
||||
log.info("Novel索引已确保: idx_name, idx_author, idx_tags, text_index");
|
||||
}
|
||||
|
||||
private void createHistoryIndexes() {
|
||||
|
||||
@ -41,6 +41,14 @@ public class NovelController {
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/suggestions")
|
||||
@Operation(summary = "获取搜索建议", description = "根据输入的关键词返回搜索建议")
|
||||
public ResponseEntity<List<NovelService.SearchSuggestion>> getSearchSuggestions(
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
return ResponseEntity.ok(novelService.getSearchSuggestions(keyword, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/chapters")
|
||||
@Operation(summary = "获取章节列表")
|
||||
public ResponseEntity<Page<ChapterHead>> getChapters(
|
||||
|
||||
@ -8,6 +8,7 @@ import com.novelreader.repository.ChapterRepository;
|
||||
import com.novelreader.repository.NovelRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
@ -15,9 +16,7 @@ import org.springframework.data.mongodb.core.aggregation.Aggregation;
|
||||
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@ -28,16 +27,18 @@ public class NovelService {
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final MongoTemplate mongoTemplate;
|
||||
|
||||
/**
|
||||
* 智能搜索小说 - 支持相关性排序和多种匹配策略
|
||||
*/
|
||||
public Page<NovelBrief> searchNovels(String keyword, String tag, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
Page<NovelDO> novels;
|
||||
|
||||
List<String> tags = tag != null && !tag.isEmpty() ? Collections.singletonList(tag) : null;
|
||||
|
||||
if (keyword != null && !keyword.isEmpty() && tags != null) {
|
||||
novels = novelRepository.findByNameOrAuthorAndTags(keyword, tags, pageable);
|
||||
} else if (keyword != null && !keyword.isEmpty()) {
|
||||
novels = novelRepository.findByNameOrAuthorContainingIgnoreCase(keyword, pageable);
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
// 智能搜索:优先精确匹配,然后模糊匹配
|
||||
novels = intelligentSearch(keyword, tags, pageable);
|
||||
} else if (tags != null) {
|
||||
novels = novelRepository.findByTagsIn(tags, pageable);
|
||||
} else {
|
||||
@ -47,6 +48,122 @@ public class NovelService {
|
||||
return novels.map(this::toBrief);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能搜索实现 - 多策略搜索并合并结果
|
||||
*/
|
||||
private Page<NovelDO> intelligentSearch(String keyword, List<String> tags, Pageable pageable) {
|
||||
keyword = keyword.trim();
|
||||
|
||||
// 策略1: 书名精确匹配(最高优先级)
|
||||
List<NovelDO> exactNameMatches = novelRepository.findByNameStartingWithIgnoreCase(keyword);
|
||||
|
||||
// 策略2: 书名包含匹配
|
||||
List<NovelDO> nameContainsMatches = novelRepository.findByNameContainingIgnoreCase(keyword);
|
||||
|
||||
// 策略3: 作者精确匹配
|
||||
List<NovelDO> exactAuthorMatches = novelRepository.findByAuthor(keyword);
|
||||
|
||||
// 策略4: 书名或作者模糊匹配(原有逻辑)
|
||||
Page<NovelDO> fuzzyMatches = novelRepository.findByNameOrAuthorContainingIgnoreCase(keyword, pageable);
|
||||
|
||||
// 合并结果,去重,按优先级排序
|
||||
Set<String> seenIds = new HashSet<>();
|
||||
List<NovelDO> mergedResults = new ArrayList<>();
|
||||
|
||||
// 1. 书名精确匹配(权重最高)
|
||||
for (NovelDO novel : exactNameMatches) {
|
||||
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
|
||||
mergedResults.add(novel);
|
||||
seenIds.add(novel.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 书名包含匹配
|
||||
for (NovelDO novel : nameContainsMatches) {
|
||||
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
|
||||
mergedResults.add(novel);
|
||||
seenIds.add(novel.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 作者精确匹配
|
||||
for (NovelDO novel : exactAuthorMatches) {
|
||||
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
|
||||
mergedResults.add(novel);
|
||||
seenIds.add(novel.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 模糊匹配结果(如果还有空间)
|
||||
int remaining = pageable.getPageSize() - mergedResults.size();
|
||||
if (remaining > 0 && fuzzyMatches.hasContent()) {
|
||||
for (NovelDO novel : fuzzyMatches.getContent()) {
|
||||
if (!seenIds.contains(novel.getId()) && (tags == null || novel.getTags() != null && new HashSet<>(novel.getTags()).containsAll(tags))) {
|
||||
mergedResults.add(novel);
|
||||
seenIds.add(novel.getId());
|
||||
if (--remaining <= 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为分页结果
|
||||
int total = mergedResults.size();
|
||||
int start = (int) pageable.getOffset();
|
||||
int end = Math.min(start + pageable.getPageSize(), total);
|
||||
List<NovelDO> pageContent = start < total ? mergedResults.subList(start, end) : Collections.emptyList();
|
||||
|
||||
return new PageImpl<>(pageContent, pageable, total);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索建议 - 返回匹配的书名和作者
|
||||
*/
|
||||
public List<SearchSuggestion> getSearchSuggestions(String keyword, int limit) {
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
keyword = keyword.trim();
|
||||
List<SearchSuggestion> suggestions = new java.util.ArrayList<>();
|
||||
|
||||
// 书名建议(前缀匹配)
|
||||
List<NovelDO> nameMatches = novelRepository.findByNameStartingWithIgnoreCase(keyword);
|
||||
for (NovelDO novel : nameMatches) {
|
||||
if (suggestions.size() >= limit) break;
|
||||
SearchSuggestion suggestion = new SearchSuggestion();
|
||||
suggestion.setType("书名");
|
||||
suggestion.setText(novel.getName());
|
||||
suggestion.setNovelId(novel.getId());
|
||||
suggestions.add(suggestion);
|
||||
}
|
||||
|
||||
// 作者建议(精确匹配)
|
||||
if (suggestions.size() < limit) {
|
||||
List<NovelDO> authorMatches = novelRepository.findByAuthor(keyword);
|
||||
java.util.Set<String> seenAuthors = new java.util.HashSet<>();
|
||||
for (NovelDO novel : authorMatches) {
|
||||
if (suggestions.size() >= limit) break;
|
||||
if (!seenAuthors.contains(novel.getAuthor())) {
|
||||
SearchSuggestion suggestion = new SearchSuggestion();
|
||||
suggestion.setType("作者");
|
||||
suggestion.setText(novel.getAuthor());
|
||||
suggestions.add(suggestion);
|
||||
seenAuthors.add(novel.getAuthor());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// 搜索建议DTO
|
||||
@lombok.Data
|
||||
public static class SearchSuggestion {
|
||||
private String type; // "书名" 或 "作者"
|
||||
private String text; // 建议文本
|
||||
private String novelId; // 如果是书名,提供novelId
|
||||
}
|
||||
|
||||
public Optional<NovelDetail> getNovelDetail(String id) {
|
||||
return novelRepository.findById(id).map(novel -> {
|
||||
NovelDetail detail = toDetail(novel);
|
||||
|
||||
@ -379,7 +379,69 @@
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
/* 搜索建议 */
|
||||
.search-suggestions {
|
||||
position: absolute;
|
||||
top: calc(var(--header-height) + 1px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.suggestion-item:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.suggestion-type {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.suggestion-text mark {
|
||||
background: var(--primary);
|
||||
color: var(--bg-primary);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 搜索结果高亮 */
|
||||
.novel-title mark,
|
||||
.novel-author mark {
|
||||
background: var(--primary);
|
||||
color: var(--bg-primary);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
|
||||
@ -65,6 +65,15 @@ const API = (function() {
|
||||
return get('/novels', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取搜索建议
|
||||
* @param {string} keyword
|
||||
* @param {number} limit
|
||||
*/
|
||||
function getSearchSuggestions(keyword, limit = 10) {
|
||||
return get('/novels/suggestions', { keyword, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取小说详情
|
||||
* @param {string} novelId
|
||||
@ -255,6 +264,7 @@ const API = (function() {
|
||||
return {
|
||||
// 小说
|
||||
searchNovels,
|
||||
getSearchSuggestions,
|
||||
getNovelDetail,
|
||||
getChapters,
|
||||
getPopularTags,
|
||||
|
||||
@ -162,17 +162,37 @@ const App = (function() {
|
||||
// 搜索面板
|
||||
$('#search-back').on('click', hideSearch);
|
||||
|
||||
// 搜索输入 - 实时搜索
|
||||
$('#search-input').on('input', debounce(function() {
|
||||
// 搜索输入 - 实时搜索建议
|
||||
let searchSuggestionTimer = null;
|
||||
$('#search-input').on('input', function() {
|
||||
const keyword = $(this).val().trim();
|
||||
if (keyword) {
|
||||
$('#search-clear').removeClass('hidden');
|
||||
doSearch(keyword);
|
||||
// 显示搜索建议
|
||||
clearTimeout(searchSuggestionTimer);
|
||||
searchSuggestionTimer = setTimeout(() => {
|
||||
loadSearchSuggestions(keyword);
|
||||
}, 300);
|
||||
} else {
|
||||
$('#search-clear').addClass('hidden');
|
||||
$('#search-results').html('<div class="search-hint">输入关键词搜索小说</div>');
|
||||
$('#search-suggestions').html('').addClass('hidden');
|
||||
}
|
||||
}, 500));
|
||||
});
|
||||
|
||||
// 搜索输入 - 回车确认搜索
|
||||
$('#search-input').on('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const keyword = $(this).val().trim();
|
||||
if (keyword) {
|
||||
$('#search-suggestions').html('').addClass('hidden');
|
||||
doSearch(keyword);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
$('#search-suggestions').html('').addClass('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索输入 - 回车确认搜索
|
||||
$('#search-input').on('keypress', function(e) {
|
||||
@ -413,11 +433,57 @@ const App = (function() {
|
||||
$('#search-panel').removeClass('hidden');
|
||||
$('#search-input').val('').focus();
|
||||
$('#search-results').html('<div class="search-hint">输入关键词搜索小说</div>');
|
||||
$('#search-suggestions').html('').addClass('hidden');
|
||||
|
||||
// 渲染搜索历史和热门标签
|
||||
renderSearchTags();
|
||||
}
|
||||
|
||||
// 加载搜索建议
|
||||
async function loadSearchSuggestions(keyword) {
|
||||
if (!keyword || keyword.length < 1) {
|
||||
$('#search-suggestions').html('').addClass('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const suggestions = await API.getSearchSuggestions(keyword, 8);
|
||||
if (suggestions.length === 0) {
|
||||
$('#search-suggestions').html('').addClass('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="suggestions-list">';
|
||||
suggestions.forEach(suggestion => {
|
||||
const highlightText = highlightKeyword(suggestion.text, keyword);
|
||||
if (suggestion.novelId) {
|
||||
html += `<div class="suggestion-item" data-novel-id="${suggestion.novelId}">
|
||||
<span class="suggestion-type">${suggestion.type}</span>
|
||||
<span class="suggestion-text">${highlightText}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="suggestion-item" data-author="${suggestion.text}">
|
||||
<span class="suggestion-type">${suggestion.type}</span>
|
||||
<span class="suggestion-text">${highlightText}</span>
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
$('#search-suggestions').html(html).removeClass('hidden');
|
||||
} catch (error) {
|
||||
console.error('加载搜索建议失败:', error);
|
||||
$('#search-suggestions').html('').addClass('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 高亮关键词
|
||||
function highlightKeyword(text, keyword) {
|
||||
if (!text || !keyword) return text;
|
||||
const regex = new RegExp(`(${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
return text.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
async function renderSearchTags() {
|
||||
let html = '';
|
||||
|
||||
|
||||
@ -687,6 +687,9 @@ const Pages = (function() {
|
||||
const $results = $('#search-results');
|
||||
$results.html('<div class="chapter-loading"><div class="loading-spinner"></div><span>搜索中...</span></div>');
|
||||
|
||||
// 保存当前搜索关键词用于高亮
|
||||
currentSearchKeyword = keyword;
|
||||
|
||||
try {
|
||||
const data = await API.searchNovels({ keyword, page: 0, size: 30 });
|
||||
const novels = data.content || [];
|
||||
@ -699,7 +702,7 @@ const Pages = (function() {
|
||||
let html = '<div class="novel-list">';
|
||||
novels.forEach(novel => {
|
||||
const isInShelf = Store.isInShelfCache(novel.id);
|
||||
html += renderNovelCard(novel, isInShelf);
|
||||
html += renderNovelCard(novel, isInShelf, keyword);
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
|
||||
@ -275,6 +275,9 @@
|
||||
<div id="search-tags" class="search-tags">
|
||||
<!-- 热门标签 -->
|
||||
</div>
|
||||
<div id="search-suggestions" class="search-suggestions hidden">
|
||||
<!-- 搜索建议 -->
|
||||
</div>
|
||||
<div id="search-results" class="search-results">
|
||||
<!-- 搜索结果 -->
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user