ソースを参照

feat: 书架管理之优化

HapeLee 1 ヶ月 前
親
コミット
d47406fbfa

+ 7 - 0
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -702,6 +702,13 @@ interface BookDao {
     @Query("SELECT * FROM books WHERE bookUrl = :bookUrl")
     @Query("SELECT * FROM books WHERE bookUrl = :bookUrl")
     fun getBook(bookUrl: String): Book?
     fun getBook(bookUrl: String): Book?
 
 
+    /**
+     * 按 bookUrl 批量取书,供跨分组多选解析选中实体(选中项可能不在当前分组列表里)。
+     * 调用方负责分批,避免单次绑定参数超过 SQLite 上限。
+     */
+    @Query("SELECT * FROM books WHERE bookUrl IN (:bookUrls)")
+    suspend fun getBooksByUrls(bookUrls: Set<String>): List<Book>
+
     @Query(
     @Query(
         """
         """
         SELECT
         SELECT

+ 19 - 0
app/src/main/java/io/legado/app/data/repository/BookRepository.kt

@@ -17,6 +17,11 @@ class BookRepository(
     private val bookChapterDao: BookChapterDao,
     private val bookChapterDao: BookChapterDao,
     private val appDb: AppDatabase,
     private val appDb: AppDatabase,
 ) {
 ) {
+
+    private companion object {
+        const val SQLITE_MAX_BIND_PARAMETERS = 900
+    }
+
     fun flowBook(bookUrl: String): Flow<Book?> {
     fun flowBook(bookUrl: String): Flow<Book?> {
         return bookDao.flowGetBook(bookUrl)
         return bookDao.flowGetBook(bookUrl)
     }
     }
@@ -57,6 +62,20 @@ class BookRepository(
         }
         }
     }
     }
 
 
+    /**
+     * 批量取书,用于把跨分组选中的 bookUrl 解析成实体。
+     * 分批是因为 `IN (:bookUrls)` 会展开成绑定参数,单次上限由 SQLite 决定(默认 999)。
+     */
+    suspend fun getBooksByUrls(bookUrls: Set<String>): List<Book> = withContext(Dispatchers.IO) {
+        if (bookUrls.isEmpty()) return@withContext emptyList()
+        if (bookUrls.size <= SQLITE_MAX_BIND_PARAMETERS) {
+            return@withContext bookDao.getBooksByUrls(bookUrls)
+        }
+        bookUrls.chunked(SQLITE_MAX_BIND_PARAMETERS).flatMap { chunk ->
+            bookDao.getBooksByUrls(chunk.toSet())
+        }
+    }
+
     suspend fun getShelfBookSummaries(): List<ShelfBookSummary> {
     suspend fun getShelfBookSummaries(): List<ShelfBookSummary> {
         return withContext(Dispatchers.IO) {
         return withContext(Dispatchers.IO) {
             bookDao.getShelfBookSummaries()
             bookDao.getShelfBookSummaries()

+ 49 - 57
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -182,9 +182,9 @@ private fun BookshelfManageScreen(
     var moreMenuBookUrl by remember { mutableStateOf<String?>(null) }
     var moreMenuBookUrl by remember { mutableStateOf<String?>(null) }
     var pendingDeleteBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
     var pendingDeleteBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
     var fabMenuExpanded by rememberSaveable { mutableStateOf(false) }
     var fabMenuExpanded by rememberSaveable { mutableStateOf(false) }
-    var pendingExportBookUrl by remember { mutableStateOf<String?>(null) }
-    var pendingExportSelection by remember { mutableStateOf<Set<String>>(emptySet()) }
-    var exportSheetBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
+    // 下面两处待导出集合直接存实体而不是 url:跨分组选中的书不在当前列表里,按 url 回查会漏掉
+    var pendingExportBooks by remember { mutableStateOf<List<Book>>(emptyList()) }
+    var exportSheetBooks by remember { mutableStateOf<List<Book>>(emptyList()) }
     var customExportPath by remember { mutableStateOf("") }
     var customExportPath by remember { mutableStateOf("") }
     var customExportBook by remember { mutableStateOf<Book?>(null) }
     var customExportBook by remember { mutableStateOf<Book?>(null) }
     var customExportAllChapter by remember { mutableStateOf(false) }
     var customExportAllChapter by remember { mutableStateOf(false) }
@@ -196,7 +196,8 @@ private fun BookshelfManageScreen(
     var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) }
     var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) }
     var isSearchMode by remember { mutableStateOf(false) }
     var isSearchMode by remember { mutableStateOf(false) }
     var searchKey by remember { mutableStateOf("") }
     var searchKey by remember { mutableStateOf("") }
-    var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
+    // 选中集合由 ViewModel 持有:切分组、进详情再返回都不清空,因此可以跨分组多选
+    val selectedBookUrls: Set<String> = state.selectedBookUrls
     var deleteOriginalBookFile by remember { mutableStateOf(state.deleteBookOriginal) }
     var deleteOriginalBookFile by remember { mutableStateOf(state.deleteBookOriginal) }
     val exportBookPathKey = remember { "exportBookPath" }
     val exportBookPathKey = remember { "exportBookPath" }
     val exportTypes = remember { arrayListOf("txt", "epub") }
     val exportTypes = remember { arrayListOf("txt", "epub") }
@@ -212,10 +213,6 @@ private fun BookshelfManageScreen(
     val noGroupText = stringResource(R.string.no_group)
     val noGroupText = stringResource(R.string.no_group)
     val exportFileNameHintText = stringResource(R.string.export_file_name_template_hint)
     val exportFileNameHintText = stringResource(R.string.export_file_name_template_hint)
     val exportFileNameHelpText = stringResource(R.string.export_file_name_template_help)
     val exportFileNameHelpText = stringResource(R.string.export_file_name_template_help)
-    val booksByUrl = remember(state.books) { state.books.associateBy { it.bookUrl } }
-    val exportSheetBooks = remember(exportSheetBookUrls, booksByUrl) {
-        exportSheetBookUrls.mapNotNull(booksByUrl::get)
-    }
     val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } }
     val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } }
 
 
     val groupNameResolver: (Book) -> String = remember(userGroups, noGroupText) {
     val groupNameResolver: (Book) -> String = remember(userGroups, noGroupText) {
@@ -254,29 +251,31 @@ private fun BookshelfManageScreen(
         )
         )
     }
     }
     val inSelectionMode = selectedBookUrls.isNotEmpty()
     val inSelectionMode = selectedBookUrls.isNotEmpty()
-    val hasLocalBookInDeleteTarget = remember(state.books, pendingDeleteBookUrls) {
-        state.books.any { pendingDeleteBookUrls.contains(it.bookUrl) && it.isLocal }
+    // 跨分组选中的书里,有多少不在当前(分组 + 搜索)列表里
+    val selectedOutOfViewCount = remember(selectedBookUrls, filteredBooks) {
+        val inViewCount = filteredBooks.count { selectedBookUrls.contains(it.bookUrl) }
+        selectedBookUrls.size - inViewCount
+    }
+    // 跨分组时 selectedIds 可能多于当前列表条目,"已选/总数" 会失真,这里自己出文案
+    val selectionTitle = if (selectedOutOfViewCount > 0) {
+        "已选 ${selectedBookUrls.size} 本($selectedOutOfViewCount 本不在当前列表)"
+    } else {
+        "已选 ${selectedBookUrls.size}/${filteredBooks.size}"
+    }
+    val hasLocalBookInDeleteTarget = remember(state.selectedBooks, pendingDeleteBookUrls) {
+        state.selectedBooks.any { pendingDeleteBookUrls.contains(it.bookUrl) && it.isLocal }
     }
     }
     val clearSelection = {
     val clearSelection = {
-        selectedBookUrls = emptySet()
+        viewModel.dispatch(BookshelfManageScreenIntent.ClearBookSelection)
     }
     }
     val toggleBookSelection: (Book) -> Unit = { book ->
     val toggleBookSelection: (Book) -> Unit = { book ->
-        selectedBookUrls = if (selectedBookUrls.contains(book.bookUrl)) {
-            selectedBookUrls - book.bookUrl
-        } else {
-            selectedBookUrls + book.bookUrl
-        }
+        viewModel.dispatch(BookshelfManageScreenIntent.ToggleBookSelection(book.bookUrl))
     }
     }
 
 
-    BackHandler(enabled = selectedBookUrls.isNotEmpty()) {
+    BackHandler(enabled = inSelectionMode) {
         clearSelection()
         clearSelection()
     }
     }
 
 
-    LaunchedEffect(state.books) {
-        val visibleBookUrls = booksByUrl.keys
-        selectedBookUrls = selectedBookUrls.intersect(visibleBookUrls)
-    }
-
     val exportDir = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
     val exportDir = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
         var isReadyPath = false
         var isReadyPath = false
         var dirPath = ""
         var dirPath = ""
@@ -294,26 +293,20 @@ private fun BookshelfManageScreen(
             }
             }
         }
         }
         if (!isReadyPath) return@rememberLauncherForActivityResult
         if (!isReadyPath) return@rememberLauncherForActivityResult
-        if (pendingExportSelection.isNotEmpty()) {
-            pendingExportSelection.forEach { bookUrl ->
-                booksByUrl[bookUrl]?.let { book ->
-                    startExport(context, dirPath, book, state.exportConfig.exportType)
-                }
-            }
-            return@rememberLauncherForActivityResult
-        }
-        val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
-        val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
-        if (state.exportConfig.isCustomEpubExportEnabled) {
+        val pendingBooks = pendingExportBooks
+        if (pendingBooks.isEmpty()) return@rememberLauncherForActivityResult
+        if (pendingBooks.size == 1 && state.exportConfig.isCustomEpubExportEnabled) {
             customExportPath = dirPath
             customExportPath = dirPath
-            customExportBook = book
+            customExportBook = pendingBooks.single()
             customExportAllChapter = false
             customExportAllChapter = false
             customEpubScopeInput = ""
             customEpubScopeInput = ""
             customEpubScopeError = null
             customEpubScopeError = null
             customEpubSizeInput = "1"
             customEpubSizeInput = "1"
             customEpisodeExportNameInput = state.exportConfig.episodeExportFileName
             customEpisodeExportNameInput = state.exportConfig.episodeExportFileName
             showCustomExportDialog = true
             showCustomExportDialog = true
-        } else {
+            return@rememberLauncherForActivityResult
+        }
+        pendingBooks.forEach { book ->
             startExport(context, dirPath, book, state.exportConfig.exportType)
             startExport(context, dirPath, book, state.exportConfig.exportType)
         }
         }
     }
     }
@@ -330,12 +323,8 @@ private fun BookshelfManageScreen(
         }
         }
     }
     }
 
 
-    fun selectExportFolder(
-        bookUrl: String? = null,
-        selection: Set<String> = emptySet()
-    ) {
-        pendingExportBookUrl = bookUrl
-        pendingExportSelection = selection
+    fun selectExportFolder(books: List<Book> = emptyList()) {
+        pendingExportBooks = books
         showFilePickerSheet = true
         showFilePickerSheet = true
     }
     }
 
 
@@ -343,10 +332,7 @@ private fun BookshelfManageScreen(
         if (books.isEmpty()) return
         if (books.isEmpty()) return
         val path = ACache.get().getAsString(exportBookPathKey)
         val path = ACache.get().getAsString(exportBookPathKey)
         if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
         if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
-            when (books.size) {
-                1 -> selectExportFolder(books.single().bookUrl)
-                else -> selectExportFolder(selection = books.mapTo(linkedSetOf()) { it.bookUrl })
-            }
+            selectExportFolder(books)
         } else if (state.exportConfig.isCustomEpubExportEnabled && books.size == 1) {
         } else if (state.exportConfig.isCustomEpubExportEnabled && books.size == 1) {
             customExportPath = path
             customExportPath = path
             customExportBook = books.single()
             customExportBook = books.single()
@@ -364,9 +350,9 @@ private fun BookshelfManageScreen(
     }
     }
 
 
     fun showExportSheetFor(books: List<Book>) {
     fun showExportSheetFor(books: List<Book>) {
-        exportSheetBookUrls = books.mapTo(linkedSetOf()) { it.bookUrl }
+        exportSheetBooks = books
         showExportSettings = false
         showExportSettings = false
-        showExportSheet = exportSheetBookUrls.isNotEmpty()
+        showExportSheet = exportSheetBooks.isNotEmpty()
     }
     }
 
 
     fun exportAll() {
     fun exportAll() {
@@ -374,10 +360,10 @@ private fun BookshelfManageScreen(
     }
     }
 
 
     fun exportSelected() {
     fun exportSelected() {
-        showExportSheetFor(selectedBookUrls.mapNotNull(booksByUrl::get))
+        showExportSheetFor(state.selectedBooks)
     }
     }
     fun resolveSelectionGroupMask(): Long {
     fun resolveSelectionGroupMask(): Long {
-        val targetBooks = selectedBookUrls.mapNotNull { booksByUrl[it] }
+        val targetBooks = state.selectedBooks
         if (targetBooks.isEmpty()) return 0L
         if (targetBooks.isEmpty()) return 0L
         val firstGroup = targetBooks.first().group.coerceAtLeast(0L)
         val firstGroup = targetBooks.first().group.coerceAtLeast(0L)
         return if (targetBooks.all { it.group == firstGroup }) firstGroup else 0L
         return if (targetBooks.all { it.group == firstGroup }) firstGroup else 0L
@@ -387,14 +373,21 @@ private fun BookshelfManageScreen(
             Icons.Default.SelectAll,
             Icons.Default.SelectAll,
             stringResource(R.string.select_all)
             stringResource(R.string.select_all)
         ) {
         ) {
-            selectedBookUrls = filteredBooks.mapTo(hashSetOf()) { it.bookUrl }
+            viewModel.dispatch(
+                BookshelfManageScreenIntent.SelectVisibleBooks(
+                    filteredBooks.mapTo(hashSetOf()) { it.bookUrl }
+                )
+            )
         },
         },
         FabMenuItem(
         FabMenuItem(
             Icons.Default.Refresh,
             Icons.Default.Refresh,
             stringResource(R.string.revert_selection)
             stringResource(R.string.revert_selection)
         ) {
         ) {
-            val filteredUrls = filteredBooks.map { it.bookUrl }.toSet()
-            selectedBookUrls = (selectedBookUrls - filteredUrls) + (filteredUrls - selectedBookUrls)
+            viewModel.dispatch(
+                BookshelfManageScreenIntent.InvertVisibleBooks(
+                    filteredBooks.mapTo(hashSetOf()) { it.bookUrl }
+                )
+            )
         },
         },
         FabMenuItem(
         FabMenuItem(
             Icons.Default.Download,
             Icons.Default.Download,
@@ -459,12 +452,11 @@ private fun BookshelfManageScreen(
         }
         }
     }
     }
     ListScaffold(
     ListScaffold(
-        title = if (inSelectionMode) {
-            "已选 ${selectedBookUrls.size}/${filteredBooks.size}"
-        } else {
-            state.groupName ?: stringResource(R.string.offline_cache)
-        },
+        title = state.groupName ?: stringResource(R.string.offline_cache),
         state = listUiState,
         state = listUiState,
+        selectionTitle = selectionTitle,
+        // 跨分组多选:选中后还要能继续搜索、切分组去挑别的书,所以顶栏 actions 不跟着选择态收起
+        keepActionsInSelection = true,
         onBackClick = onBackClick,
         onBackClick = onBackClick,
         onSearchToggle = { active ->
         onSearchToggle = { active ->
             isSearchMode = active
             isSearchMode = active

+ 97 - 6
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt

@@ -14,6 +14,8 @@ import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.gateway.BookExportSettingsGateway
 import io.legado.app.domain.gateway.BookExportSettingsGateway
+import io.legado.app.domain.gateway.BookshelfSettingsGateway
+import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.model.settings.BookExportSettings
 import io.legado.app.domain.model.settings.BookExportSettings
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.BatchChangeSourceCandidate
 import io.legado.app.domain.usecase.BatchChangeSourceCandidate
@@ -31,13 +33,16 @@ import io.legado.app.help.book.removeType
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.model.CacheBook
 import io.legado.app.model.CacheBook
 import io.legado.app.service.ExportBookService
 import io.legado.app.service.ExportBookService
-import io.legado.app.domain.gateway.BookshelfSettingsGateway
-import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
-import org.koin.core.context.GlobalContext
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.main.bookshelf.toLightBook
 import io.legado.app.ui.main.bookshelf.toLightBook
 import io.legado.app.utils.cnCompare
 import io.legado.app.utils.cnCompare
 import io.legado.app.utils.move
 import io.legado.app.utils.move
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
@@ -48,17 +53,31 @@ import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
+import org.koin.core.context.GlobalContext
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ConcurrentHashMap
 import kotlin.math.max
 import kotlin.math.max
 import kotlin.math.min
 import kotlin.math.min
 
 
 private val bookshelfSettingsGateway get() = GlobalContext.get().get<BookshelfSettingsGateway>()
 private val bookshelfSettingsGateway get() = GlobalContext.get().get<BookshelfSettingsGateway>()
 
 
+/**
+ * 全选:只覆盖当前列表内的选择,其他分组里已选的书保留(跨分组多选的关键)。
+ */
+internal fun selectionOfSelectVisible(
+    selected: Set<String>,
+    visibleBookUrls: Set<String>
+): Set<String> = (selected - visibleBookUrls) + visibleBookUrls
+
+/**
+ * 反选:只翻转当前列表内的选择,其他分组里已选的书不受影响。
+ */
+internal fun selectionOfInvertVisible(
+    selected: Set<String>,
+    visibleBookUrls: Set<String>
+): Set<String> = (selected - visibleBookUrls) + (visibleBookUrls - selected)
+
 data class BookshelfManageScreenExportConfig(
 data class BookshelfManageScreenExportConfig(
     val exportUseReplace: Boolean = true,
     val exportUseReplace: Boolean = true,
     val enableCustomExport: Boolean = false,
     val enableCustomExport: Boolean = false,
@@ -80,6 +99,17 @@ data class BookshelfManageScreenUiState(
     val groupName: String? = null,
     val groupName: String? = null,
     val groupList: List<BookGroup> = emptyList(),
     val groupList: List<BookGroup> = emptyList(),
     val books: List<Book> = emptyList(),
     val books: List<Book> = emptyList(),
+    /**
+     * 跨分组选中的书(只以 bookUrl 为准,不按分组分桶)。
+     * 切换分组、进入书籍详情再返回都不会清空它,批量操作因此可以横跨多个分组;
+     * 只有显式清除或书籍本身被删除才会移除。
+     */
+    val selectedBookUrls: ImmutableSet<String> = persistentSetOf(),
+    /**
+     * [selectedBookUrls] 解析出的实体快照,包含不在当前分组列表里的书。
+     * 导出 / 分组掩码 / 删除确认需要实体而不是 url,所以这里从库里回查一次。
+     */
+    val selectedBooks: ImmutableList<Book> = persistentListOf(),
     val bookSort: Int = bookshelfSettingsGateway.currentSettings.bookshelfSort,
     val bookSort: Int = bookshelfSettingsGateway.currentSettings.bookshelfSort,
     val bookSortOrder: Int = bookshelfSettingsGateway.currentSettings.bookshelfSortOrder,
     val bookSortOrder: Int = bookshelfSettingsGateway.currentSettings.bookshelfSortOrder,
     val isDownloadRunning: Boolean = false,
     val isDownloadRunning: Boolean = false,
@@ -106,6 +136,10 @@ sealed interface BookshelfManageScreenIntent {
     data class ToggleBookDownload(val book: Book) : BookshelfManageScreenIntent
     data class ToggleBookDownload(val book: Book) : BookshelfManageScreenIntent
     data class DeleteBookDownload(val bookUrl: String) : BookshelfManageScreenIntent
     data class DeleteBookDownload(val bookUrl: String) : BookshelfManageScreenIntent
     data class ClearBookCache(val book: Book) : BookshelfManageScreenIntent
     data class ClearBookCache(val book: Book) : BookshelfManageScreenIntent
+    data class ToggleBookSelection(val bookUrl: String) : BookshelfManageScreenIntent
+    data class SelectVisibleBooks(val visibleBookUrls: Set<String>) : BookshelfManageScreenIntent
+    data class InvertVisibleBooks(val visibleBookUrls: Set<String>) : BookshelfManageScreenIntent
+    data object ClearBookSelection : BookshelfManageScreenIntent
     data class MoveBooksToGroup(val bookUrls: Set<String>, val groupId: Long) : BookshelfManageScreenIntent
     data class MoveBooksToGroup(val bookUrls: Set<String>, val groupId: Long) : BookshelfManageScreenIntent
     data class DeleteBooks(val bookUrls: Set<String>, val deleteOriginal: Boolean) : BookshelfManageScreenIntent
     data class DeleteBooks(val bookUrls: Set<String>, val deleteOriginal: Boolean) : BookshelfManageScreenIntent
     data class ClearCachesForBooks(val bookUrls: Set<String>) : BookshelfManageScreenIntent
     data class ClearCachesForBooks(val bookUrls: Set<String>) : BookshelfManageScreenIntent
@@ -188,6 +222,7 @@ class BookshelfManageScreenViewModel(
     private val downloadFailureMessages = ConcurrentHashMap<String, String>()
     private val downloadFailureMessages = ConcurrentHashMap<String, String>()
     private var booksJob: Job? = null
     private var booksJob: Job? = null
     private var groupsJob: Job? = null
     private var groupsJob: Job? = null
+    private var selectionResolveJob: Job? = null
     private var cacheLoadJob: Job? = null
     private var cacheLoadJob: Job? = null
     private var observersStarted = false
     private var observersStarted = false
     private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet<String>()
     private val pendingDownloadStatusBookUrls = ConcurrentHashMap.newKeySet<String>()
@@ -219,6 +254,16 @@ class BookshelfManageScreenViewModel(
             is BookshelfManageScreenIntent.ToggleBookDownload -> toggleBookDownload(intent.book)
             is BookshelfManageScreenIntent.ToggleBookDownload -> toggleBookDownload(intent.book)
             is BookshelfManageScreenIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl)
             is BookshelfManageScreenIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl)
             is BookshelfManageScreenIntent.ClearBookCache -> clearCacheForBook(intent.book)
             is BookshelfManageScreenIntent.ClearBookCache -> clearCacheForBook(intent.book)
+            is BookshelfManageScreenIntent.ToggleBookSelection -> toggleBookSelection(intent.bookUrl)
+            is BookshelfManageScreenIntent.SelectVisibleBooks -> applySelection(
+                selectionOfSelectVisible(_uiState.value.selectedBookUrls, intent.visibleBookUrls)
+            )
+
+            is BookshelfManageScreenIntent.InvertVisibleBooks -> applySelection(
+                selectionOfInvertVisible(_uiState.value.selectedBookUrls, intent.visibleBookUrls)
+            )
+
+            BookshelfManageScreenIntent.ClearBookSelection -> applySelection(emptySet())
             is BookshelfManageScreenIntent.MoveBooksToGroup -> moveBooksToGroup(intent.bookUrls, intent.groupId)
             is BookshelfManageScreenIntent.MoveBooksToGroup -> moveBooksToGroup(intent.bookUrls, intent.groupId)
             is BookshelfManageScreenIntent.DeleteBooks -> deleteBooks(intent.bookUrls, intent.deleteOriginal)
             is BookshelfManageScreenIntent.DeleteBooks -> deleteBooks(intent.bookUrls, intent.deleteOriginal)
             is BookshelfManageScreenIntent.ClearCachesForBooks -> clearCachesForBooks(intent.bookUrls)
             is BookshelfManageScreenIntent.ClearCachesForBooks -> clearCachesForBooks(intent.bookUrls)
@@ -320,6 +365,45 @@ class BookshelfManageScreenViewModel(
         }
         }
     }
     }
 
 
+    private fun toggleBookSelection(bookUrl: String) {
+        val selected = _uiState.value.selectedBookUrls
+        applySelection(if (selected.contains(bookUrl)) selected - bookUrl else selected + bookUrl)
+    }
+
+    /**
+     * 提交新的选中集合。选中状态由 ViewModel 持有,因此切换分组、进入详情再返回、
+     * 配置变更都不会丢;只有 [ClearBookSelection] 或书籍被删除才会变化。
+     */
+    private fun applySelection(bookUrls: Set<String>) {
+        _uiState.update { it.copy(selectedBookUrls = bookUrls.toImmutableSet()) }
+        resolveSelectedBooks()
+    }
+
+    /**
+     * 回查选中书籍的实体。查询顺带做一次存在性校验:书可能在本页之外被删掉
+     * (书籍详情页删除、换源失败回滚等),这时它的 url 不能继续留在选中集合里。
+     * 分组不影响存在性,所以跨分组选中不会被这里清掉。
+     */
+    private fun resolveSelectedBooks() {
+        selectionResolveJob?.cancel()
+        val bookUrls = _uiState.value.selectedBookUrls
+        if (bookUrls.isEmpty()) {
+            _uiState.update { it.copy(selectedBooks = persistentListOf()) }
+            return
+        }
+        selectionResolveJob = viewModelScope.launch {
+            val books = bookRepository.getBooksByUrls(bookUrls)
+            val existingUrls = books.mapTo(hashSetOf()) { it.bookUrl }
+            _uiState.update { state ->
+                state.copy(
+                    selectedBookUrls = state.selectedBookUrls.intersect(existingUrls)
+                        .toImmutableSet(),
+                    selectedBooks = books.toImmutableList()
+                )
+            }
+        }
+    }
+
     fun getCacheCount(bookUrl: String): Int? = cacheCounts[bookUrl]
     fun getCacheCount(bookUrl: String): Int? = cacheCounts[bookUrl]
 
 
     fun isBookDownloading(bookUrl: String): Boolean {
     fun isBookDownloading(bookUrl: String): Boolean {
@@ -343,6 +427,8 @@ class BookshelfManageScreenViewModel(
         observeBooks(groupId)
         observeBooks(groupId)
         observeDownloadAndExportChanges()
         observeDownloadAndExportChanges()
         refreshGroupName(groupId)
         refreshGroupName(groupId)
+        // 从书籍详情返回时 Initialize 会重跑,这里顺带清掉在别处被删掉的选中项
+        resolveSelectedBooks()
     }
     }
 
 
     private fun changeGroup(groupId: Long) {
     private fun changeGroup(groupId: Long) {
@@ -645,6 +731,11 @@ class BookshelfManageScreenViewModel(
         }.onSuccess { deletedBookUrls ->
         }.onSuccess { deletedBookUrls ->
             _uiState.update { it.copy(deleteBookOriginal = deleteOriginal) }
             _uiState.update { it.copy(deleteBookOriginal = deleteOriginal) }
             deletedBookUrls.forEach { cacheCounts.remove(it) }
             deletedBookUrls.forEach { cacheCounts.remove(it) }
+            // 书已经不在书架里了,选中集合不能再留着它的 url(跨分组选中时它可能根本不在当前列表)
+            val remaining = _uiState.value.selectedBookUrls - deletedBookUrls
+            if (remaining.size != _uiState.value.selectedBookUrls.size) {
+                applySelection(remaining)
+            }
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除成功"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除成功"))
         }.onError {
         }.onError {
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("删除失败\n${it.localizedMessage}"))

+ 9 - 0
app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt

@@ -59,6 +59,13 @@ fun <T> ListScaffold(
     dropDownMenuContent: @Composable (ColumnScope.(dismiss: () -> Unit) -> Unit)? = null,
     dropDownMenuContent: @Composable (ColumnScope.(dismiss: () -> Unit) -> Unit)? = null,
     onClearSelection: (() -> Unit)? = null,
     onClearSelection: (() -> Unit)? = null,
     selectionActions: SelectionActions? = null,
     selectionActions: SelectionActions? = null,
+    /**
+     * 选择模式下是否保留顶栏右侧 actions 区(搜索 / [topBarActions] / 更多菜单),
+     * 默认关闭;详见 [DynamicTopAppBar]。需要跨分组多选的书架管理会打开它。
+     */
+    keepActionsInSelection: Boolean = false,
+    /** 选择态自定义标题,跨分组多选时默认计数口径会失真,详见 [DynamicTopAppBar]。 */
+    selectionTitle: String? = null,
     onAddClick: (() -> Unit)? = null,
     onAddClick: (() -> Unit)? = null,
     floatingActionButton: @Composable () -> Unit = {
     floatingActionButton: @Composable () -> Unit = {
         onAddClick?.let { onClick ->
         onAddClick?.let { onClick ->
@@ -103,6 +110,8 @@ fun <T> ListScaffold(
                 searchTrailingIcon = searchTrailingIcon,
                 searchTrailingIcon = searchTrailingIcon,
                 searchPlaceholder = searchPlaceholder,
                 searchPlaceholder = searchPlaceholder,
                 onClearSelection = { onClearSelection?.invoke() ?: selectionActions?.onClearSelection?.invoke() },
                 onClearSelection = { onClearSelection?.invoke() ?: selectionActions?.onClearSelection?.invoke() },
+                keepActionsInSelection = keepActionsInSelection,
+                selectionTitle = selectionTitle,
                 topBarActions = topBarActions,
                 topBarActions = topBarActions,
                 dropDownMenuContent = dropDownMenuContent,
                 dropDownMenuContent = dropDownMenuContent,
                 bottomContent = bottomContent
                 bottomContent = bottomContent

+ 18 - 3
app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt

@@ -47,6 +47,19 @@ fun <T> DynamicTopAppBar(
     searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
     searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
     onClearSelection: () -> Unit,
     onClearSelection: () -> Unit,
     topBarActions: @Composable RowScope.() -> Unit = {},
     topBarActions: @Composable RowScope.() -> Unit = {},
+    /**
+     * 选择模式下是否保留顶栏右侧 actions 区(搜索 / [topBarActions] / 更多菜单)。
+     *
+     * 默认关闭:多数列表页进入选择态后只需要「退出选择」和底部操作栏。
+     * 书架管理需要打开 —— 它支持跨分组多选,选中后还要继续搜索、切分组才能挑别的书。
+     */
+    keepActionsInSelection: Boolean = false,
+    /**
+     * 选择态标题。跨分组多选时 [ListUiState.selectedIds] 可能多于当前列表条目,
+     * 默认的 "Selected 已选/总数" 口径会失真,调用方可以在这里给出自己的文案;
+     * 为 null 时退回默认计数。
+     */
+    selectionTitle: String? = null,
     dropDownMenuContent: @Composable (ColumnScope.(dismiss: () -> Unit) -> Unit)? = null,
     dropDownMenuContent: @Composable (ColumnScope.(dismiss: () -> Unit) -> Unit)? = null,
     bottomContent: @Composable (ColumnScope.(GlassTopAppBarScrollBehavior) -> Unit)? = null
     bottomContent: @Composable (ColumnScope.(GlassTopAppBarScrollBehavior) -> Unit)? = null
 ) {
 ) {
@@ -59,7 +72,7 @@ fun <T> DynamicTopAppBar(
             .fillMaxWidth(),
             .fillMaxWidth(),
         title = when {
         title = when {
             state.isLoading -> stringResource(R.string.list_loading_title)
             state.isLoading -> stringResource(R.string.list_loading_title)
-            isSelecting -> stringResource(
+            isSelecting -> selectionTitle ?: stringResource(
                 R.string.list_selected_count,
                 R.string.list_selected_count,
                 state.selectedIds.size,
                 state.selectedIds.size,
                 state.items.size
                 state.items.size
@@ -80,7 +93,8 @@ fun <T> DynamicTopAppBar(
             }
             }
         },
         },
         actions = {
         actions = {
-            if (!isSelecting) {
+            // 选择态默认收起整个 actions 区,只有显式声明的调用方才继续显示
+            if (!isSelecting || keepActionsInSelection) {
                 if (showSearchAction) {
                 if (showSearchAction) {
                     TopBarActionButton(
                     TopBarActionButton(
                         onClick = { onSearchToggle(!state.isSearch) },
                         onClick = { onSearchToggle(!state.isSearch) },
@@ -113,7 +127,8 @@ fun <T> DynamicTopAppBar(
             AnimatedVisibility(
             AnimatedVisibility(
                 modifier = Modifier
                 modifier = Modifier
                     .adaptiveHorizontalPadding(),
                     .adaptiveHorizontalPadding(),
-                visible = state.isSearch && !isSelecting,
+                // 保留 actions 时必须同步保留输入框,否则选择态点搜索按钮会没有任何反馈
+                visible = state.isSearch && (!isSelecting || keepActionsInSelection),
                 enter = expandVertically() + fadeIn(),
                 enter = expandVertically() + fadeIn(),
                 exit = shrinkVertically() + fadeOut()
                 exit = shrinkVertically() + fadeOut()
             ) {
             ) {

+ 40 - 0
app/src/test/java/io/legado/app/ui/book/manage/BookshelfManageSelectionTest.kt

@@ -0,0 +1,40 @@
+package io.legado.app.ui.book.manage
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BookshelfManageSelectionTest {
+
+    @Test
+    fun selectVisible_keepsBooksSelectedInOtherGroups() {
+        // 分组 A 里选了 a1,切到分组 B 后全选 B:a1 必须还在
+        val result = selectionOfSelectVisible(
+            selected = setOf("a1"),
+            visibleBookUrls = setOf("b1", "b2")
+        )
+        assertEquals(setOf("a1", "b1", "b2"), result)
+    }
+
+    @Test
+    fun selectVisible_isIdempotent() {
+        val visible = setOf("b1", "b2")
+        val first = selectionOfSelectVisible(emptySet(), visible)
+        assertEquals(first, selectionOfSelectVisible(first, visible))
+    }
+
+    @Test
+    fun invertVisible_onlyFlipsVisibleBooks() {
+        // a1 属于另一个分组(不可见),反选当前分组时不能被翻掉
+        val result = selectionOfInvertVisible(
+            selected = setOf("a1", "b1"),
+            visibleBookUrls = setOf("b1", "b2")
+        )
+        assertEquals(setOf("a1", "b2"), result)
+    }
+
+    @Test
+    fun invertVisible_addsAllVisibleWhenNothingSelected() {
+        val visible = setOf("b1", "b2")
+        assertEquals(visible, selectionOfInvertVisible(setOf("a1"), visible) - setOf("a1"))
+    }
+}