fix: 优化搜素逻辑

This commit is contained in:
HapeLee committed 2026-06-07 01:09:24 +08:00
1 parent adc7738627
commit 116762f826
11 files changed
+119 -48

No files matched your search

@@ -191,10 +191,11 @@ class SearchBooksUseCase(
source, source,
keyword, keyword,
page, page,
filter = { name, author -> filter = { name, author, kind ->
matchMode == MatchMode.DEFAULT || matchMode == MatchMode.DEFAULT ||
name.contains(keyword, ignoreCase = true) || 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 equalBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val tagsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>() private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>() private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
var resultLimitReached = false var resultLimitReached = false
private set private set
val count: Int 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 { suspend fun merge(newBooks: List<SearchBook>): SearchBookChange {
if (newBooks.isEmpty()) return SearchBookChange() if (newBooks.isEmpty()) return SearchBookChange()
val upsertBooks = arrayListOf<SearchBook>() val upsertBooks = arrayListOf<SearchBook>()
val removedBookUrls = linkedSetOf<String>() val removedBookUrls = linkedSetOf<String>()
val touchedBuckets = linkedSetOf<LinkedHashMap<SearchBookKey, SearchBook>>()
newBooks.forEach { newBook -> newBooks.forEach { newBook ->
coroutineContext.ensureActive() coroutineContext.ensureActive()
val bucket = classifyBucket(newBook) ?: return@forEach val bucket = classifyBucket(newBook) ?: return@forEach
@@ -255,33 +258,71 @@ class SearchBooksUseCase(
currentBook.addOrigin(newBook.origin) currentBook.addOrigin(newBook.origin)
upsertBooks.add(currentBook) upsertBooks.add(currentBook)
} }
touchedBuckets.add(bucket)
trimSearchBooks()?.let { removed -> trimSearchBooks()?.let { removed ->
removedBookUrls.add(removed.bookUrl) removedBookUrls.add(removed.bookUrl)
upsertBooks.removeAll { it.bookUrl == 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()) return SearchBookChange(upsertBooks, removedBookUrls.toList())
} }
/**
* 将书籍分类到对应的优先级桶中:
* - equalBooks: 书名或作者完全等于搜索词
* - tagsBooks: 分类标签包含搜索词
* - containsBooks: 书名或作者包含搜索词(非精确匹配)
* - otherBooks: 其他结果(仅 DEFAULT 模式保留)
*/
private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? { private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? {
return when { return when {
book.name.equals(keyword, ignoreCase = true) || book.name.equals(keyword, ignoreCase = true) ||
book.author.equals(keyword, ignoreCase = true) -> equalBooks 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.name.contains(keyword, ignoreCase = true) ||
book.author.contains( book.author.contains(keyword, ignoreCase = true) -> {
keyword, if (matchMode == MatchMode.EXACT) null else containsBooks
ignoreCase = true }
) -> if (matchMode == MatchMode.EXACT) null else containsBooks
matchMode != MatchMode.DEFAULT -> null matchMode != MatchMode.DEFAULT -> null
else -> otherBooks 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? { private fun trimSearchBooks(): SearchBook? {
if (count <= MAX_RETAINED_SEARCH_RESULTS) return null if (count <= MAX_RETAINED_SEARCH_RESULTS) return null
resultLimitReached = true resultLimitReached = true
return removeLast(otherBooks) return removeLast(otherBooks)
?: removeLast(tagsBooks)
?: removeLowestOrigin(containsBooks) ?: removeLowestOrigin(containsBooks)
?: removeLowestOrigin(equalBooks) ?: removeLowestOrigin(equalBooks)
} }
@@ -40,7 +40,7 @@ object BookList {
body: String?, body: String?,
isSearch: Boolean = true, isSearch: Boolean = true,
isRedirect: Boolean = false, isRedirect: Boolean = false,
filter: ((name: String, author: String) -> Boolean)? = null, filter: ((name: String, author: String, kind: String?) -> Boolean)? = null,
shouldBreak: ((size: Int) -> Boolean)? = null shouldBreak: ((size: Int) -> Boolean)? = null
): ArrayList<SearchBook> { ): ArrayList<SearchBook> {
body ?: throw NoStackTraceException( body ?: throw NoStackTraceException(
@@ -159,7 +159,7 @@ object BookList {
baseUrl: String, baseUrl: String,
variable: String?, variable: String?,
isRedirect: Boolean, isRedirect: Boolean,
filter: ((name: String, author: String) -> Boolean)? filter: ((name: String, author: String, kind: String?) -> Boolean)?
): SearchBook? { ): SearchBook? {
val book = Book(variable = variable) val book = Book(variable = variable)
book.bookUrl = if (isRedirect) { book.bookUrl = if (isRedirect) {
@@ -181,7 +181,7 @@ object BookList {
baseUrl, baseUrl,
false false
) )
if (filter?.invoke(book.name, book.author) == false) { if (filter?.invoke(book.name, book.author, book.kind) == false) {
return null return null
} }
if (book.name.isNotBlank()) { if (book.name.isNotBlank()) {
@@ -198,7 +198,7 @@ object BookList {
baseUrl: String, baseUrl: String,
variable: String?, variable: String?,
log: Boolean, log: Boolean,
filter: ((name: String, author: String) -> Boolean)?, filter: ((name: String, author: String, kind: String?) -> Boolean)?,
ruleName: List<AnalyzeRule.SourceRule>, ruleName: List<AnalyzeRule.SourceRule>,
ruleBookUrl: List<AnalyzeRule.SourceRule>, ruleBookUrl: List<AnalyzeRule.SourceRule>,
ruleAuthor: List<AnalyzeRule.SourceRule>, ruleAuthor: List<AnalyzeRule.SourceRule>,
@@ -224,9 +224,6 @@ object BookList {
Debug.log(bookSource.bookSourceUrl, "┌获取作者", log) Debug.log(bookSource.bookSourceUrl, "┌获取作者", log)
searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor)) searchBook.author = BookHelp.formatBookAuthor(analyzeRule.getString(ruleAuthor))
Debug.log(bookSource.bookSourceUrl, "└${searchBook.author}", log) Debug.log(bookSource.bookSourceUrl, "└${searchBook.author}", log)
if (filter?.invoke(searchBook.name, searchBook.author) == false) {
return null
}
coroutineContext.ensureActive() coroutineContext.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取分类", log) Debug.log(bookSource.bookSourceUrl, "┌获取分类", log)
try { try {
@@ -236,6 +233,9 @@ object BookList {
coroutineContext.ensureActive() coroutineContext.ensureActive()
Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log) Debug.log(bookSource.bookSourceUrl, "└${e.localizedMessage}", log)
} }
if (filter?.invoke(searchBook.name, searchBook.author, searchBook.kind) == false) {
return null
}
coroutineContext.ensureActive() coroutineContext.ensureActive()
Debug.log(bookSource.bookSourceUrl, "┌获取字数", log) Debug.log(bookSource.bookSourceUrl, "┌获取字数", log)
try { try {
@@ -51,7 +51,7 @@ object WebBook {
bookSource: BookSource, bookSource: BookSource,
key: String, key: String,
page: Int? = 1, page: Int? = 1,
filter: ((name: String, author: String) -> Boolean)? = null, filter: ((name: String, author: String, kind: String?) -> Boolean)? = null,
shouldBreak: ((size: Int) -> Boolean)? = null shouldBreak: ((size: Int) -> Boolean)? = null
): ArrayList<SearchBook> { ): ArrayList<SearchBook> {
val searchUrl = bookSource.searchUrl val searchUrl = bookSource.searchUrl
@@ -481,7 +481,7 @@ object WebBook {
coroutineContext.ensureActive() coroutineContext.ensureActive()
searchBookAwait( searchBookAwait(
bookSource, name, bookSource, name,
filter = { fName, fAuthor -> fName == name && fAuthor == author }, filter = { fName, fAuthor, _ -> fName == name && fAuthor == author },
shouldBreak = { it > 0 } shouldBreak = { it > 0 }
).firstOrNull()?.let { searchBook -> ).firstOrNull()?.let { searchBook ->
coroutineContext.ensureActive() coroutineContext.ensureActive()
@@ -275,7 +275,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
val loadWordCount = AppConfig.changeSourceLoadWordCount val loadWordCount = AppConfig.changeSourceLoadWordCount
val resultBooks = WebBook.searchBookAwait( val resultBooks = WebBook.searchBookAwait(
source, name, source, name,
filter = { fName, fAuthor -> filter = { fName, fAuthor, _ ->
fName == name && (!checkAuthor || fAuthor.contains(author)) fName == name && (!checkAuthor || fAuthor.contains(author))
}) })
resultBooks.forEach { searchBook -> resultBooks.forEach { searchBook ->
@@ -16,7 +16,11 @@ class SearchActivity : BaseComposeActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) { override fun onNewIntent(intent: Intent) {
@@ -25,16 +29,6 @@ class SearchActivity : BaseComposeActivity() {
dispatchInit(intent) dispatchInit(intent)
} }
override fun onResume() {
super.onResume()
viewModel.onIntent(SearchIntent.ResumeEngine)
}
override fun onPause() {
viewModel.onIntent(SearchIntent.PauseEngine)
super.onPause()
}
@Composable @Composable
override fun Content() { override fun Content() {
SearchScreen( SearchScreen(
@@ -8,7 +8,9 @@ import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.model.MatchMode import io.legado.app.domain.model.MatchMode
import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@Stable @Stable
data class SearchResultItemUi( data class SearchResultItemUi(
@@ -27,7 +29,7 @@ data class SearchUiState(
val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(), val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
val scopeDisplay: String = "", val scopeDisplay: String = "",
val scopeDisplayNames: ImmutableList<String> = persistentListOf(), val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
val selectedScopeSourceUrls: Set<String> = emptySet(), val selectedScopeSourceUrls: ImmutableSet<String> = persistentSetOf(),
val isAllScope: Boolean = true, val isAllScope: Boolean = true,
val isSourceScope: Boolean = false, val isSourceScope: Boolean = false,
val matchMode: MatchMode = MatchMode.DEFAULT, val matchMode: MatchMode = MatchMode.DEFAULT,
@@ -36,7 +38,7 @@ data class SearchUiState(
val hasMore: Boolean = true, val hasMore: Boolean = true,
val processedSources: Int = 0, val processedSources: Int = 0,
val totalSources: Int = 0, val totalSources: Int = 0,
val selectedSourceTypes: Set<Int> = emptySet(), val selectedSourceTypes: ImmutableSet<Int> = persistentSetOf(),
val showScopeSheet: Boolean = false, val showScopeSheet: Boolean = false,
val showSettingsSheet: Boolean = false, val showSettingsSheet: Boolean = false,
val showClearHistoryDialog: Boolean = false, val showClearHistoryDialog: Boolean = false,
@@ -63,7 +65,7 @@ data class SearchEmptyScopeAction(
sealed interface SearchIntent { sealed interface SearchIntent {
data class Initialize(val key: String?, val scopeRaw: String?) : 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 SubmitSearch : SearchIntent
data object LoadMore : SearchIntent data object LoadMore : SearchIntent
data object StopSearch : SearchIntent data object StopSearch : SearchIntent
@@ -84,6 +86,7 @@ sealed interface SearchIntent {
data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
data class ToggleSourceType(val type: Int) : SearchIntent data class ToggleSourceType(val type: Int) : SearchIntent
data object ClearAllSourceTypes : SearchIntent
data object SelectAllScope : SearchIntent data object SelectAllScope : SearchIntent
data class ToggleScopeGroup(val groupName: String) : SearchIntent data class ToggleScopeGroup(val groupName: String) : SearchIntent
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
@@ -120,6 +120,7 @@ data class SearchScope(private var scope: String) {
this.scope = stringBuilder.toString() this.scope = stringBuilder.toString()
} }
stateLiveData.postValue(this.scope) stateLiveData.postValue(this.scope)
save()
} }
/** /**
@@ -108,6 +108,7 @@ fun SearchScreen(
var previewBook by remember { mutableStateOf<SearchBook?>(null) } var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) } var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val groupedListState = rememberLazyListState()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) } var queryInput by rememberSaveable { mutableStateOf(state.query) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) } 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) { LaunchedEffect(state.query) {
if (state.query != queryInput) { if (state.query != queryInput) {
queryInput = state.query queryInput = state.query
@@ -150,20 +159,19 @@ fun SearchScreen(
LaunchedEffect( LaunchedEffect(
shouldLoadMore, shouldLoadMore,
shouldLoadMoreGrouped,
state.isSearching, state.isSearching,
state.hasMore, state.hasMore,
state.isManualStop, state.isManualStop,
state.showSuggestions, state.showSuggestions,
isSourceGroupedMode, isSourceGroupedMode,
) { ) {
if ( val readyToLoad = !state.isSearching &&
shouldLoadMore &&
!state.isSearching &&
state.hasMore && state.hasMore &&
!state.isManualStop && !state.isManualStop &&
!state.showSuggestions && !state.showSuggestions
!isSourceGroupedMode val nearEnd = if (isSourceGroupedMode) shouldLoadMoreGrouped else shouldLoadMore
) { if (readyToLoad && nearEnd) {
viewModel.onIntent(SearchIntent.LoadMore) viewModel.onIntent(SearchIntent.LoadMore)
} }
} }
@@ -416,6 +424,7 @@ fun SearchScreen(
) { isSourceGrouped -> ) { isSourceGrouped ->
if (isSourceGrouped) { if (isSourceGrouped) {
LazyColumn( LazyColumn(
state = groupedListState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPaddingOnlyVertical( contentPadding = adaptiveContentPaddingOnlyVertical(
top = 48.dp, top = 48.dp,
@@ -477,11 +486,11 @@ fun SearchScreen(
) { ) {
itemsIndexed( itemsIndexed(
items = state.results, items = state.results,
key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" } key = { _, item -> "${item.book.origin}:${item.book.bookUrl}" }
) { index, item -> ) { index, item ->
val sharedCoverKey = bookCoverSharedElementKey( val sharedCoverKey = bookCoverSharedElementKey(
item.book.bookUrl, item.book.bookUrl,
"search:${item.book.origin}:$index" "search:${item.book.origin}"
) )
SearchBookListItem( SearchBookListItem(
book = item.book, book = item.book,
@@ -636,7 +645,7 @@ fun SearchScreen(
AppIcon(Icons.Default.Layers, contentDescription = null) AppIcon(Icons.Default.Layers, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
AppText( AppText(
text = "搜索类型", text = stringResource(R.string.search_type),
style = LegadoTheme.typography.titleSmall 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 -> val previewShelfState = previewBook?.let { book ->
state.results.find { it.book.bookUrl == book.bookUrl }?.shelfState resultsByBookUrl[book.bookUrl]?.shelfState ?: BookShelfState.NOT_IN_SHELF
?: BookShelfState.NOT_IN_SHELF
} }
SearchBookPreviewSheet( SearchBookPreviewSheet(
data = previewBook, data = previewBook,
@@ -864,7 +875,7 @@ private fun SearchResultFooter(
hasMore -> { hasMore -> {
Text( Text(
text = stringResource(R.string.search_empty), text = stringResource(R.string.search_has_more),
color = LegadoTheme.colorScheme.onSurfaceVariant, color = LegadoTheme.colorScheme.onSurfaceVariant,
) )
} }
@@ -20,7 +20,9 @@ import io.legado.app.domain.usecase.SearchRunEvent
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -107,7 +109,7 @@ class SearchViewModel(
fun onIntent(intent: SearchIntent) { fun onIntent(intent: SearchIntent) {
when (intent) { when (intent) {
is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw) 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.SubmitSearch -> submitSearch()
SearchIntent.LoadMore -> loadMore() SearchIntent.LoadMore -> loadMore()
SearchIntent.StopSearch -> stopSearch() SearchIntent.StopSearch -> stopSearch()
@@ -185,15 +187,20 @@ class SearchViewModel(
_uiState.update { state -> _uiState.update { state ->
val current = state.selectedSourceTypes val current = state.selectedSourceTypes
val next = if (current.contains(intent.type)) { val next = if (current.contains(intent.type)) {
current - intent.type (current - intent.type).toImmutableSet()
} else { } else {
current + intent.type (current + intent.type).toImmutableSet()
} }
state.copy(selectedSourceTypes = next) state.copy(selectedSourceTypes = next)
} }
restartCommittedSearchIfNeeded() restartCommittedSearchIfNeeded()
} }
SearchIntent.ClearAllSourceTypes -> {
_uiState.update { it.copy(selectedSourceTypes = persistentSetOf()) }
restartCommittedSearchIfNeeded()
}
SearchIntent.SelectAllScope -> { SearchIntent.SelectAllScope -> {
val oldScope = searchScope.toString() val oldScope = searchScope.toString()
searchScope.update("") searchScope.update("")
@@ -310,6 +317,10 @@ class SearchViewModel(
} }
syncScopeState() 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() val initKey = key?.trim().orEmpty()
if (initKey.isNotEmpty()) { if (initKey.isNotEmpty()) {
updateQuery(initKey, showSuggestions = false) updateQuery(initKey, showSuggestions = false)
@@ -413,6 +424,11 @@ class SearchViewModel(
updateQuery(keyword, showSuggestions = false) 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 currentSearchPage = 1
searchResultBooks.clear() searchResultBooks.clear()
_uiState.update { _uiState.update {
@@ -546,6 +562,7 @@ class SearchViewModel(
private fun clearSearchResults() { private fun clearSearchResults() {
stopSearch(manualStop = true) stopSearch(manualStop = true)
searchResultBooks.clear() searchResultBooks.clear()
queryFlow.value = ""
_uiState.update { _uiState.update {
it.copy( it.copy(
query = "", query = "",
@@ -646,7 +663,7 @@ class SearchViewModel(
it.copy( it.copy(
scopeDisplay = searchScope.display, scopeDisplay = searchScope.display,
scopeDisplayNames = searchScope.displayNames.toImmutableList(), scopeDisplayNames = searchScope.displayNames.toImmutableList(),
selectedScopeSourceUrls = searchScope.sourceUrls.toSet(), selectedScopeSourceUrls = searchScope.sourceUrls.toImmutableSet(),
isAllScope = searchScope.isAll(), isAllScope = searchScope.isAll(),
isSourceScope = searchScope.isSource(), isSourceScope = searchScope.isSource(),
) )
@@ -1648,6 +1648,8 @@
<string name="search_source_progress"> · 进度 %1$d/%2$d</string> <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_disable_precision">%1$s分组搜索结果为空,是否关闭精准搜索?</string>
<string name="search_empty_scope_switch_all">%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_list">列表布局</string>
<string name="search_layout_source_grouped">来源分组</string> <string name="search_layout_source_grouped">来源分组</string>
<string name="download_cache_config">下载与缓存</string> <string name="download_cache_config">下载与缓存</string>
+2
View File
@@ -1684,6 +1684,8 @@
<string name="search_source_progress"> · Progress %1$d/%2$d</string> <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_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_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_list">List layout</string>
<string name="search_layout_source_grouped">Source grouped</string> <string name="search_layout_source_grouped">Source grouped</string>
<string name="download_cache_config">Download &amp; Cache</string> <string name="download_cache_config">Download &amp; Cache</string>