HapeLee 5 сар өмнө
parent
commit
116762f826

+ 49 - 8
app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt

@@ -191,10 +191,11 @@ class SearchBooksUseCase(
                     source,
                     keyword,
                     page,
-                    filter = { name, author ->
+                    filter = { name, author, kind ->
                         matchMode == MatchMode.DEFAULT ||
                             name.contains(keyword, ignoreCase = true) ||
-                            author.contains(keyword, ignoreCase = true)
+                            author.contains(keyword, ignoreCase = true) ||
+                            kind?.contains(keyword, ignoreCase = true) == true
                     }
                 )
             }
@@ -230,19 +231,21 @@ class SearchBooksUseCase(
         }
 
         private val equalBooks = LinkedHashMap<SearchBookKey, SearchBook>()
+        private val tagsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         var resultLimitReached = false
             private set
 
         val count: Int
-            get() = equalBooks.size + containsBooks.size + otherBooks.size
+            get() = equalBooks.size + tagsBooks.size + containsBooks.size + otherBooks.size
 
         suspend fun merge(newBooks: List<SearchBook>): SearchBookChange {
             if (newBooks.isEmpty()) return SearchBookChange()
 
             val upsertBooks = arrayListOf<SearchBook>()
             val removedBookUrls = linkedSetOf<String>()
+            val touchedBuckets = linkedSetOf<LinkedHashMap<SearchBookKey, SearchBook>>()
             newBooks.forEach { newBook ->
                 coroutineContext.ensureActive()
                 val bucket = classifyBucket(newBook) ?: return@forEach
@@ -255,33 +258,71 @@ class SearchBooksUseCase(
                     currentBook.addOrigin(newBook.origin)
                     upsertBooks.add(currentBook)
                 }
+                touchedBuckets.add(bucket)
                 trimSearchBooks()?.let { removed ->
                     removedBookUrls.add(removed.bookUrl)
                     upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
                 }
             }
+            // Re-sort touched buckets by origins.size descending
+            touchedBuckets.forEach { bucket ->
+                sortBucket(bucket)
+            }
             return SearchBookChange(upsertBooks, removedBookUrls.toList())
         }
 
+        /**
+         * 将书籍分类到对应的优先级桶中:
+         * - equalBooks: 书名或作者完全等于搜索词
+         * - tagsBooks:   分类标签包含搜索词
+         * - containsBooks: 书名或作者包含搜索词(非精确匹配)
+         * - otherBooks:  其他结果(仅 DEFAULT 模式保留)
+         */
         private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? {
             return when {
                 book.name.equals(keyword, ignoreCase = true) ||
                     book.author.equals(keyword, ignoreCase = true) -> equalBooks
+                book.kind?.contains(keyword, ignoreCase = true) == true -> {
+                    if (matchMode != MatchMode.DEFAULT) null else tagsBooks
+                }
                 book.name.contains(keyword, ignoreCase = true) ||
-                        book.author.contains(
-                            keyword,
-                            ignoreCase = true
-                        ) -> if (matchMode == MatchMode.EXACT) null else containsBooks
-
+                    book.author.contains(keyword, ignoreCase = true) -> {
+                    if (matchMode == MatchMode.EXACT) null else containsBooks
+                }
                 matchMode != MatchMode.DEFAULT -> null
                 else -> otherBooks
             }
         }
 
+        /**
+         * 按 origins.size 降序重新排列桶内元素。
+         * 使用 sortedEntries 重建 LinkedHashMap 以保持排序后的迭代顺序。
+         */
+        private fun sortBucket(bucket: LinkedHashMap<SearchBookKey, SearchBook>) {
+            if (bucket.size <= 1) return
+            val sorted = bucket.entries.sortedByDescending { it.value.origins.size }
+            bucket.clear()
+            sorted.forEach { (k, v) -> bucket[k] = v }
+        }
+
+        /**
+         * 获取排序后的最终结果列表。
+         * 每个桶内按来源数量降序排列(多源 = 更可靠),桶间按优先级拼接。
+         */
+        fun getSortedList(): List<SearchBook> {
+            val sorted = ArrayList<SearchBook>(count)
+            sorted.addAll(equalBooks.values.sortedByDescending { it.origins.size })
+            sorted.addAll(tagsBooks.values.sortedByDescending { it.origins.size })
+            sorted.addAll(containsBooks.values.sortedByDescending { it.origins.size })
+            sorted.addAll(otherBooks.values)
+            return sorted
+        }
+
         private fun trimSearchBooks(): SearchBook? {
             if (count <= MAX_RETAINED_SEARCH_RESULTS) return null
             resultLimitReached = true
             return removeLast(otherBooks)
+                ?: removeLast(tagsBooks)
                 ?: removeLowestOrigin(containsBooks)
                 ?: removeLowestOrigin(equalBooks)
         }

+ 7 - 7
app/src/main/java/io/legado/app/model/webBook/BookList.kt

@@ -40,7 +40,7 @@ object BookList {
         body: String?,
         isSearch: Boolean = true,
         isRedirect: Boolean = false,
-        filter: ((name: String, author: String) -> Boolean)? = null,
+        filter: ((name: String, author: String, kind: String?) -> Boolean)? = null,
         shouldBreak: ((size: Int) -> Boolean)? = null
     ): ArrayList<SearchBook> {
         body ?: throw NoStackTraceException(
@@ -159,7 +159,7 @@ object BookList {
         baseUrl: String,
         variable: String?,
         isRedirect: Boolean,
-        filter: ((name: String, author: String) -> Boolean)?
+        filter: ((name: String, author: String, kind: String?) -> Boolean)?
     ): SearchBook? {
         val book = Book(variable = variable)
         book.bookUrl = if (isRedirect) {
@@ -181,7 +181,7 @@ object BookList {
             baseUrl,
             false
         )
-        if (filter?.invoke(book.name, book.author) == false) {
+        if (filter?.invoke(book.name, book.author, book.kind) == false) {
             return null
         }
         if (book.name.isNotBlank()) {
@@ -198,7 +198,7 @@ object BookList {
         baseUrl: String,
         variable: String?,
         log: Boolean,
-        filter: ((name: String, author: String) -> Boolean)?,
+        filter: ((name: String, author: String, kind: String?) -> Boolean)?,
         ruleName: List<AnalyzeRule.SourceRule>,
         ruleBookUrl: List<AnalyzeRule.SourceRule>,
         ruleAuthor: List<AnalyzeRule.SourceRule>,
@@ -224,9 +224,6 @@ object BookList {
             Debug.log(bookSource.bookSourceUrl, "┌获取作者", log)
             searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor))
             Debug.log(bookSource.bookSourceUrl, "└${searchBook.author}", log)
-            if (filter?.invoke(searchBook.name, searchBook.author) == false) {
-                return null
-            }
             coroutineContext.ensureActive()
             Debug.log(bookSource.bookSourceUrl, "┌获取分类", log)
             try {
@@ -236,6 +233,9 @@ object BookList {
                 coroutineContext.ensureActive()
                 Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log)
             }
+            if (filter?.invoke(searchBook.name, searchBook.author, searchBook.kind) == false) {
+                return null
+            }
             coroutineContext.ensureActive()
             Debug.log(bookSource.bookSourceUrl, "┌获取字数", log)
             try {

+ 2 - 2
app/src/main/java/io/legado/app/model/webBook/WebBook.kt

@@ -51,7 +51,7 @@ object WebBook {
         bookSource: BookSource,
         key: String,
         page: Int? = 1,
-        filter: ((name: String, author: String) -> Boolean)? = null,
+        filter: ((name: String, author: String, kind: String?) -> Boolean)? = null,
         shouldBreak: ((size: Int) -> Boolean)? = null
     ): ArrayList<SearchBook> {
         val searchUrl = bookSource.searchUrl
@@ -481,7 +481,7 @@ object WebBook {
             coroutineContext.ensureActive()
             searchBookAwait(
                 bookSource, name,
-                filter = { fName, fAuthor -> fName == name && fAuthor == author },
+                filter = { fName, fAuthor, _ -> fName == name && fAuthor == author },
                 shouldBreak = { it > 0 }
             ).firstOrNull()?.let { searchBook ->
                 coroutineContext.ensureActive()

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt

@@ -275,7 +275,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         val loadWordCount = AppConfig.changeSourceLoadWordCount
         val resultBooks = WebBook.searchBookAwait(
             source, name,
-            filter = { fName, fAuthor ->
+            filter = { fName, fAuthor, _ ->
                 fName == name && (!checkAuthor || fAuthor.contains(author))
             })
         resultBooks.forEach { searchBook ->

+ 5 - 11
app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt

@@ -16,7 +16,11 @@ class SearchActivity : BaseComposeActivity() {
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        dispatchInit(intent)
+        // Only dispatch init on fresh launch, not on config change
+        // (ViewModel is retained by Koin and already has correct state).
+        if (savedInstanceState == null) {
+            dispatchInit(intent)
+        }
     }
 
     override fun onNewIntent(intent: Intent) {
@@ -25,16 +29,6 @@ class SearchActivity : BaseComposeActivity() {
         dispatchInit(intent)
     }
 
-    override fun onResume() {
-        super.onResume()
-        viewModel.onIntent(SearchIntent.ResumeEngine)
-    }
-
-    override fun onPause() {
-        viewModel.onIntent(SearchIntent.PauseEngine)
-        super.onPause()
-    }
-
     @Composable
     override fun Content() {
         SearchScreen(

+ 6 - 3
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -8,7 +8,9 @@ import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.model.MatchMode
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
 import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
 
 @Stable
 data class SearchResultItemUi(
@@ -27,7 +29,7 @@ data class SearchUiState(
     val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
     val scopeDisplay: String = "",
     val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
-    val selectedScopeSourceUrls: Set<String> = emptySet(),
+    val selectedScopeSourceUrls: ImmutableSet<String> = persistentSetOf(),
     val isAllScope: Boolean = true,
     val isSourceScope: Boolean = false,
     val matchMode: MatchMode = MatchMode.DEFAULT,
@@ -36,7 +38,7 @@ data class SearchUiState(
     val hasMore: Boolean = true,
     val processedSources: Int = 0,
     val totalSources: Int = 0,
-    val selectedSourceTypes: Set<Int> = emptySet(),
+    val selectedSourceTypes: ImmutableSet<Int> = persistentSetOf(),
     val showScopeSheet: Boolean = false,
     val showSettingsSheet: Boolean = false,
     val showClearHistoryDialog: Boolean = false,
@@ -63,7 +65,7 @@ data class SearchEmptyScopeAction(
 
 sealed interface SearchIntent {
     data class Initialize(val key: String?, val scopeRaw: String?) : SearchIntent
-    data class UpdateQuery(val query: String) : SearchIntent
+    data class UpdateQuery(val query: String, val showSuggestions: Boolean = true) : SearchIntent
     data object SubmitSearch : SearchIntent
     data object LoadMore : SearchIntent
     data object StopSearch : SearchIntent
@@ -84,6 +86,7 @@ sealed interface SearchIntent {
     data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
     data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
     data class ToggleSourceType(val type: Int) : SearchIntent
+    data object ClearAllSourceTypes : SearchIntent
     data object SelectAllScope : SearchIntent
     data class ToggleScopeGroup(val groupName: String) : SearchIntent
     data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt

@@ -120,6 +120,7 @@ data class SearchScope(private var scope: String) {
             this.scope = stringBuilder.toString()
         }
         stateLiveData.postValue(this.scope)
+        save()
     }
 
     /**

+ 23 - 12
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -108,6 +108,7 @@ fun SearchScreen(
     var previewBook by remember { mutableStateOf<SearchBook?>(null) }
     var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
     val listState = rememberLazyListState()
+    val groupedListState = rememberLazyListState()
     val lifecycleOwner = LocalLifecycleOwner.current
     var queryInput by rememberSaveable { mutableStateOf(state.query) }
     var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
@@ -127,6 +128,14 @@ fun SearchScreen(
         }
     }
 
+    val shouldLoadMoreGrouped by remember {
+        derivedStateOf {
+            val totalCount = groupedListState.layoutInfo.totalItemsCount
+            val lastVisible = groupedListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
+            totalCount > 0 && lastVisible >= totalCount - 3
+        }
+    }
+
     LaunchedEffect(state.query) {
         if (state.query != queryInput) {
             queryInput = state.query
@@ -150,20 +159,19 @@ fun SearchScreen(
 
     LaunchedEffect(
         shouldLoadMore,
+        shouldLoadMoreGrouped,
         state.isSearching,
         state.hasMore,
         state.isManualStop,
         state.showSuggestions,
         isSourceGroupedMode,
     ) {
-        if (
-            shouldLoadMore &&
-            !state.isSearching &&
+        val readyToLoad = !state.isSearching &&
             state.hasMore &&
             !state.isManualStop &&
-            !state.showSuggestions &&
-            !isSourceGroupedMode
-        ) {
+            !state.showSuggestions
+        val nearEnd = if (isSourceGroupedMode) shouldLoadMoreGrouped else shouldLoadMore
+        if (readyToLoad && nearEnd) {
             viewModel.onIntent(SearchIntent.LoadMore)
         }
     }
@@ -416,6 +424,7 @@ fun SearchScreen(
                             ) { isSourceGrouped ->
                                 if (isSourceGrouped) {
                                     LazyColumn(
+                                        state = groupedListState,
                                         modifier = Modifier.fillMaxSize(),
                                         contentPadding = adaptiveContentPaddingOnlyVertical(
                                             top = 48.dp,
@@ -477,11 +486,11 @@ fun SearchScreen(
                                     ) {
                                         itemsIndexed(
                                             items = state.results,
-                                            key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
+                                            key = { _, item -> "${item.book.origin}:${item.book.bookUrl}" }
                                         ) { index, item ->
                                             val sharedCoverKey = bookCoverSharedElementKey(
                                                 item.book.bookUrl,
-                                                "search:${item.book.origin}:$index"
+                                                "search:${item.book.origin}"
                                             )
                                             SearchBookListItem(
                                                 book = item.book,
@@ -636,7 +645,7 @@ fun SearchScreen(
                 AppIcon(Icons.Default.Layers, contentDescription = null)
                 Spacer(modifier = Modifier.width(8.dp))
                 AppText(
-                    text = "搜索类型",
+                    text = stringResource(R.string.search_type),
                     style = LegadoTheme.typography.titleSmall
                 )
             }
@@ -679,9 +688,11 @@ fun SearchScreen(
         }
     }
 
+    val resultsByBookUrl = remember(state.results) {
+        state.results.associateBy { it.book.bookUrl }
+    }
     val previewShelfState = previewBook?.let { book ->
-        state.results.find { it.book.bookUrl == book.bookUrl }?.shelfState
-            ?: BookShelfState.NOT_IN_SHELF
+        resultsByBookUrl[book.bookUrl]?.shelfState ?: BookShelfState.NOT_IN_SHELF
     }
     SearchBookPreviewSheet(
         data = previewBook,
@@ -864,7 +875,7 @@ private fun SearchResultFooter(
 
             hasMore -> {
                 Text(
-                    text = stringResource(R.string.search_empty),
+                    text = stringResource(R.string.search_has_more),
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                 )
             }

+ 21 - 4
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -20,7 +20,9 @@ import io.legado.app.domain.usecase.SearchRunEvent
 import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
 import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
@@ -107,7 +109,7 @@ class SearchViewModel(
     fun onIntent(intent: SearchIntent) {
         when (intent) {
             is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw)
-            is SearchIntent.UpdateQuery -> updateQuery(intent.query, showSuggestions = true)
+            is SearchIntent.UpdateQuery -> updateQuery(intent.query, intent.showSuggestions)
             SearchIntent.SubmitSearch -> submitSearch()
             SearchIntent.LoadMore -> loadMore()
             SearchIntent.StopSearch -> stopSearch()
@@ -185,15 +187,20 @@ class SearchViewModel(
                 _uiState.update { state ->
                     val current = state.selectedSourceTypes
                     val next = if (current.contains(intent.type)) {
-                        current - intent.type
+                        (current - intent.type).toImmutableSet()
                     } else {
-                        current + intent.type
+                        (current + intent.type).toImmutableSet()
                     }
                     state.copy(selectedSourceTypes = next)
                 }
                 restartCommittedSearchIfNeeded()
             }
 
+            SearchIntent.ClearAllSourceTypes -> {
+                _uiState.update { it.copy(selectedSourceTypes = persistentSetOf()) }
+                restartCommittedSearchIfNeeded()
+            }
+
             SearchIntent.SelectAllScope -> {
                 val oldScope = searchScope.toString()
                 searchScope.update("")
@@ -310,6 +317,10 @@ class SearchViewModel(
         }
         syncScopeState()
 
+        // Always reset search state so the screen starts clean,
+        // regardless of whether the ViewModel was retained by navigation.
+        clearSearchResults()
+
         val initKey = key?.trim().orEmpty()
         if (initKey.isNotEmpty()) {
             updateQuery(initKey, showSuggestions = false)
@@ -413,6 +424,11 @@ class SearchViewModel(
 
         updateQuery(keyword, showSuggestions = false)
 
+        // Cancel the old search job BEFORE clearing results to prevent
+        // stale Progress events from re-inserting books into the map.
+        searchJob?.cancel()
+        searchJob = null
+
         currentSearchPage = 1
         searchResultBooks.clear()
         _uiState.update {
@@ -546,6 +562,7 @@ class SearchViewModel(
     private fun clearSearchResults() {
         stopSearch(manualStop = true)
         searchResultBooks.clear()
+        queryFlow.value = ""
         _uiState.update {
             it.copy(
                 query = "",
@@ -646,7 +663,7 @@ class SearchViewModel(
             it.copy(
                 scopeDisplay = searchScope.display,
                 scopeDisplayNames = searchScope.displayNames.toImmutableList(),
-                selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
+                selectedScopeSourceUrls = searchScope.sourceUrls.toImmutableSet(),
                 isAllScope = searchScope.isAll(),
                 isSourceScope = searchScope.isSource(),
             )

+ 2 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1648,6 +1648,8 @@
     <string name="search_source_progress"> · 进度 %1$d/%2$d</string>
     <string name="search_empty_scope_disable_precision">%1$s分组搜索结果为空,是否关闭精准搜索?</string>
     <string name="search_empty_scope_switch_all">%1$s分组搜索结果为空,是否切换到全部分组?</string>
+    <string name="search_type">搜索类型</string>
+    <string name="search_has_more">上拉或点击按钮加载更多</string>
     <string name="search_layout_list">列表布局</string>
     <string name="search_layout_source_grouped">来源分组</string>
     <string name="download_cache_config">下载与缓存</string>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -1684,6 +1684,8 @@
     <string name="search_source_progress"> · Progress %1$d/%2$d</string>
     <string name="search_empty_scope_disable_precision">%1$s group search returned no results. Disable precise search?</string>
     <string name="search_empty_scope_switch_all">%1$s group search returned no results. Switch to all groups?</string>
+    <string name="search_type">Search type</string>
+    <string name="search_has_more">Pull up or tap button to load more</string>
     <string name="search_layout_list">List layout</string>
     <string name="search_layout_source_grouped">Source grouped</string>
     <string name="download_cache_config">Download &amp; Cache</string>