From 8af87cd9f4eac3743d7c94df4ab168cc63b44c41 Mon Sep 17 00:00:00 2001 From: irony Date: Sat, 6 Dec 2025 20:57:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(auto):=20=E6=99=BA=E8=83=BD=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 12 +- .../novelreader/config/MongoIndexConfig.java | 19 ++- .../controller/NovelController.java | 8 ++ .../com/novelreader/service/NovelService.java | 131 +++++++++++++++++- src/main/resources/static/css/pages.css | 62 +++++++++ src/main/resources/static/js/api.js | 10 ++ src/main/resources/static/js/app.js | 74 +++++++++- src/main/resources/static/js/pages.js | 5 +- src/main/resources/templates/index.html | 3 + 9 files changed, 308 insertions(+), 16 deletions(-) diff --git a/pom.xml b/pom.xml index 8391c3b..232c71c 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,9 @@ 1.8 2.7.18 + UTF-8 + UTF-8 + UTF-8 @@ -82,9 +85,12 @@ org.apache.maven.plugins maven-compiler-plugin 3.11.0 - + + UTF-8 + 1.8 + 1.8 + + diff --git a/src/main/java/com/novelreader/config/MongoIndexConfig.java b/src/main/java/com/novelreader/config/MongoIndexConfig.java index 1b464d2..c68d4ab 100644 --- a/src/main/java/com/novelreader/config/MongoIndexConfig.java +++ b/src/main/java/com/novelreader/config/MongoIndexConfig.java @@ -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() { diff --git a/src/main/java/com/novelreader/controller/NovelController.java b/src/main/java/com/novelreader/controller/NovelController.java index 7583c9b..1550f3a 100644 --- a/src/main/java/com/novelreader/controller/NovelController.java +++ b/src/main/java/com/novelreader/controller/NovelController.java @@ -41,6 +41,14 @@ public class NovelController { .orElse(ResponseEntity.notFound().build()); } + @GetMapping("/suggestions") + @Operation(summary = "获取搜索建议", description = "根据输入的关键词返回搜索建议") + public ResponseEntity> getSearchSuggestions( + @RequestParam String keyword, + @RequestParam(defaultValue = "10") int limit) { + return ResponseEntity.ok(novelService.getSearchSuggestions(keyword, limit)); + } + @GetMapping("/{id}/chapters") @Operation(summary = "获取章节列表") public ResponseEntity> getChapters( diff --git a/src/main/java/com/novelreader/service/NovelService.java b/src/main/java/com/novelreader/service/NovelService.java index 520a30f..32c5ee0 100644 --- a/src/main/java/com/novelreader/service/NovelService.java +++ b/src/main/java/com/novelreader/service/NovelService.java @@ -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 searchNovels(String keyword, String tag, int page, int size) { Pageable pageable = PageRequest.of(page, size); Page novels; List 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 intelligentSearch(String keyword, List tags, Pageable pageable) { + keyword = keyword.trim(); + + // 策略1: 书名精确匹配(最高优先级) + List exactNameMatches = novelRepository.findByNameStartingWithIgnoreCase(keyword); + + // 策略2: 书名包含匹配 + List nameContainsMatches = novelRepository.findByNameContainingIgnoreCase(keyword); + + // 策略3: 作者精确匹配 + List exactAuthorMatches = novelRepository.findByAuthor(keyword); + + // 策略4: 书名或作者模糊匹配(原有逻辑) + Page fuzzyMatches = novelRepository.findByNameOrAuthorContainingIgnoreCase(keyword, pageable); + + // 合并结果,去重,按优先级排序 + Set seenIds = new HashSet<>(); + List 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 pageContent = start < total ? mergedResults.subList(start, end) : Collections.emptyList(); + + return new PageImpl<>(pageContent, pageable, total); + } + + /** + * 搜索建议 - 返回匹配的书名和作者 + */ + public List getSearchSuggestions(String keyword, int limit) { + if (keyword == null || keyword.trim().isEmpty()) { + return Collections.emptyList(); + } + + keyword = keyword.trim(); + List suggestions = new java.util.ArrayList<>(); + + // 书名建议(前缀匹配) + List 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 authorMatches = novelRepository.findByAuthor(keyword); + java.util.Set 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 getNovelDetail(String id) { return novelRepository.findById(id).map(novel -> { NovelDetail detail = toDetail(novel); diff --git a/src/main/resources/static/css/pages.css b/src/main/resources/static/css/pages.css index 37d8b5d..deaf43d 100644 --- a/src/main/resources/static/css/pages.css +++ b/src/main/resources/static/css/pages.css @@ -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 { diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index a6052bb..f02e334 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -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, diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 4d922ed..f3184b0 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -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('
输入关键词搜索小说
'); + $('#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('
输入关键词搜索小说
'); + $('#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 = '
'; + suggestions.forEach(suggestion => { + const highlightText = highlightKeyword(suggestion.text, keyword); + if (suggestion.novelId) { + html += `
+ ${suggestion.type} + ${highlightText} +
`; + } else { + html += `
+ ${suggestion.type} + ${highlightText} +
`; + } + }); + html += '
'; + + $('#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, '$1'); + } + async function renderSearchTags() { let html = ''; diff --git a/src/main/resources/static/js/pages.js b/src/main/resources/static/js/pages.js index 03ae2ef..d920081 100644 --- a/src/main/resources/static/js/pages.js +++ b/src/main/resources/static/js/pages.js @@ -687,6 +687,9 @@ const Pages = (function() { const $results = $('#search-results'); $results.html('
搜索中...
'); + // 保存当前搜索关键词用于高亮 + 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 = '
'; novels.forEach(novel => { const isInShelf = Store.isInShelfCache(novel.id); - html += renderNovelCard(novel, isInShelf); + html += renderNovelCard(novel, isInShelf, keyword); }); html += '
'; diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 175dd87..7684766 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -275,6 +275,9 @@
+