Просмотр исходного кода

[优化] 合并下载和原本的书架管理界面

HapeLee 6 месяцев назад
Родитель
Сommit
1e3ea159e7

+ 22 - 1
app/src/main/java/io/legado/app/di/appModule.kt

@@ -17,7 +17,11 @@ import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
 import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.UploadRepository
+import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
+import io.legado.app.domain.usecase.ClearBookCacheUseCase
+import io.legado.app.domain.usecase.DeleteBooksUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
+import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.help.coil.CoverFetcher
 import io.legado.app.help.coil.CoverInterceptor
 import io.legado.app.help.http.okHttpClient
@@ -67,6 +71,7 @@ val appModule = module {
     single { get<AppDatabase>().bookDao }
     single { get<AppDatabase>().bookChapterDao }
     single { get<AppDatabase>().bookGroupDao }
+    single { get<AppDatabase>().bookSourceDao }
 
     singleOf(::ReadRecordRepository)
     singleOf(::BookRepository)
@@ -74,6 +79,10 @@ val appModule = module {
     singleOf(::SearchContentRepository)
     singleOf(::RemoteBookRepository)
     singleOf(::ExploreKindUiUseCase)
+    singleOf(::BatchCacheDownloadUseCase)
+    singleOf(::ClearBookCacheUseCase)
+    singleOf(::DeleteBooksUseCase)
+    singleOf(::UpdateBooksGroupUseCase)
     singleOf(::CacheConfig)
 
     single<UploadRepository> { DirectLinkUploadRepository() }
@@ -125,7 +134,19 @@ val appModule = module {
     viewModelOf(::ExploreViewModel)
     viewModelOf(::RssViewModel)
     viewModelOf(::SearchViewModel)
-    viewModelOf(::CacheViewModel)
+    viewModel {
+        CacheViewModel(
+            application = get(),
+            bookDao = get(),
+            bookGroupDao = get(),
+            bookChapterDao = get(),
+            cacheConfig = get(),
+            batchCacheDownloadUseCase = get(),
+            clearBookCacheUseCase = get(),
+            deleteBooksUseCase = get(),
+            updateBooksGroupUseCase = get()
+        )
+    }
 
     viewModel { (route: ReplaceEditRoute) ->
         ReplaceEditViewModel(

+ 63 - 0
app/src/main/java/io/legado/app/domain/usecase/BatchCacheDownloadUseCase.kt

@@ -0,0 +1,63 @@
+package io.legado.app.domain.usecase
+
+import android.content.Context
+import io.legado.app.constant.BookType
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.entities.Book
+import io.legado.app.model.CacheBook
+
+class BatchCacheDownloadUseCase(
+    private val bookDao: BookDao
+) {
+
+    suspend fun execute(
+        context: Context,
+        bookUrls: Set<String>,
+        downloadAllChapters: Boolean,
+        skipAudioBooks: Boolean = false
+    ): Int {
+        if (bookUrls.isEmpty()) return 0
+        var count = 0
+        bookUrls.forEach { bookUrl ->
+            val book = bookDao.getBook(bookUrl) ?: return@forEach
+            if (startIfNeeded(context, book, downloadAllChapters, skipAudioBooks)) {
+                count++
+            }
+        }
+        return count
+    }
+
+    fun execute(
+        context: Context,
+        books: List<Book>,
+        downloadAllChapters: Boolean,
+        skipAudioBooks: Boolean = false
+    ): Int {
+        if (books.isEmpty()) return 0
+        var count = 0
+        books.forEach { book ->
+            if (startIfNeeded(context, book, downloadAllChapters, skipAudioBooks)) {
+                count++
+            }
+        }
+        return count
+    }
+
+    private fun startIfNeeded(
+        context: Context,
+        book: Book,
+        downloadAllChapters: Boolean,
+        skipAudioBooks: Boolean
+    ): Boolean {
+        if (book.type and BookType.local > 0) return false
+        if (skipAudioBooks && book.type and BookType.audio > 0) return false
+        val indices = if (downloadAllChapters) {
+            (0..book.lastChapterIndex).toList()
+        } else {
+            (book.durChapterIndex..book.lastChapterIndex).toList()
+        }
+        if (indices.isEmpty()) return false
+        CacheBook.start(context, book, indices)
+        return true
+    }
+}

+ 22 - 0
app/src/main/java/io/legado/app/domain/usecase/ClearBookCacheUseCase.kt

@@ -0,0 +1,22 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.entities.Book
+import io.legado.app.help.book.BookHelp
+
+class ClearBookCacheUseCase(
+    private val bookDao: BookDao
+) {
+
+    fun execute(book: Book): String {
+        BookHelp.clearCache(book)
+        return book.bookUrl
+    }
+
+    suspend fun execute(bookUrls: Set<String>): List<String> {
+        if (bookUrls.isEmpty()) return emptyList()
+        return bookUrls.mapNotNull { bookUrl ->
+            bookDao.getBook(bookUrl)?.let { execute(it) }
+        }
+    }
+}

+ 33 - 0
app/src/main/java/io/legado/app/domain/usecase/DeleteBooksUseCase.kt

@@ -0,0 +1,33 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.dao.BookChapterDao
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.BookSourceDao
+import io.legado.app.help.book.isLocal
+import io.legado.app.model.SourceCallBack
+import io.legado.app.model.localBook.LocalBook
+
+class DeleteBooksUseCase(
+    private val bookDao: BookDao,
+    private val bookChapterDao: BookChapterDao,
+    private val bookSourceDao: BookSourceDao
+) {
+
+    suspend fun execute(bookUrls: Set<String>, deleteOriginal: Boolean): List<String> {
+        if (bookUrls.isEmpty()) return emptyList()
+        val books = bookUrls.mapNotNull { bookDao.getBook(it) }
+        books.forEach { book ->
+            if (book.isLocal) {
+                LocalBook.deleteBook(book, deleteOriginal)
+            } else {
+                val source = bookSourceDao.getBookSource(book.origin)
+                SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, source, book)
+            }
+            bookChapterDao.delByBook(book.bookUrl)
+        }
+        if (books.isNotEmpty()) {
+            bookDao.delete(*books.toTypedArray())
+        }
+        return books.map { it.bookUrl }
+    }
+}

+ 29 - 0
app/src/main/java/io/legado/app/domain/usecase/UpdateBooksGroupUseCase.kt

@@ -0,0 +1,29 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.dao.BookDao
+
+class UpdateBooksGroupUseCase(
+    private val bookDao: BookDao
+) {
+
+    suspend fun replaceGroup(bookUrls: Set<String>, groupId: Long) {
+        updateGroups(bookUrls) { groupId }
+    }
+
+    suspend fun updateGroups(bookUrls: Set<String>, transform: (Long) -> Long) {
+        if (bookUrls.isEmpty()) return
+        val updateBooks = bookUrls.mapNotNull { bookUrl ->
+            bookDao.getBook(bookUrl)?.let { book ->
+                val targetGroup = transform(book.group)
+                if (targetGroup == book.group) {
+                    null
+                } else {
+                    book.copy(group = targetGroup)
+                }
+            }
+        }
+        if (updateBooks.isNotEmpty()) {
+            bookDao.update(*updateBooks.toTypedArray())
+        }
+    }
+}

+ 559 - 254
app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt

@@ -1,36 +1,58 @@
 package io.legado.app.ui.book.cache
 
+import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.MenuOpen
+import androidx.compose.material.icons.filled.Bookmarks
+import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Download
 import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.SelectAll
 import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material.icons.filled.Upload
-import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingActionButtonMenu
+import androidx.compose.material3.FloatingActionButtonMenuItem
 import androidx.compose.material3.Icon
 import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.ToggleFloatingActionButton
+import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon
+import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
@@ -39,31 +61,27 @@ import io.legado.app.data.entities.Book
 import io.legado.app.help.book.getExportFileName
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.tryParesExportFileName
-import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.service.ExportBookService
 import io.legado.app.ui.about.AppLogSheet
-import io.legado.app.ui.file.HandleFileContract
+import io.legado.app.ui.book.info.GroupSelectSheet
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppLinearProgressIndicator
-import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
-import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.SmallTonalIconButton
 import io.legado.app.ui.widget.components.button.TopBarActionButton
-import io.legado.app.ui.widget.components.button.TopBarNavigationButton
-import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.divider.PillDivider
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.list.ListScaffold
+import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
-import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.utils.ACache
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.checkWrite
@@ -71,8 +89,24 @@ import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.startService
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.verificationField
+import io.legado.app.help.config.LocalConfig
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import org.koin.androidx.compose.koinViewModel
 
+data class CacheFabAction(
+    val icon: androidx.compose.ui.graphics.vector.ImageVector,
+    val label: String,
+    val action: () -> Unit
+)
+
+private data class CacheListState(
+    override val items: List<Book> = emptyList(),
+    override val selectedIds: Set<Any> = emptySet(),
+    override val searchKey: String = "",
+    override val isSearch: Boolean = false,
+    override val isLoading: Boolean = false
+) : ListUiState<Book>
+
 @Composable
 fun CacheRouteScreen(
     groupId: Long,
@@ -85,6 +119,7 @@ fun CacheRouteScreen(
     CacheScreen(viewModel = viewModel, onBackClick = onBackClick)
 }
 
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 private fun CacheScreen(
     viewModel: CacheViewModel,
@@ -92,18 +127,25 @@ private fun CacheScreen(
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val context = LocalContext.current
-    var showMenu by remember { mutableStateOf(false) }
     var showGroupMenu by remember { mutableStateOf(false) }
     var showFilePickerSheet by remember { mutableStateOf(false) }
-    var showDownloadConfirmDialog by remember { mutableStateOf(false) }
     var showDownloadAllConfirmDialog by remember { mutableStateOf(false) }
+    var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) }
     var showExportTypeDialog by remember { mutableStateOf(false) }
     var showExportFileNameDialog by remember { mutableStateOf(false) }
     var showCharsetDialog by remember { mutableStateOf(false) }
     var showLogSheet by remember { mutableStateOf(false) }
+    var showGroupSelectSheet by remember { mutableStateOf(false) }
+    var showDeleteBookConfirmDialog by remember { mutableStateOf(false) }
     var showCustomExportDialog by remember { mutableStateOf(false) }
+    var pendingMoveGroupBookUrl by remember { mutableStateOf<String?>(null) }
+    var groupPickerCurrentGroupId by remember { mutableLongStateOf(0L) }
+    var moreMenuBookUrl by remember { mutableStateOf<String?>(null) }
+    var pendingDeleteBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
+    var fabMenuExpanded by rememberSaveable { mutableStateOf(false) }
     var pendingExportBookUrl by remember { mutableStateOf<String?>(null) }
     var pendingExportAll by remember { mutableStateOf(false) }
+    var pendingExportSelection by remember { mutableStateOf<Set<String>>(emptySet()) }
     var customExportPath by remember { mutableStateOf("") }
     var customExportBook by remember { mutableStateOf<Book?>(null) }
     var customExportAllChapter by remember { mutableStateOf(false) }
@@ -113,9 +155,13 @@ private fun CacheScreen(
     var customEpisodeExportNameInput by remember { mutableStateOf(state.exportConfig.episodeExportFileName) }
     var exportFileNameInput by remember { mutableStateOf(state.exportConfig.bookExportFileName.orEmpty()) }
     var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) }
+    var isSearchMode by remember { mutableStateOf(false) }
+    var searchKey by remember { mutableStateOf("") }
+    var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
+    var deleteOriginalBookFile by remember { mutableStateOf(LocalConfig.deleteBookOriginal) }
     val exportBookPathKey = remember { "exportBookPath" }
     val exportTypes = remember { arrayListOf("txt", "epub") }
-    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val focusRequester = remember { FocusRequester() }
     val exportFolderText = stringResource(R.string.export_folder)
     val exportAllText = stringResource(R.string.export_all)
     val exportChapterIndexText = stringResource(R.string.export_chapter_index)
@@ -135,11 +181,72 @@ private fun CacheScreen(
 书名:《{name}》-作者:{author}_备注
 输出:书名:《三体》-作者:刘慈欣_备注
     """.trimIndent()
+    val booksByUrl = remember(state.books) { state.books.associateBy { it.bookUrl } }
+    val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } }
+
+    val groupNameResolver: (Book) -> String = remember(userGroups, context) {
+        { book ->
+            if (book.group <= 0L) {
+                context.getString(R.string.no_group)
+            } else {
+                val groups = userGroups.filter {
+                    (book.group and it.groupId) > 0L
+                }
+                if (groups.isEmpty()) context.getString(R.string.no_group)
+                else groups.joinToString("、") { it.groupName }
+            }
+        }
+    }
+    val filteredBooks = remember(state.books, searchKey, isSearchMode, groupNameResolver) {
+        if (!isSearchMode || searchKey.isBlank()) {
+            state.books
+        } else {
+            val key = searchKey.trim()
+            state.books.filter { book ->
+                book.name.contains(key, true) ||
+                        book.getRealAuthor().contains(key, true) ||
+                        book.originName.contains(key, true) ||
+                        groupNameResolver(book).contains(key, true)
+            }
+        }
+    }
+    val listUiState = remember(filteredBooks, selectedBookUrls, searchKey, isSearchMode) {
+        CacheListState(
+            items = filteredBooks,
+            selectedIds = selectedBookUrls.mapTo(linkedSetOf()) { it as Any },
+            searchKey = searchKey,
+            isSearch = isSearchMode,
+            isLoading = false
+        )
+    }
+    val inSelectionMode = selectedBookUrls.isNotEmpty()
+    val hasLocalBookInDeleteTarget = remember(state.books, pendingDeleteBookUrls) {
+        state.books.any { pendingDeleteBookUrls.contains(it.bookUrl) && it.isLocal }
+    }
+    val clearSelection = {
+        selectedBookUrls = emptySet()
+    }
+    val toggleBookSelection: (Book) -> Unit = { book ->
+        selectedBookUrls = if (selectedBookUrls.contains(book.bookUrl)) {
+            selectedBookUrls - book.bookUrl
+        } else {
+            selectedBookUrls + book.bookUrl
+        }
+    }
+
+    BackHandler(enabled = selectedBookUrls.isNotEmpty()) {
+        clearSelection()
+    }
+
+    LaunchedEffect(state.books) {
+        val visibleBookUrls = booksByUrl.keys
+        selectedBookUrls = selectedBookUrls.intersect(visibleBookUrls)
+    }
 
-    val exportDir = rememberLauncherForActivityResult(HandleFileContract()) { result ->
+    val exportDir = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
         var isReadyPath = false
         var dirPath = ""
-        result.uri?.let { uri ->
+        uri?.let {
             if (uri.isContentScheme()) {
                 ACache.get().put(exportBookPathKey, uri.toString())
                 dirPath = uri.toString()
@@ -153,6 +260,14 @@ private fun CacheScreen(
             }
         }
         if (!isReadyPath) return@rememberLauncherForActivityResult
+        if (pendingExportSelection.isNotEmpty()) {
+            pendingExportSelection.forEach { bookUrl ->
+                booksByUrl[bookUrl]?.let { book ->
+                    startExport(context, dirPath, book, state.exportConfig.exportType)
+                }
+            }
+            return@rememberLauncherForActivityResult
+        }
         if (pendingExportAll) {
             state.books.forEach { book ->
                 startExport(context, dirPath, book, state.exportConfig.exportType)
@@ -160,7 +275,7 @@ private fun CacheScreen(
             return@rememberLauncherForActivityResult
         }
         val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
-        val book = state.books.firstOrNull { it.bookUrl == bookUrl } ?: return@rememberLauncherForActivityResult
+        val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
         if (state.exportConfig.enableCustomExport) {
             customExportPath = dirPath
             customExportBook = book
@@ -184,9 +299,14 @@ private fun CacheScreen(
         }
     }
 
-    fun selectExportFolder(bookUrl: String? = null, forAll: Boolean = false) {
+    fun selectExportFolder(
+        bookUrl: String? = null,
+        forAll: Boolean = false,
+        selection: Set<String> = emptySet()
+    ) {
         pendingExportBookUrl = bookUrl
         pendingExportAll = forAll
+        pendingExportSelection = selection
         showFilePickerSheet = true
     }
 
@@ -219,182 +339,280 @@ private fun CacheScreen(
         }
     }
 
-    AppScaffold(
-        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
-        topBar = {
-            GlassMediumFlexibleTopAppBar(
-                title = state.groupName ?: stringResource(R.string.offline_cache),
-                scrollBehavior = scrollBehavior,
-                navigationIcon = {
-                    TopBarNavigationButton(onClick = onBackClick)
-                },
-                actions = {
-                    if (state.groupList.isNotEmpty()) {
-                        TopBarActionButton(
-                            onClick = { showGroupMenu = true },
-                            imageVector = AppIcons.Filter,
-                            contentDescription = null
-                        )
-                        RoundDropdownMenu(
-                            expanded = showGroupMenu,
-                            onDismissRequest = { showGroupMenu = false }
-                        ) { dismiss ->
-                            state.groupList.forEach { group ->
-                                RoundDropdownMenuItem(
-                                    text = "${group.groupName}",
-                                    isSelected = group.groupId == state.groupId,
-                                    onClick = {
-                                        dismiss()
-                                        viewModel.dispatch(CacheIntent.ChangeGroup(group.groupId))
-                                    }
-                                )
-                            }
-                        }
-                    }
-                    TopBarActionButton(
-                        onClick = { showMenu = true },
-                        imageVector = Icons.Default.MoreVert,
-                        contentDescription = null
-                    )
-                    RoundDropdownMenu(
-                        expanded = showMenu,
-                        onDismissRequest = { showMenu = false }
-                    ) {
-                        RoundDropdownMenuItem(
-                            text = stringResource(R.string.download_all),
-                            onClick = {
-                                showMenu = false
-                                if (state.isDownloadRunning) {
-                                    viewModel.dispatch(CacheIntent.StopDownload)
-                                } else {
-                                    showDownloadAllConfirmDialog = true
-                                }
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = stringResource(R.string.export_all),
-                            onClick = { showMenu = false; exportAll() }
-                        )
-                        PillDivider()
-                        RoundDropdownMenuItem(
-                            text = stringResource(R.string.export_folder),
-                            onClick = { showMenu = false; selectExportFolder() }
-                        )
-                        RoundDropdownMenuItem(
-                            text = stringResource(R.string.export_file_name),
-                            onClick = {
-                                showMenu = false
-                                exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty()
-                                showExportFileNameDialog = true
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})",
-                            onClick = {
-                                showMenu = false
-                                showExportTypeDialog = true
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})",
-                            onClick = {
-                                showMenu = false
-                                exportCharsetInput = state.exportConfig.exportCharset
-                                showCharsetDialog = true
-                            }
-                        )
-                        PillDivider()
-                        RoundDropdownMenuItem(
-                            text = "替换净化",
-                            isSelected = state.exportConfig.exportUseReplace,
-                            onClick = {
-                                showMenu = false
-                                viewModel.dispatch(
-                                    CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace)
-                                )
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = "自定义导出",
-                            isSelected = state.exportConfig.enableCustomExport,
-                            onClick = {
-                                showMenu = false
-                                viewModel.dispatch(
-                                    CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport)
-                                )
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = "导出包含章节名",
-                            isSelected = !state.exportConfig.exportNoChapterName,
-                            onClick = {
-                                showMenu = false
-                                viewModel.dispatch(
-                                    CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName)
-                                )
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = "导出到WebDav",
-                            isSelected = state.exportConfig.exportToWebDav,
-                            onClick = {
-                                showMenu = false
-                                viewModel.dispatch(
-                                    CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav)
-                                )
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = "导出插图文件",
-                            isSelected = state.exportConfig.exportPictureFile,
-                            onClick = {
-                                showMenu = false
-                                viewModel.dispatch(
-                                    CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile)
-                                )
-                            }
-                        )
-                        RoundDropdownMenuItem(
-                            text = "并行导出",
-                            isSelected = state.exportConfig.parallelExportBook,
-                            onClick = {
-                                showMenu = false
-                                viewModel.dispatch(
-                                    CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook)
-                                )
-                            }
-                        )
-                        PillDivider()
+    fun exportSelected() {
+        if (selectedBookUrls.isEmpty()) return
+        val path = ACache.get().getAsString(exportBookPathKey)
+        if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
+            selectExportFolder(selection = selectedBookUrls)
+        } else {
+            selectedBookUrls.forEach { bookUrl ->
+                booksByUrl[bookUrl]?.let { book ->
+                    startExport(context, path, book, state.exportConfig.exportType)
+                }
+            }
+        }
+    }
+    fun resolveSelectionGroupMask(): Long {
+        val targetBooks = selectedBookUrls.mapNotNull { booksByUrl[it] }
+        if (targetBooks.isEmpty()) return 0L
+        val firstGroup = targetBooks.first().group.coerceAtLeast(0L)
+        return if (targetBooks.all { it.group == firstGroup }) firstGroup else 0L
+    }
+    val fabItems = listOf(
+        CacheFabAction(
+            Icons.Default.SelectAll,
+            stringResource(R.string.select_all)
+        ) {
+            selectedBookUrls = state.books.mapTo(hashSetOf()) { it.bookUrl }
+        },
+        CacheFabAction(
+            Icons.Default.Refresh,
+            stringResource(R.string.revert_selection)
+        ) {
+            val visibleBookUrls = booksByUrl.keys
+            selectedBookUrls = visibleBookUrls - selectedBookUrls
+        },
+        CacheFabAction(
+            Icons.Default.Download,
+            "缓存选中"
+        ) {
+            if (selectedBookUrls.isNotEmpty()) {
+                showBatchDownloadConfirmDialog = true
+            }
+        },
+        CacheFabAction(
+            Icons.Default.Bookmarks,
+            stringResource(R.string.move_to_group)
+        ) {
+            if (selectedBookUrls.isNotEmpty()) {
+                groupPickerCurrentGroupId = resolveSelectionGroupMask()
+                pendingMoveGroupBookUrl = null
+                showGroupSelectSheet = true
+            }
+        },
+        CacheFabAction(
+            Icons.Default.Upload,
+            "导出选中"
+        ) {
+            exportSelected()
+        },
+        CacheFabAction(
+            Icons.Default.Delete,
+            stringResource(R.string.clear_cache)
+        ) {
+            viewModel.dispatch(CacheIntent.ClearCachesForBooks(selectedBookUrls))
+            clearSelection()
+        },
+        CacheFabAction(
+            Icons.Default.Delete,
+            stringResource(R.string.delete)
+        ) {
+            if (selectedBookUrls.isNotEmpty()) {
+                pendingDeleteBookUrls = selectedBookUrls
+                deleteOriginalBookFile = LocalConfig.deleteBookOriginal
+                showDeleteBookConfirmDialog = true
+            }
+        }
+    )
+    val listState = rememberLazyListState()
+    ListScaffold(
+        title = if (inSelectionMode) {
+            "已选 ${selectedBookUrls.size}/${filteredBooks.size}"
+        } else {
+            state.groupName ?: stringResource(R.string.offline_cache)
+        },
+        state = listUiState,
+        onBackClick = onBackClick,
+        onSearchToggle = { active ->
+            isSearchMode = active
+            if (!active) {
+                searchKey = ""
+            }
+        },
+        onSearchQueryChange = { searchKey = it },
+        searchPlaceholder = "筛选书名/作者/书源/分组",
+        topBarActions = {
+            if (state.groupList.isNotEmpty()) {
+                TopBarActionButton(
+                    onClick = { showGroupMenu = true },
+                    imageVector = AppIcons.Filter,
+                    contentDescription = null
+                )
+                RoundDropdownMenu(
+                    expanded = showGroupMenu,
+                    onDismissRequest = { showGroupMenu = false }
+                ) { dismiss ->
+                    state.groupList.forEach { group ->
                         RoundDropdownMenuItem(
-                            text = stringResource(R.string.log),
+                            text = group.groupName,
+                            isSelected = group.groupId == state.groupId,
                             onClick = {
-                                showMenu = false
-                                showLogSheet = true
+                                dismiss()
+                                viewModel.dispatch(CacheIntent.ChangeGroup(group.groupId))
                             }
                         )
                     }
                 }
-            )
+            }
         },
-        floatingActionButton = {
-            AppFloatingActionButton(
+        dropDownMenuContent = { dismiss ->
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.download_all),
                 onClick = {
+                    dismiss()
                     if (state.isDownloadRunning) {
                         viewModel.dispatch(CacheIntent.StopDownload)
                     } else {
-                        showDownloadConfirmDialog = true
+                        showDownloadAllConfirmDialog = true
+                    }
+                }
+            )
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.export_all),
+                onClick = { dismiss(); exportAll() }
+            )
+            PillDivider()
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.export_folder),
+                onClick = { dismiss(); selectExportFolder() }
+            )
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.export_file_name),
+                onClick = {
+                    dismiss()
+                    exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty()
+                    showExportFileNameDialog = true
+                }
+            )
+            RoundDropdownMenuItem(
+                text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})",
+                onClick = {
+                    dismiss()
+                    showExportTypeDialog = true
+                }
+            )
+            RoundDropdownMenuItem(
+                text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})",
+                onClick = {
+                    dismiss()
+                    exportCharsetInput = state.exportConfig.exportCharset
+                    showCharsetDialog = true
+                }
+            )
+            PillDivider()
+            RoundDropdownMenuItem(
+                text = "替换净化",
+                isSelected = state.exportConfig.exportUseReplace,
+                onClick = {
+                    dismiss()
+                    viewModel.dispatch(
+                        CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace)
+                    )
+                }
+            )
+            RoundDropdownMenuItem(
+                text = "自定义导出",
+                isSelected = state.exportConfig.enableCustomExport,
+                onClick = {
+                    dismiss()
+                    viewModel.dispatch(
+                        CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport)
+                    )
+                }
+            )
+            RoundDropdownMenuItem(
+                text = "导出包含章节名",
+                isSelected = !state.exportConfig.exportNoChapterName,
+                onClick = {
+                    dismiss()
+                    viewModel.dispatch(
+                        CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName)
+                    )
+                }
+            )
+            RoundDropdownMenuItem(
+                text = "导出到WebDav",
+                isSelected = state.exportConfig.exportToWebDav,
+                onClick = {
+                    dismiss()
+                    viewModel.dispatch(
+                        CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav)
+                    )
+                }
+            )
+            RoundDropdownMenuItem(
+                text = "导出插图文件",
+                isSelected = state.exportConfig.exportPictureFile,
+                onClick = {
+                    dismiss()
+                    viewModel.dispatch(
+                        CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile)
+                    )
+                }
+            )
+            RoundDropdownMenuItem(
+                text = "并行导出",
+                isSelected = state.exportConfig.parallelExportBook,
+                onClick = {
+                    dismiss()
+                    viewModel.dispatch(
+                        CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook)
+                    )
+                }
+            )
+            PillDivider()
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.log),
+                onClick = {
+                    dismiss()
+                    showLogSheet = true
+                }
+            )
+        },
+        floatingActionButton = {
+            FloatingActionButtonMenu(
+                modifier = Modifier.offset(x = 16.dp, y = 16.dp),
+                expanded = fabMenuExpanded,
+                button = {
+                    ToggleFloatingActionButton(
+                        modifier = Modifier
+                            .animateFloatingActionButton(
+                                visible = true,
+                                alignment = Alignment.BottomEnd,
+                            )
+                            .focusRequester(focusRequester),
+                        checked = fabMenuExpanded,
+                        onCheckedChange = { fabMenuExpanded = !fabMenuExpanded },
+                    ) {
+                        val imageVector by remember {
+                            derivedStateOf {
+                                if (checkedProgress > 0.5f) Icons.Filled.Close else Icons.AutoMirrored.Filled.MenuOpen
+                            }
+                        }
+                        Icon(
+                            imageVector = imageVector,
+                            contentDescription = "Menu",
+                            modifier = Modifier.animateIcon({ checkedProgress }),
+                        )
                     }
                 }
             ) {
-                Icon(
-                    imageVector = if (state.isDownloadRunning) Icons.Default.Stop else Icons.Default.Download,
-                    contentDescription = null
-                )
+                fabItems.forEach { (icon, label, action) ->
+                    FloatingActionButtonMenuItem(
+                        onClick = {
+                            action()
+                            fabMenuExpanded = false
+                        },
+                        icon = { Icon(icon, contentDescription = null) },
+                        text = { Text(text = label) }
+                    )
+                }
             }
         }
     ) { paddingValues ->
         val renderVersion by rememberUpdatedState(state.cacheVersion)
         LazyColumn(
+            state = listState,
             modifier = Modifier.fillMaxSize(),
             contentPadding = PaddingValues(
                 start = 12.dp,
@@ -404,93 +622,125 @@ private fun CacheScreen(
             ),
             verticalArrangement = Arrangement.spacedBy(8.dp)
         ) {
-            items(state.books, key = { it.bookUrl }) { book ->
+            items(filteredBooks, key = { it.bookUrl }) { book ->
                 val cacheCount = remember(renderVersion, book.bookUrl) {
                     viewModel.getCacheChapters(book.bookUrl)?.size ?: 0
                 }
                 val isDownloading = remember(renderVersion, book.bookUrl) {
                     viewModel.isBookDownloading(book.bookUrl)
                 }
+                val isSelected = selectedBookUrls.contains(book.bookUrl)
                 val exportMsg = remember(renderVersion, book.bookUrl) {
                     ExportBookService.exportMsg[book.bookUrl]
                 }
-                val exportProgress = remember(renderVersion, book.bookUrl) {
-                    ExportBookService.exportProgress[book.bookUrl]
-                }
-                GlassCard(
-                    containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+                NormalCard(
+                    modifier = Modifier.fillMaxWidth(),
+                    onClick = { toggleBookSelection(book) },
+                    onLongClick = { toggleBookSelection(book) },
+                    containerColor = if (isSelected) {
+                        LegadoTheme.colorScheme.secondaryContainer
+                    } else {
+                        LegadoTheme.colorScheme.surfaceContainerLow
+                    }
                 ) {
                     Column(
                         modifier = Modifier
                             .fillMaxWidth()
-                            .padding(12.dp)
+                            .adaptiveHorizontalPadding(vertical = 12.dp),
+                        verticalArrangement = Arrangement.SpaceBetween
                     ) {
                         Row(
                             modifier = Modifier.fillMaxWidth(),
-                            horizontalArrangement = Arrangement.SpaceBetween
+                            horizontalArrangement = Arrangement.spacedBy(8.dp)
                         ) {
-                            AppText(
-                                text = book.name,
-                                maxLines = 1,
-                                overflow = TextOverflow.Ellipsis,
-                                modifier = Modifier.weight(1f)
-                            )
-                            TextCard(text = stringResource(R.string.download_count, cacheCount, book.totalChapterNum))
-                        }
-                        AppText(
-                            text = stringResource(R.string.author_show, book.getRealAuthor()),
-                            maxLines = 1,
-                            overflow = TextOverflow.Ellipsis
-                        )
-                        AppText(
-                            text = if (book.isLocal) {
-                                stringResource(R.string.local_book)
-                            } else {
-                                stringResource(R.string.download_count, cacheCount, book.totalChapterNum)
+                            Column(
+                                modifier = Modifier.weight(1f),
+                                verticalArrangement = Arrangement.spacedBy(4.dp)
+                            ) {
+                                AppText(
+                                    text = book.name,
+                                    style = LegadoTheme.typography.titleSmallEmphasized,
+                                    maxLines = 1
+                                )
+                                AppText(
+                                    text = book.getRealAuthor(),
+                                    style = LegadoTheme.typography.bodySmall
+                                )
+                                AppText(
+                                    text = "${groupNameResolver(book)} | ${book.originName.ifBlank { book.origin }}",
+                                    style = LegadoTheme.typography.labelSmallEmphasized.copy(color = LegadoTheme.colorScheme.primary)
+                                )
+                                if (exportMsg != null) {
+                                    AppText(text = exportMsg, modifier = Modifier.padding(top = 2.dp))
+                                }
                             }
-                        )
-                        if (isDownloading && book.totalChapterNum > 0) {
-                            AppLinearProgressIndicator(
-                                progress = (cacheCount.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
-                                modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
-                            )
-                        }
-                        if (exportMsg != null) {
-                            AppText(text = exportMsg, modifier = Modifier.padding(top = 6.dp))
-                        } else if (exportProgress != null && book.totalChapterNum > 0) {
-                            AppLinearProgressIndicator(
-                                progress = (exportProgress.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
-                                modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
+                            TextCard(
+                                text = if (book.isLocal) {
+                                    stringResource(R.string.local_book)
+                                } else {
+                                    stringResource(
+                                        R.string.download_count,
+                                        cacheCount,
+                                        book.totalChapterNum
+                                    )
+                                }
                             )
                         }
                         Row(
-                            modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+                            modifier = Modifier.fillMaxWidth(),
                             horizontalArrangement = Arrangement.End
                         ) {
-                            SmallTonalIconButton(
-                                onClick = {
-                                    if (!book.isLocal) {
-                                        viewModel.dispatch(CacheIntent.ToggleBookDownload(book))
-                                    }
-                                },
-                                imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
-                                contentDescription = null
-                            )
-                            SmallTonalIconButton(
-                                onClick = { exportBook(book) },
-                                modifier = Modifier.padding(start = 8.dp),
-                                imageVector = Icons.Default.Upload,
-                                contentDescription = null
-                            )
-                            SmallTonalIconButton(
-                                onClick = {
-                                    viewModel.dispatch(CacheIntent.DeleteBookDownload(book.bookUrl))
-                                    viewModel.dispatch(CacheIntent.ClearBookCache(book))
-                                },
-                                modifier = Modifier.padding(start = 8.dp),
-                                imageVector = Icons.Default.Delete,
-                                contentDescription = null
-                            )
+                            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+                                SmallTonalIconButton(
+                                    onClick = {
+                                        if (!book.isLocal) {
+                                            viewModel.dispatch(CacheIntent.ToggleBookDownload(book))
+                                        }
+                                    },
+                                    imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
+                                    contentDescription = "download"
+                                )
+                                SmallTonalIconButton(
+                                    onClick = { exportBook(book) },
+                                    imageVector = Icons.Default.Upload,
+                                    contentDescription = "upload"
+                                )
+                                SmallTonalIconButton(
+                                    onClick = {
+                                        pendingMoveGroupBookUrl = book.bookUrl
+                                        groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
+                                        showGroupSelectSheet = true
+                                    },
+                                    imageVector = Icons.Default.Bookmarks,
+                                    contentDescription = "group"
+                                )
+                                SmallTonalIconButton(
+                                    onClick = { moreMenuBookUrl = book.bookUrl },
+                                    imageVector = Icons.Default.MoreVert,
+                                    contentDescription = "more"
+                                )
+                                RoundDropdownMenu(
+                                    expanded = moreMenuBookUrl == book.bookUrl,
+                                    onDismissRequest = { moreMenuBookUrl = null }
+                                ) { dismiss ->
+                                    RoundDropdownMenuItem(
+                                        text = "删除书籍",
+                                        onClick = {
+                                            pendingDeleteBookUrls = setOf(book.bookUrl)
+                                            deleteOriginalBookFile = LocalConfig.deleteBookOriginal
+                                            showDeleteBookConfirmDialog = true
+                                            dismiss()
+                                        }
+                                    )
+                                    RoundDropdownMenuItem(
+                                        text = "删除缓存",
+                                        onClick = {
+                                            viewModel.dispatch(CacheIntent.ClearCachesForBooks(setOf(book.bookUrl)))
+                                            dismiss()
+                                        }
+                                    )
+                                }
+                            }
                         }
                     }
                 }
@@ -504,35 +754,90 @@ private fun CacheScreen(
         title = exportFolderText,
         onSelectSysDir = {
             showFilePickerSheet = false
-            val default = arrayListOf<SelectItem<Int>>()
-            val path = ACache.get().getAsString(exportBookPathKey)
-            if (!path.isNullOrEmpty()) default.add(SelectItem(path, -1))
-            exportDir.launch {
-                mode = HandleFileContract.DIR_SYS
-                title = exportFolderText
-                otherActions = default
-                requestCode = 0
-            }
+            exportDir.launch(null)
         }
     )
 
     AppAlertDialog(
-        show = showDownloadConfirmDialog,
-        onDismissRequest = { showDownloadConfirmDialog = false },
+        show = showBatchDownloadConfirmDialog,
+        onDismissRequest = { showBatchDownloadConfirmDialog = false },
         title = stringResource(R.string.draw),
         text = stringResource(R.string.sure_cache_book),
         confirmText = stringResource(android.R.string.ok),
         onConfirm = {
-            showDownloadConfirmDialog = false
+            showBatchDownloadConfirmDialog = false
             viewModel.dispatch(
-                CacheIntent.StartDownloadForVisibleBooks(
-                    books = state.books,
+                CacheIntent.DownloadBooks(
+                    bookUrls = selectedBookUrls,
                     downloadAllChapters = false
                 )
             )
         },
         dismissText = stringResource(android.R.string.cancel),
-        onDismiss = { showDownloadConfirmDialog = false }
+        onDismiss = { showBatchDownloadConfirmDialog = false }
+    )
+
+    AppAlertDialog(
+        show = showDeleteBookConfirmDialog,
+        onDismissRequest = { showDeleteBookConfirmDialog = false },
+        title = stringResource(R.string.draw),
+        content = {
+            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                AppText(text = stringResource(R.string.sure_del))
+                if (hasLocalBookInDeleteTarget) {
+                    Row(
+                        modifier = Modifier.fillMaxWidth(),
+                        horizontalArrangement = Arrangement.Start
+                    ) {
+                        Checkbox(
+                            checked = deleteOriginalBookFile,
+                            onCheckedChange = { checked ->
+                                deleteOriginalBookFile = checked
+                            }
+                        )
+                        AppText(
+                            text = stringResource(R.string.delete_book_file),
+                            modifier = Modifier.padding(top = 12.dp)
+                        )
+                    }
+                }
+            }
+        },
+        confirmText = stringResource(android.R.string.ok),
+        onConfirm = {
+            showDeleteBookConfirmDialog = false
+            LocalConfig.deleteBookOriginal = deleteOriginalBookFile
+            viewModel.dispatch(
+                CacheIntent.DeleteBooks(
+                    bookUrls = pendingDeleteBookUrls,
+                    deleteOriginal = deleteOriginalBookFile
+                )
+            )
+            pendingDeleteBookUrls = emptySet()
+            clearSelection()
+        },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { showDeleteBookConfirmDialog = false }
+    )
+
+    GroupSelectSheet(
+        show = showGroupSelectSheet,
+        currentGroupId = groupPickerCurrentGroupId,
+        onDismissRequest = { showGroupSelectSheet = false },
+        onConfirm = { groupId ->
+            val moveSet = pendingMoveGroupBookUrl?.let { setOf(it) } ?: selectedBookUrls
+            val targetGroupId = groupId.coerceAtLeast(0L)
+            viewModel.dispatch(
+                CacheIntent.MoveBooksToGroup(
+                    bookUrls = moveSet,
+                    groupId = targetGroupId
+                )
+            )
+            pendingMoveGroupBookUrl = null
+            groupPickerCurrentGroupId = 0L
+            showGroupSelectSheet = false
+            clearSelection()
+        }
     )
 
     AppAlertDialog(

+ 87 - 12
app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt

@@ -9,6 +9,10 @@ import io.legado.app.data.dao.BookGroupDao
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
+import io.legado.app.domain.usecase.ClearBookCacheUseCase
+import io.legado.app.domain.usecase.DeleteBooksUseCase
+import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isLocal
@@ -61,6 +65,10 @@ sealed interface CacheIntent {
     data class ToggleBookDownload(val book: Book) : CacheIntent
     data class DeleteBookDownload(val bookUrl: String) : CacheIntent
     data class ClearBookCache(val book: Book) : CacheIntent
+    data class MoveBooksToGroup(val bookUrls: Set<String>, val groupId: Long) : CacheIntent
+    data class DeleteBooks(val bookUrls: Set<String>, val deleteOriginal: Boolean) : CacheIntent
+    data class ClearCachesForBooks(val bookUrls: Set<String>) : CacheIntent
+    data class DownloadBooks(val bookUrls: Set<String>, val downloadAllChapters: Boolean) : CacheIntent
     data class SetExportUseReplace(val enabled: Boolean) : CacheIntent
     data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent
     data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent
@@ -83,7 +91,11 @@ class CacheViewModel(
     private val bookDao: BookDao,
     private val bookGroupDao: BookGroupDao,
     private val bookChapterDao: BookChapterDao,
-    val cacheConfig: CacheConfig
+    val cacheConfig: CacheConfig,
+    private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
+    private val clearBookCacheUseCase: ClearBookCacheUseCase,
+    private val deleteBooksUseCase: DeleteBooksUseCase,
+    private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
 ) : BaseViewModel(application) {
 
     private val _uiState = MutableStateFlow(CacheUiState())
@@ -110,6 +122,10 @@ class CacheViewModel(
             is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book)
             is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl)
             is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book)
+            is CacheIntent.MoveBooksToGroup -> moveBooksToGroup(intent.bookUrls, intent.groupId)
+            is CacheIntent.DeleteBooks -> deleteBooks(intent.bookUrls, intent.deleteOriginal)
+            is CacheIntent.ClearCachesForBooks -> clearCachesForBooks(intent.bookUrls)
+            is CacheIntent.DownloadBooks -> downloadBooks(intent.bookUrls, intent.downloadAllChapters)
             is CacheIntent.SetExportUseReplace -> {
                 cacheConfig.exportUseReplace = intent.enabled
                 syncExportConfig()
@@ -317,15 +333,16 @@ class CacheViewModel(
     }
 
     private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
-        books.forEach { book ->
-            val indices = if (downloadAllChapters) {
-                (0..book.lastChapterIndex).toList()
-            } else {
-                (book.durChapterIndex..book.lastChapterIndex).toList()
-            }
-            CacheBook.start(context, book, indices)
+        execute {
+            batchCacheDownloadUseCase.execute(
+                context = context,
+                books = books,
+                downloadAllChapters = downloadAllChapters,
+                skipAudioBooks = true
+            )
+        }.onFinally {
+            syncDownloadRunning()
         }
-        syncDownloadRunning()
     }
 
     private fun toggleBookDownload(book: Book) {
@@ -338,10 +355,68 @@ class CacheViewModel(
         syncDownloadRunning()
     }
 
+    private fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
+        if (bookUrls.isEmpty()) return
+        val safeGroupId = groupId.coerceAtLeast(0L)
+        execute {
+            updateBooksGroupUseCase.replaceGroup(bookUrls, safeGroupId)
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("移动分组失败\n${it.localizedMessage}"))
+        }
+    }
+
+    private fun deleteBooks(bookUrls: Set<String>, deleteOriginal: Boolean) {
+        if (bookUrls.isEmpty()) return
+        execute {
+            deleteBooksUseCase.execute(bookUrls, deleteOriginal)
+        }.onSuccess { deletedBookUrls ->
+            deletedBookUrls.forEach { cacheChapters.remove(it) }
+            _effects.tryEmit(CacheEffect.ShowMessage("删除成功"))
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("删除失败\n${it.localizedMessage}"))
+        }
+    }
+
+    private fun clearCachesForBooks(bookUrls: Set<String>) {
+        if (bookUrls.isEmpty()) return
+        execute {
+            clearBookCacheUseCase.execute(bookUrls)
+        }.onSuccess { clearedBookUrls ->
+            clearedBookUrls.forEach { bookUrl ->
+                cacheChapters[bookUrl] = hashSetOf()
+                emitBookChanged(bookUrl)
+            }
+            _effects.tryEmit(CacheEffect.ShowMessage("缓存已清理"))
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("清理缓存失败\n${it.localizedMessage}"))
+        }
+    }
+
+    private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) {
+        if (bookUrls.isEmpty()) return
+        execute {
+            batchCacheDownloadUseCase.execute(
+                context = context,
+                bookUrls = bookUrls,
+                downloadAllChapters = downloadAllChapters,
+                skipAudioBooks = true
+            )
+        }.onSuccess { count ->
+            if (count > 0) {
+                _effects.tryEmit(CacheEffect.ShowMessage("已加入缓存队列: $count 本"))
+            } else {
+                _effects.tryEmit(CacheEffect.ShowMessage("没有可缓存的书籍"))
+            }
+            syncDownloadRunning()
+        }.onError {
+            _effects.tryEmit(CacheEffect.ShowMessage("批量缓存失败\n${it.localizedMessage}"))
+        }
+    }
+
     private fun clearCacheForBook(book: Book) {
-        BookHelp.clearCache(book)
-        cacheChapters[book.bookUrl] = hashSetOf()
-        emitBookChanged(book.bookUrl)
+        val bookUrl = clearBookCacheUseCase.execute(book)
+        cacheChapters[bookUrl] = hashSetOf()
+        emitBookChanged(bookUrl)
     }
 
     private fun emitBookChanged(bookUrl: String) {

+ 149 - 9
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -6,16 +6,20 @@ import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.grid.GridCells
 import androidx.compose.foundation.lazy.grid.items
 import androidx.compose.foundation.lazy.grid.itemsIndexed
@@ -79,13 +83,16 @@ import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
 import io.legado.app.ui.widget.components.button.TopBarActionButton
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.divider.PillHeaderDivider
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid
 import io.legado.app.ui.widget.components.list.ListScaffold
+import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
@@ -124,6 +131,7 @@ fun BookshelfScreen(
     var showLogSheet by remember { mutableStateOf(false) }
     var showGroupMenu by remember { mutableStateOf(false) }
     var showGroupSelectSheet by remember { mutableStateOf(false) }
+    var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) }
     var isEditMode by remember { mutableStateOf(false) }
     var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
 
@@ -289,18 +297,14 @@ fun BookshelfScreen(
         else -> stringResource(R.string.bookshelf)
     }
     val title = if (isEditMode) {
-        "$currentGroupBookCount · ${selectedBookUrls.size} 本"
+        stringResource(R.string.bookshelf)
     } else if (uiState.upBooksCount > 0) {
         "$baseTitle (${uiState.upBooksCount})"
     } else {
         baseTitle
     }
     val subtitle = if (isEditMode) {
-        if (bookGroupStyle == 0) {
-            "共${allGroupsBookCount}本"
-        } else {
-            "${currentGroupName ?: stringResource(R.string.bookshelf)} · 共${allGroupsBookCount}本"
-        }
+        "共${allGroupsBookCount}本"
     } else {
         null
     }
@@ -323,13 +327,22 @@ fun BookshelfScreen(
         if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) 12.dp else 16.dp
     val gridContentHorizontalPadding = totalHorizontalPadding / 2
     val gridInnerHorizontalPadding = totalHorizontalPadding / 2
+    val currentMenuGroupId = if (uiState.isSearch) uiState.selectedGroupId else currentTabGroupId
+    val editStickySummary = if (isEditMode) {
+        BookshelfEditStickySummary(
+            selectedCount = selectedBookUrls.size,
+            currentGroupTotalCount = currentGroupBookCount,
+            groupName = currentGroupName,
+            showGroupName = bookGroupStyle != 0
+        )
+    } else {
+        null
+    }
 
     ListScaffold(
         title = title,
         subtitle = subtitle,
         state = uiState,
-        onBackClick = if (isEditMode) exitEditMode else null,
-        backNavigationIcon = if (isEditMode) AppIcons.Close else AppIcons.Back,
         showSearchAction = true,
         onSearchToggle = { active ->
             viewModel.setSearchMode(active)
@@ -353,7 +366,7 @@ fun BookshelfScreen(
             }
         },
         topBarActions = {
-            if (isEditMode) {
+            AnimatedVisibility(visible = isEditMode) {
                 TopBarActionButton(
                     onClick = {
                         selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
@@ -361,6 +374,8 @@ fun BookshelfScreen(
                     imageVector = Icons.Default.SelectAll,
                     contentDescription = stringResource(R.string.select_all)
                 )
+            }
+            AnimatedVisibility(visible = isEditMode) {
                 TopBarActionButton(
                     onClick = {
                         val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
@@ -369,6 +384,19 @@ fun BookshelfScreen(
                     imageVector = Icons.Default.Refresh,
                     contentDescription = stringResource(R.string.revert_selection)
                 )
+            }
+            AnimatedVisibility(visible = isEditMode) {
+                TopBarActionButton(
+                    onClick = {
+                        if (selectedBookUrls.isNotEmpty()) {
+                            showBatchDownloadConfirmDialog = true
+                        }
+                    },
+                    imageVector = Icons.Default.Download,
+                    contentDescription = stringResource(R.string.action_download)
+                )
+            }
+            AnimatedVisibility(visible = isEditMode) {
                 TopBarActionButton(
                     onClick = {
                         if (selectedBookUrls.isNotEmpty()) {
@@ -719,6 +747,97 @@ fun BookshelfScreen(
                 }
             }
 
+            TopFloatingStickyItem(
+                item = editStickySummary,
+                modifier = Modifier
+                    .align(Alignment.TopCenter)
+                    .padding(top = paddingValues.calculateTopPadding() + 6.dp),
+            ) { summary ->
+                Box {
+                    NormalCard(
+                        cornerRadius = 32.dp,
+                        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+                        contentColor = LegadoTheme.colorScheme.onCardContainer
+                    ) {
+                        Row(
+                            modifier = Modifier.padding(all = 8.dp),
+                            verticalAlignment = Alignment.CenterVertically
+                        ) {
+                            TextCard(
+                                icon = AppIcons.Close,
+                                backgroundColor = LegadoTheme.colorScheme.surfaceContainerHighest,
+                                cornerRadius = 16.dp,
+                                verticalPadding = 8.dp,
+                                horizontalPadding = 8.dp,
+                                onClick = exitEditMode
+                            )
+                            Spacer(modifier = Modifier.width(8.dp))
+                            AppText(
+                                text = "已选${summary.selectedCount}本",
+                                style = LegadoTheme.typography.labelSmallEmphasized
+                            )
+                            AppText(
+                                text = " · 共${summary.currentGroupTotalCount}本",
+                                style = LegadoTheme.typography.labelSmallEmphasized
+                            )
+                            Spacer(modifier = Modifier.width(8.dp))
+                            if (summary.showGroupName && !summary.groupName.isNullOrBlank()) {
+                                TextCard(
+                                    text = summary.groupName,
+                                    textStyle = LegadoTheme.typography.labelSmallEmphasized,
+                                    backgroundColor = LegadoTheme.colorScheme.surfaceContainerHighest,
+                                    cornerRadius = 16.dp,
+                                    verticalPadding = 8.dp,
+                                    horizontalPadding = 12.dp,
+                                    onClick = { showGroupMenu = true }
+                                )
+                            }
+                        }
+                    }
+                    
+
+                    if (summary.showGroupName) {
+                        RoundDropdownMenu(
+                            expanded = showGroupMenu,
+                            onDismissRequest = { showGroupMenu = false }
+                        ) { dismiss ->
+                            uiState.groups.forEach { group ->
+                                RoundDropdownMenuItem(
+                                    text = group.groupName,
+                                    onClick = {
+                                        val targetIndex =
+                                            uiState.groups.indexOfFirst { it.groupId == group.groupId }
+                                        if (targetIndex >= 0) {
+                                            scope.launch {
+                                                if (pagerState.currentPage != targetIndex) {
+                                                    pagerState.animateScrollToPage(targetIndex)
+                                                }
+                                            }
+                                        }
+                                        if (uiState.isSearch || uiState.selectedGroupId != group.groupId) {
+                                            viewModel.changeGroup(group.groupId)
+                                        }
+                                        if (bookGroupStyle == 2) {
+                                            isInFolderRoot = false
+                                        }
+                                        dismiss()
+                                    },
+                                    trailingIcon = {
+                                        if (currentMenuGroupId == group.groupId) {
+                                            Icon(
+                                                Icons.Default.Check,
+                                                null,
+                                                modifier = Modifier.size(18.dp)
+                                            )
+                                        }
+                                    }
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+
             PullToRefreshDefaults.LoadingIndicator(
                 state = pullToRefreshState,
                 isRefreshing = isRefreshing,
@@ -794,6 +913,20 @@ fun BookshelfScreen(
         onDismissRequest = { showLogSheet = false }
     )
 
+    AppAlertDialog(
+        show = showBatchDownloadConfirmDialog,
+        onDismissRequest = { showBatchDownloadConfirmDialog = false },
+        title = stringResource(R.string.draw),
+        text = stringResource(R.string.sure_cache_book),
+        confirmText = stringResource(android.R.string.ok),
+        onConfirm = {
+            showBatchDownloadConfirmDialog = false
+            viewModel.downloadBooks(selectedBookUrls)
+        },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { showBatchDownloadConfirmDialog = false }
+    )
+
     if (uiState.isLoading) {
         Dialog(onDismissRequest = {}) {
             NormalCard(
@@ -818,6 +951,13 @@ fun BookshelfScreen(
     }
 }
 
+private data class BookshelfEditStickySummary(
+    val selectedCount: Int,
+    val currentGroupTotalCount: Int,
+    val groupName: String?,
+    val showGroupName: Boolean,
+)
+
 @Composable
 fun BookshelfPage(
     paddingValues: PaddingValues,

+ 24 - 19
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -19,6 +19,8 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.UploadRepository
+import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
+import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.addType
@@ -91,7 +93,9 @@ import kotlin.math.min
 class BookshelfViewModel(
     application: Application,
     private val bookGroupRepository: BookGroupRepository,
-    private val uploadRepository: UploadRepository
+    private val uploadRepository: UploadRepository,
+    private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
+    private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
 ) : BaseViewModel(application) {
     var addBookJob: Coroutine<*>? = null
 
@@ -377,30 +381,31 @@ class BookshelfViewModel(
     }
 
     fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
-        updateBooksGroup(bookUrls) { groupId }
+        if (bookUrls.isEmpty()) return
+        execute {
+            updateBooksGroupUseCase.replaceGroup(bookUrls, groupId)
+        }.onError {
+            context.toastOnUi("更新分组失败\n${it.localizedMessage}")
+        }
     }
 
-    private fun updateBooksGroup(
-        bookUrls: Set<String>,
-        transform: (Long) -> Long
-    ) {
+    fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean = false) {
         if (bookUrls.isEmpty()) return
         execute {
-            val updateBooks = bookUrls.mapNotNull { url ->
-                appDb.bookDao.getBook(url)?.let { book ->
-                    val targetGroup = transform(book.group)
-                    if (targetGroup != book.group) {
-                        book.copy(group = targetGroup)
-                    } else {
-                        null
-                    }
-                }
-            }
-            if (updateBooks.isNotEmpty()) {
-                appDb.bookDao.update(*updateBooks.toTypedArray())
+            batchCacheDownloadUseCase.execute(
+                context = context,
+                bookUrls = bookUrls,
+                downloadAllChapters = downloadAllChapters,
+                skipAudioBooks = true
+            )
+        }.onSuccess { count ->
+            if (count > 0) {
+                context.toastOnUi("已加入缓存队列: $count 本")
+            } else {
+                context.toastOnUi(R.string.no_download)
             }
         }.onError {
-            context.toastOnUi("更新分组失败\n${it.localizedMessage}")
+            context.toastOnUi("批量缓存失败\n${it.localizedMessage}")
         }
     }
 

+ 13 - 9
app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt

@@ -25,7 +25,7 @@ import io.legado.app.ui.widget.components.text.AnimatedTextLine
 @Composable
 fun TextCard(
     modifier: Modifier = Modifier,
-    text: String,
+    text: String? = null,
     icon: ImageVector? = null,
     onClick: (() -> Unit)? = null,
     backgroundColor: Color? = null,
@@ -65,16 +65,20 @@ fun TextCard(
                     tint = finalContentColor,
                     modifier = Modifier.size(iconSize)
                 )
-                Spacer(modifier = Modifier.width(spacing))
             }
 
-            AnimatedTextLine(
-                text = text,
-                style = textStyle,
-                color = finalContentColor,
-                maxLines = 2,
-                overflow = TextOverflow.Ellipsis
-            )
+            if (icon != null && text != null)
+                Spacer(modifier = Modifier.width(spacing))
+
+            text?.let {
+                AnimatedTextLine(
+                    text = it,
+                    style = textStyle,
+                    color = finalContentColor,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis
+                )
+            }
         }
     }
 }