Browse Source

主页模块初始版本

HapeLee 5 tháng trước cách đây
mục cha
commit
b00944b612
69 tập tin đã thay đổi với 7796 bổ sung và 469 xóa
  1. 2 1
      .claude/settings.local.json
  2. 2299 0
      app/schemas/io.legado.app.data.AppDatabase/88.json
  3. 240 0
      app/src/main/assets/web/help/md/homepageModulesHelp.md
  4. 5 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  5. 10 3
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  6. 0 1
      app/src/main/java/io/legado/app/data/DatabaseMigrations.kt
  7. 15 1
      app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt
  8. 30 0
      app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt
  9. 48 0
      app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt
  10. 6 1
      app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt
  11. 4 1
      app/src/main/java/io/legado/app/data/entities/BookSource.kt
  12. 12 0
      app/src/main/java/io/legado/app/data/entities/HomepageCustomSet.kt
  13. 25 0
      app/src/main/java/io/legado/app/data/entities/HomepageModule.kt
  14. 86 2
      app/src/main/java/io/legado/app/data/repository/BookRepository.kt
  15. 55 0
      app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt
  16. 51 0
      app/src/main/java/io/legado/app/data/repository/BookshelfRepository.kt
  17. 0 14
      app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt
  18. 94 0
      app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt
  19. 12 5
      app/src/main/java/io/legado/app/data/repository/SearchRepository.kt
  20. 25 1
      app/src/main/java/io/legado/app/di/appDatabaseModule.kt
  21. 24 3
      app/src/main/java/io/legado/app/di/appModule.kt
  22. 33 0
      app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt
  23. 71 0
      app/src/main/java/io/legado/app/domain/model/HomepageModels.kt
  24. 71 0
      app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt
  25. 71 0
      app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt
  26. 79 0
      app/src/main/java/io/legado/app/domain/usecase/ExportBookshelfUseCase.kt
  27. 109 0
      app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt
  28. 61 0
      app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt
  29. 11 0
      app/src/main/java/io/legado/app/domain/usecase/SaveSearchBooksUseCase.kt
  30. 34 24
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  31. 25 26
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt
  32. 19 4
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt
  33. 2 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  34. 7 1
      app/src/main/java/io/legado/app/ui/main/MainDestination.kt
  35. 0 7
      app/src/main/java/io/legado/app/ui/main/MainFragmentInterface.kt
  36. 12 0
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  37. 48 279
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  38. 26 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt
  39. 76 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt
  40. 17 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt
  41. 51 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt
  42. 1089 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt
  43. 617 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt
  44. 92 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt
  45. 882 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt
  46. 57 0
      app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt
  47. 171 0
      app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt
  48. 101 0
      app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt
  49. 60 0
      app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt
  50. 161 0
      app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt
  51. 146 0
      app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt
  52. 126 0
      app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt
  53. 18 0
      app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt
  54. 48 15
      app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt
  55. 2 1
      app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt
  56. 2 1
      app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
  57. 4 2
      app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt
  58. 138 0
      app/src/main/java/io/legado/app/ui/widget/components/JsonConfigEditor.kt
  59. 88 0
      app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt
  60. 44 25
      app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt
  61. 15 1
      app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
  62. 51 44
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  63. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt
  64. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt
  65. 3 0
      app/src/main/res/values-zh-rCN/strings.xml
  66. 3 0
      app/src/main/res/values-zh-rHK/strings.xml
  67. 3 0
      app/src/main/res/values-zh-rTW/strings.xml
  68. 3 0
      app/src/main/res/values/strings.xml
  69. 2 2
      app/version.properties

+ 2 - 1
.claude/settings.local.json

@@ -101,7 +101,8 @@
       "Read(//c/Users/**)",
       "Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)",
       "Bash(Select-Object Name)",
-      "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })"
+      "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })",
+      "Bash(powershell *)"
     ]
   }
 }

+ 2299 - 0
app/schemas/io.legado.app.data.AppDatabase/88.json

@@ -0,0 +1,2299 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 88,
+    "identityHash": "7cbef4b2b125c9742578e51c4f8b5a09",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          },
+          {
+            "name": "index_books_durChapterTime",
+            "unique": false,
+            "columnNames": [
+              "durChapterTime"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_groups",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`groupId`))",
+        "fields": [
+          {
+            "fieldPath": "groupId",
+            "columnName": "groupId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cover",
+            "columnName": "cover",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enableRefresh",
+            "columnName": "enableRefresh",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "show",
+            "columnName": "show",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "bookSort",
+            "columnName": "bookSort",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "groupId"
+          ]
+        }
+      },
+      {
+        "tableName": "book_sources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookSourceUrl",
+            "columnName": "bookSourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceName",
+            "columnName": "bookSourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceGroup",
+            "columnName": "bookSourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceType",
+            "columnName": "bookSourceType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrlPattern",
+            "columnName": "bookUrlPattern",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "enabledExplore",
+            "columnName": "enabledExplore",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceComment",
+            "columnName": "bookSourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "weight",
+            "columnName": "weight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exploreUrl",
+            "columnName": "exploreUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "exploreScreen",
+            "columnName": "exploreScreen",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleExplore",
+            "columnName": "ruleExplore",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleSearch",
+            "columnName": "ruleSearch",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleBookInfo",
+            "columnName": "ruleBookInfo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleToc",
+            "columnName": "ruleToc",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleReview",
+            "columnName": "ruleReview",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "eventListener",
+            "columnName": "eventListener",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customButton",
+            "columnName": "customButton",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "homepageModules",
+            "columnName": "homepageModules",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookSourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_sources_bookSourceUrl",
+            "unique": false,
+            "columnNames": [
+              "bookSourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapters",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVolume",
+            "columnName": "isVolume",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "index",
+            "columnName": "index",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVip",
+            "columnName": "isVip",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isPay",
+            "columnName": "isPay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resourceUrl",
+            "columnName": "resourceUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "startFragmentId",
+            "columnName": "startFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "endFragmentId",
+            "columnName": "endFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "reviewImg",
+            "columnName": "reviewImg",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url",
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapters_bookUrl",
+            "unique": false,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_chapters_bookUrl_index",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "index"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookUrl"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "replace_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "replacement",
+            "columnName": "replacement",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "scope",
+            "columnName": "scope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "scopeTitle",
+            "columnName": "scopeTitle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "scopeContent",
+            "columnName": "scopeContent",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "excludeScope",
+            "columnName": "excludeScope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "isRegex",
+            "columnName": "isRegex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "timeoutMillisecond",
+            "columnName": "timeoutMillisecond",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3000"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_replace_rules_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ]
+      },
+      {
+        "tableName": "searchBooks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterWordCountText",
+            "columnName": "chapterWordCountText",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chapterWordCount",
+            "columnName": "chapterWordCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_searchBooks_bookUrl",
+            "unique": true,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_searchBooks_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "book_sources",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "origin"
+            ],
+            "referencedColumns": [
+              "bookSourceUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "search_keywords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))",
+        "fields": [
+          {
+            "fieldPath": "word",
+            "columnName": "word",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "usage",
+            "columnName": "usage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastUseTime",
+            "columnName": "lastUseTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "word"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_keywords_word",
+            "unique": true,
+            "columnNames": [
+              "word"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cookies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cookie",
+            "columnName": "cookie",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cookies_url",
+            "unique": true,
+            "columnNames": [
+              "url"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssSources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceName",
+            "columnName": "sourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceIcon",
+            "columnName": "sourceIcon",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceGroup",
+            "columnName": "sourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceComment",
+            "columnName": "sourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortUrl",
+            "columnName": "sortUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "singleUrl",
+            "columnName": "singleUrl",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "articleStyle",
+            "columnName": "articleStyle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "ruleArticles",
+            "columnName": "ruleArticles",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleNextPage",
+            "columnName": "ruleNextPage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleTitle",
+            "columnName": "ruleTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "rulePubDate",
+            "columnName": "rulePubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleDescription",
+            "columnName": "ruleDescription",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleImage",
+            "columnName": "ruleImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleLink",
+            "columnName": "ruleLink",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentWhitelist",
+            "columnName": "contentWhitelist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentBlacklist",
+            "columnName": "contentBlacklist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "shouldOverrideUrlLoading",
+            "columnName": "shouldOverrideUrlLoading",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "style",
+            "columnName": "style",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enableJs",
+            "columnName": "enableJs",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "loadWithBaseUrl",
+            "columnName": "loadWithBaseUrl",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "injectJs",
+            "columnName": "injectJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "preloadJs",
+            "columnName": "preloadJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startHtml",
+            "columnName": "startHtml",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startStyle",
+            "columnName": "startStyle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startJs",
+            "columnName": "startJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "showWebLog",
+            "columnName": "showWebLog",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "preload",
+            "columnName": "preload",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "cacheFirst",
+            "columnName": "cacheFirst",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "redirectPolicy",
+            "columnName": "redirectPolicy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'ASK_CROSS_ORIGIN'"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "sourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssSources_sourceUrl",
+            "unique": false,
+            "columnNames": [
+              "sourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "bookmarks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
+        "fields": [
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPos",
+            "columnName": "chapterPos",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookText",
+            "columnName": "bookText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "time"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_bookmarks_bookName_bookAuthor",
+            "unique": false,
+            "columnNames": [
+              "bookName",
+              "bookAuthor"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssArticles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "date",
+            "columnName": "date",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "readWords",
+            "columnName": "readWords",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "firstReadTime",
+            "columnName": "firstReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastReadTime",
+            "columnName": "lastReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor",
+            "date"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordSession",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "rule",
+            "columnName": "rule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "search_content_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "query",
+            "columnName": "query",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_content_history_bookName_bookAuthor_query",
+            "unique": true,
+            "columnNames": [
+              "bookName",
+              "bookAuthor",
+              "query"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)"
+          }
+        ]
+      },
+      {
+        "tableName": "homepage_modules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "moduleKey",
+            "columnName": "moduleKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "args",
+            "columnName": "args",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "layoutConfig",
+            "columnName": "layoutConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customSetId",
+            "columnName": "customSetId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isUserCreated",
+            "columnName": "isUserCreated",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customTitle",
+            "columnName": "customTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customSetTitle",
+            "columnName": "customSetTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceJsonHash",
+            "columnName": "sourceJsonHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncedAt",
+            "columnName": "syncedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "homepage_custom_sets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      }
+    ],
+    "views": [
+      {
+        "viewName": "book_sources_part",
+        "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n    (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n    (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n    from book_sources"
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7cbef4b2b125c9742578e51c4f8b5a09')"
+    ]
+  }
+}

+ 240 - 0
app/src/main/assets/web/help/md/homepageModulesHelp.md

@@ -0,0 +1,240 @@
+# 首页模块 (homepageModules)
+
+在书源编辑页的「发现」Tab 中,可以配置 `首页模块 (JSON)` 字段,声明该书源为首页提供哪些内容模块。
+
+## 概述
+
+- 每个模块代表首页上的一个内容区块(如轮播图、排行榜、网格书架等)
+- 一个书源可以声明多个模块(例如同时提供"热门推荐"Banner 和"周榜"排行榜)
+- 用户在首页可以自由拖拽排序、隐藏/显示各个模块
+- 模块内容来自书源的**发现(explore)**接口,通过 `kindTitle` 匹配分类URL
+
+## JSON 格式
+
+`homepageModules` 是一个 JSON 数组,每个元素定义一个模块:
+
+```json
+[
+  {
+    "key": "模块唯一标识",
+    "type": "模块类型",
+    "title": "模块标题",
+    "kindTitle": "匹配的分类标题(可选)",
+    "url": "覆盖分类URL(可选)",
+    "args": "特殊参数(可选)",
+    "layoutConfig": {
+      "columns": 2,
+      "rows": 3
+    }
+  }
+]
+```
+
+### 字段说明
+
+| 字段             | 类型        | 必填 | 说明                                                                                                    |
+|----------------|-----------|----|-------------------------------------------------------------------------------------------------------|
+| `key`          | `String`  | 是  | 模块在书源内的唯一标识,用于关联用户偏好。建议使用英文,如 `"hot"`, `"rank_week"`                                                  |
+| `type`         | `String`  | 是  | 模块类型,可选值:`"banner"`, `"ranking"`, `"gridRanking"`, `"grid"`, `"card"`, `"waterfall"`, `"buttonGroup"` |
+| `title`        | `String`  | 是  | 模块标题,会在首页模块头部展示                                                                                       |
+| `kindTitle`    | `String?` | 否  | 用于匹配该书源「发现」中的分类标题。不填则使用默认 `exploreUrl`                                                                |
+| `url`          | `String?` | 否  | 显式指定该模块的 URL,优先级高于 `kindTitle` 匹配到的 URL                                                               |
+| `args`         | `String?` | 否  | 模块特有参数。在 `buttonGroup` 中为包含分类标题的 JSON 数组字符串                                                           |
+| `layoutConfig` | `Object?` | 否  | 布局配置对象。支持 `columns` (列数), `rows` (行数)                                                                 |
+
+## 模块类型
+
+### banner — 横滑轮播图
+
+适合展示热门推荐、本周强推等内容。以大图封面横向滑动展示。
+
+```json
+{
+  "key": "hot_banner",
+  "type": "banner",
+  "title": "热门推荐",
+  "kindTitle": "热门",
+  "displayCount": 6
+}
+```
+
+### ranking — 排行榜
+
+带排名序号的列表。前三名高亮为橙色,其余为灰色。默认折叠显示前 5 本,末尾有"展开更多"按钮。
+
+```json
+{
+  "key": "week_rank",
+  "type": "ranking",
+  "title": "周排行榜",
+  "kindTitle": "周榜",
+  "displayCount": 10
+}
+```
+
+### gridRanking — 网格排行
+
+4×4 列式排列(可通过 `layoutConfig.rows` 修改行数),每页多本书,可横向翻页。封面较小,仅显示书名和作者。适合作为首页入口展示大量书籍。
+
+```json
+{
+  "key": "all_grid",
+  "type": "gridRanking",
+  "title": "全部分类",
+  "kindTitle": "全部分类",
+  "layoutConfig": {
+    "rows": 4
+  }
+}
+```
+
+### grid — 网格书架
+
+网格布局,适合展示分类书单。支持自定义行列。
+
+- `columns`: 默认 3
+- `rows`: 默认 2。若设置为 `0`,则显示为平铺列表并支持下拉加载更多。
+
+```json
+{
+  "key": "scifi_grid",
+  "type": "grid",
+  "title": "科幻精选",
+  "kindTitle": "科幻",
+  "layoutConfig": {
+    "columns": 3,
+    "rows": 2
+  }
+}
+```
+
+### card — 推荐卡片
+
+大图横向滑动卡片,展示封面 + 书名 + 简介。适合需要更多信息展示的场景。
+
+```json
+{
+  "key": "editor_pick",
+  "type": "card",
+  "title": "编辑推荐",
+  "kindTitle": "编辑推荐"
+}
+```
+
+### waterfall — 错位瀑布流
+
+瀑布流布局,展示封面 + 书名 + 简介。支持 `layoutConfig.columns` 自定义列数(默认 2)。支持无限加载更多。
+
+```json
+{
+  "key": "hot_wf",
+  "type": "waterfall",
+  "title": "大家都在看",
+  "kindTitle": "热门",
+  "layoutConfig": {
+    "columns": 2
+  }
+}
+```
+
+### buttonGroup — 按钮组
+
+显示为网格排列的分类按钮。适合放置常用的分类或动作入口。
+
+- **布局特性**:自动平衡每行按钮数量(例如 6 个按钮显示为 3+3,8 个显示为 4+4)。每行最多 5 个。
+- **args**: 可选。由分类标题组成的 JSON 数组字符串,如 `["排行", "分类", "完本"]`。若不填则默认显示该书源前
+  5 个分类。
+- **layoutConfig**:
+    - `icon`: 全局默认图标。支持网络图片 URL。
+    - `icons`: 图标映射对象。以分类标题为键,网络图片 URL 为值。
+
+```json
+{
+  "key": "entry_buttons",
+  "type": "buttonGroup",
+  "title": "快捷入口",
+  "args": "[\"排行\", \"分类\", \"我的\"]",
+  "layoutConfig": {
+    "icon": "https://example.com/default.png",
+    "icons": {
+      "排行": "https://example.com/rank.png",
+      "我的": "https://example.com/my.png"
+    }
+  }
+}
+```
+
+## kindTitle 匹配规则
+
+模块加载时,系统会根据 `kindTitle` 在书源的「发现」分类列表中进行匹配:
+
+1. 在 `exploreKinds()`(即发现页的分类列表)中查找 `title` 等于 `kindTitle` 的分类
+2. 如果匹配成功,使用该分类的 `url` 来加载数据
+3. 如果匹配失败,或 `kindTitle` 为空,则使用书源的默认 `exploreUrl`
+4. 如果两个 URL 都没有,该模块将显示加载错误
+
+**建议**:确保 `kindTitle` 的值与发现页分类的标题**完全一致**(包括大小写和标点)。
+
+## 完整示例
+
+以下是一个书源配置了多种模块的完整 JSON:
+
+```json
+[
+  {
+    "key": "hot",
+    "type": "banner",
+    "title": "热门推荐",
+    "kindTitle": "热门",
+    "displayCount": 6
+  },
+  {
+    "key": "rank_week",
+    "type": "ranking",
+    "title": "周榜",
+    "kindTitle": "周排行",
+    "displayCount": 10
+  },
+  {
+    "key": "rank_month",
+    "type": "ranking",
+    "title": "月榜",
+    "kindTitle": "月排行",
+    "displayCount": 10
+  },
+  {
+    "key": "scifi",
+    "type": "grid",
+    "title": "科幻精选",
+    "kindTitle": "科幻",
+    "displayCount": 6
+  },
+  {
+    "key": "new_book",
+    "type": "card",
+    "title": "新书上架",
+    "kindTitle": "新书",
+    "displayCount": 8
+  }
+]
+```
+
+每个 `key` 必须唯一,系统会按 JSON 数组中的声明顺序创建模块标识。
+
+## 用户体验
+
+- **发现模块**:首页会自动出现配置了 `homepageModules` 的已启用书源所声明的模块
+- **拖拽排序**:长按模块标题旁的拖拽手柄可调整顺序
+- **隐藏模块**:编辑模式下可隐藏不需要的模块
+- **下拉刷新**:刷新后所有模块重新加载数据
+- **点击书籍**:跳转到书籍详情页
+- **点击模块标题**:跳转到该书源的完整发现页
+
+## 注意事项
+
+- JSON 格式必须**严格有效**,建议使用在线 JSON 校验工具检查
+- 如果 JSON 解析失败,该书源的所有模块将被静默跳过
+- 模块使用的图片从书籍封面(`coverUrl`)获取,请确保发现规则正确提取了封面
+- `homepageModules` 为空的旧书源不会报错,也不会有首页模块展示
+- 同一书源可配置多个同类型模块(如多个排行榜),只需 `key` 不同即可
+- 模块标识全局唯一 ID 格式为 `"{setId}::{书源URL}::{key}"`,其中 `setId` 用于区分模块所属的集(书源集或自定义集)。

+ 5 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -319,4 +319,9 @@ object PreferKey {
     const val navIconExplore = "navIconExplore"
     const val navIconRss = "navIconRss"
     const val navIconMy = "navIconMy"
+
+    const val homepageModuleOrder = "homepageModuleOrder"
+    const val homepageModuleHidden = "homepageModuleHidden"
+    const val homepageLayoutMode = "homepageLayoutMode"
+    const val navIconHome = "navIconHome"
 }

+ 10 - 3
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -15,6 +15,8 @@ import io.legado.app.data.dao.BookmarkDao
 import io.legado.app.data.dao.CacheDao
 import io.legado.app.data.dao.CookieDao
 import io.legado.app.data.dao.DictRuleDao
+import io.legado.app.data.dao.HomepageCustomSetDao
+import io.legado.app.data.dao.HomepageModuleDao
 import io.legado.app.data.dao.HttpTTSDao
 import io.legado.app.data.dao.KeyboardAssistsDao
 import io.legado.app.data.dao.ReadRecordDao
@@ -38,6 +40,8 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.Cache
 import io.legado.app.data.entities.Cookie
 import io.legado.app.data.entities.DictRule
+import io.legado.app.data.entities.HomepageCustomSet
+import io.legado.app.data.entities.HomepageModule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.KeyboardAssist
 import io.legado.app.data.entities.ReplaceRule
@@ -69,7 +73,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 87,
+    version = 88,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -77,7 +81,7 @@ val appDb by lazy {
         RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class,
         RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
         RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class,
-        SearchContentHistory::class],
+        SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class],
     views = [BookSourcePart::class],
     autoMigrations = [
         AutoMigration(from = 43, to = 44),
@@ -123,7 +127,8 @@ val appDb by lazy {
         AutoMigration(from = 83, to = 84),
         AutoMigration(from = 84, to = 85),
         AutoMigration(from = 85, to = 86),
-        AutoMigration(from = 86, to = 87)
+        AutoMigration(from = 86, to = 87),
+        AutoMigration(from = 87, to = 88)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {
@@ -150,6 +155,8 @@ abstract class AppDatabase : RoomDatabase() {
     abstract val keyboardAssistsDao: KeyboardAssistsDao
     abstract val serverDao: ServerDao
     abstract val searchContentHistoryDao: SearchContentHistoryDao
+    abstract val homepageModuleDao: HomepageModuleDao
+    abstract val homepageCustomSetDao: HomepageCustomSetDao
 
     companion object {
 

+ 0 - 1
app/src/main/java/io/legado/app/data/DatabaseMigrations.kt

@@ -497,5 +497,4 @@ object DatabaseMigrations {
         columnName = "enabledReview"
     )
     class Migration_64_65 : AutoMigrationSpec
-
 }

+ 15 - 1
app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt

@@ -83,7 +83,21 @@ interface BookSourceDao {
     fun flowDisabled(): Flow<List<BookSourcePart>>
 
     @Query(
-        """select * from book_sources_part 
+        """select * from book_sources
+        where enabled = 1 and enabledExplore = 1 and homepageModules is not null
+        order by customOrder asc"""
+    )
+    fun flowHomepageModules(): Flow<List<BookSource>>
+
+    @Query(
+        """select * from book_sources
+        where enabled = 1 and enabledExplore = 1
+        order by customOrder asc"""
+    )
+    fun flowExploreSources(): Flow<List<BookSource>>
+
+    @Query(
+        """select * from book_sources_part
         where enabledExplore = 1 and hasExploreUrl = 1 order by customOrder asc"""
     )
     fun flowExplore(): Flow<List<BookSourcePart>>

+ 30 - 0
app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt

@@ -0,0 +1,30 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.legado.app.data.entities.HomepageCustomSet
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface HomepageCustomSetDao {
+
+    @Query("SELECT * FROM homepage_custom_sets ORDER BY sortOrder ASC")
+    fun flowAll(): Flow<List<HomepageCustomSet>>
+
+    @Query("SELECT * FROM homepage_custom_sets WHERE id = :id")
+    suspend fun getById(id: String): HomepageCustomSet?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsert(customSet: HomepageCustomSet)
+
+    @Query("UPDATE homepage_custom_sets SET name = :name WHERE id = :id")
+    suspend fun rename(id: String, name: String)
+
+    @Query("UPDATE homepage_custom_sets SET sortOrder = :order WHERE id = :id")
+    suspend fun setSortOrder(id: String, order: Int)
+
+    @Query("DELETE FROM homepage_custom_sets WHERE id = :id")
+    suspend fun delete(id: String)
+}

+ 48 - 0
app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt

@@ -0,0 +1,48 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.legado.app.data.entities.HomepageModule
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface HomepageModuleDao {
+
+    @Query("SELECT * FROM homepage_modules WHERE isEnabled = 1 ORDER BY sortOrder ASC")
+    fun flowEnabled(): Flow<List<HomepageModule>>
+
+    @Query("SELECT * FROM homepage_modules WHERE sourceUrl = :sourceUrl ORDER BY sortOrder ASC")
+    fun flowBySource(sourceUrl: String): Flow<List<HomepageModule>>
+
+    @Query("SELECT * FROM homepage_modules WHERE id = :id")
+    suspend fun getById(id: String): HomepageModule?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertAll(modules: List<HomepageModule>)
+
+    @Query("UPDATE homepage_modules SET isEnabled = :enabled WHERE id = :id")
+    suspend fun setEnabled(id: String, enabled: Boolean)
+
+    @Query("UPDATE homepage_modules SET sortOrder = :order WHERE id = :id")
+    suspend fun setSortOrder(id: String, order: Int)
+
+    @Query("UPDATE homepage_modules SET customSetTitle = :title WHERE id = :id")
+    suspend fun setCustomSetTitle(id: String, title: String?)
+
+    @Query("UPDATE homepage_modules SET customSetId = :setId WHERE id = :id")
+    suspend fun setCustomSetId(id: String, setId: String?)
+
+    @Query("DELETE FROM homepage_modules WHERE id = :id")
+    suspend fun delete(id: String)
+
+    @Query("DELETE FROM homepage_modules WHERE customSetId = :setId")
+    suspend fun deleteByCustomSetId(setId: String)
+
+    @Query("DELETE FROM homepage_modules WHERE sourceUrl = :sourceUrl AND isUserCreated = 0 AND id NOT IN (:currentIds)")
+    suspend fun deleteStale(sourceUrl: String, currentIds: List<String>)
+
+    @Query("SELECT * FROM homepage_modules ORDER BY sortOrder ASC")
+    fun flowAll(): Flow<List<HomepageModule>>
+}

+ 6 - 1
app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt

@@ -1,6 +1,11 @@
 package io.legado.app.data.dao
 
-import androidx.room.*
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
 import io.legado.app.data.entities.SearchBook
 
 @Dao

+ 4 - 1
app/src/main/java/io/legado/app/data/entities/BookSource.kt

@@ -98,7 +98,9 @@ data class BookSource(
     @ColumnInfo(defaultValue = "0")
     var eventListener: Boolean = false, // 是否监听事件来执行回调规则
     @ColumnInfo(defaultValue = "0")
-    var customButton: Boolean = false //由书源控制的自定义按钮
+    var customButton: Boolean = false, //由书源控制的自定义按钮
+    // 首页模块定义,JSON数组。每个元素: key, type(banner/ranking/grid/card/filter), title, args?, url?
+    var homepageModules: String? = null
 ) : Parcelable, BaseSource {
 
     override fun getTag(): String {
@@ -254,6 +256,7 @@ data class BookSource(
                 && equal(loginUi, source.loginUi)
                 && equal(loginCheckJs, source.loginCheckJs)
                 && equal(coverDecodeJs, source.coverDecodeJs)
+                && equal(homepageModules, source.homepageModules)
                 && equal(exploreUrl, source.exploreUrl)
                 && equal(searchUrl, source.searchUrl)
                 && getSearchRule() == source.getSearchRule()

+ 12 - 0
app/src/main/java/io/legado/app/data/entities/HomepageCustomSet.kt

@@ -0,0 +1,12 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "homepage_custom_sets")
+data class HomepageCustomSet(
+    @PrimaryKey
+    var id: String = "",
+    var name: String = "",
+    var sortOrder: Int = 0,
+)

+ 25 - 0
app/src/main/java/io/legado/app/data/entities/HomepageModule.kt

@@ -0,0 +1,25 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "homepage_modules")
+data class HomepageModule(
+    @PrimaryKey
+    var id: String = "",
+    var sourceUrl: String = "",
+    var moduleKey: String = "",
+    var type: String = "",
+    var title: String = "",
+    var args: String? = null,
+    var layoutConfig: String? = null,
+    var url: String? = null,
+    var isEnabled: Boolean = true,
+    var sortOrder: Int = 0,
+    var customSetId: String? = null,
+    var isUserCreated: Boolean = false,
+    var customTitle: String? = null,
+    var customSetTitle: String? = null,
+    var sourceJsonHash: String? = null,
+    var syncedAt: Long = 0,
+)

+ 86 - 2
app/src/main/java/io/legado/app/data/repository/BookRepository.kt

@@ -1,9 +1,11 @@
 package io.legado.app.data.repository
 
-import io.legado.app.data.appDb
 import io.legado.app.data.dao.BookChapterDao
 import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.GroupBookCount
 import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
@@ -33,7 +35,89 @@ class BookRepository(
     }
 
     suspend fun getBook(bookUrl: String): Book? {
-        return appDb.bookDao.getBook(bookUrl)
+        return withContext(Dispatchers.IO) {
+            bookDao.getBook(bookUrl)
+        }
+    }
+
+    suspend fun getBook(name: String, author: String): Book? {
+        return withContext(Dispatchers.IO) {
+            bookDao.getBook(name, author)
+        }
+    }
+
+    fun flowBookShelfByGroup(groupId: Long): Flow<List<BookShelfItem>> {
+        return bookDao.flowBookShelfByGroup(groupId)
+    }
+
+    fun flowSystemGroupCounts(): Flow<List<GroupBookCount>> {
+        return bookDao.flowSystemGroupCounts()
+    }
+
+    fun flowAllBookShelfCount(): Flow<Int> {
+        return bookDao.flowAllBookShelfCount()
+    }
+
+    fun flowUserGroupBookCount(groupId: Long): Flow<Int> {
+        return bookDao.flowUserGroupBookCount(groupId)
+    }
+
+    fun flowGroupPreview(groupId: Long): Flow<List<BookShelfItem>> {
+        return bookDao.flowGroupPreview(groupId)
+    }
+
+    suspend fun getChapterCount(bookUrl: String): Int {
+        return withContext(Dispatchers.IO) {
+            bookChapterDao.getChapterCount(bookUrl)
+        }
+    }
+
+    suspend fun getVolumeCount(bookUrl: String): Int {
+        return withContext(Dispatchers.IO) {
+            bookChapterDao.getVolumeCount(bookUrl)
+        }
+    }
+
+    suspend fun update(vararg book: Book) {
+        withContext(Dispatchers.IO) {
+            bookDao.update(*book)
+        }
+    }
+
+    suspend fun getMinOrder(): Int {
+        return withContext(Dispatchers.IO) {
+            bookDao.minOrder
+        }
+    }
+
+    suspend fun insert(book: Book) {
+        withContext(Dispatchers.IO) {
+            bookDao.insert(book)
+        }
+    }
+
+    suspend fun insertChapters(vararg chapters: BookChapter) {
+        withContext(Dispatchers.IO) {
+            bookChapterDao.insert(*chapters)
+        }
+    }
+
+    suspend fun getHasUpdateBooks(): List<Book> {
+        return withContext(Dispatchers.IO) {
+            bookDao.hasUpdateBooks
+        }
+    }
+
+    suspend fun replace(oldBook: Book, newBook: Book) {
+        withContext(Dispatchers.IO) {
+            bookDao.replace(oldBook, newBook)
+        }
+    }
+
+    suspend fun deleteChaptersByBook(bookUrl: String) {
+        withContext(Dispatchers.IO) {
+            bookChapterDao.delByBook(bookUrl)
+        }
     }
 
 }

+ 55 - 0
app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt

@@ -0,0 +1,55 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.BookSourceDao
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.BookSourcePart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+
+class BookSourceRepository(private val bookSourceDao: BookSourceDao) {
+
+    fun flowAll(): Flow<List<BookSourcePart>> {
+        return bookSourceDao.flowAll()
+    }
+
+    fun flowEnabled(): Flow<List<BookSourcePart>> {
+        return bookSourceDao.flowEnabled()
+    }
+
+    fun flowHomepageModules(): Flow<List<BookSource>> {
+        return bookSourceDao.flowHomepageModules()
+    }
+
+    fun flowExploreSources(): Flow<List<BookSource>> {
+        return bookSourceDao.flowExploreSources()
+    }
+
+    suspend fun getBookSource(sourceUrl: String): BookSource? {
+        return withContext(Dispatchers.IO) {
+            bookSourceDao.getBookSource(sourceUrl)
+        }
+    }
+
+    fun getBookSourceSync(sourceUrl: String): BookSource? {
+        return bookSourceDao.getBookSource(sourceUrl)
+    }
+
+    suspend fun getBookSourceAddBook(baseUrl: String): BookSource? {
+        return withContext(Dispatchers.IO) {
+            bookSourceDao.getBookSourceAddBook(baseUrl)
+        }
+    }
+
+    suspend fun getHasBookUrlPattern(): List<BookSourcePart> {
+        return withContext(Dispatchers.IO) {
+            bookSourceDao.hasBookUrlPattern
+        }
+    }
+
+    suspend fun getAllEnabledPart(): List<BookSourcePart> {
+        return withContext(Dispatchers.IO) {
+            bookSourceDao.allEnabledPart
+        }
+    }
+}

+ 51 - 0
app/src/main/java/io/legado/app/data/repository/BookshelfRepository.kt

@@ -0,0 +1,51 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.ui.main.bookshelf.BookShelfItem
+import io.legado.app.utils.cnCompare
+import kotlin.math.max
+
+class BookshelfRepository {
+    fun sortBooks(
+        list: List<BookShelfItem>,
+        group: BookGroup?,
+        sort: Int,
+        sortOrder: Int
+    ): List<BookShelfItem> {
+        val bookSort = if (group != null && group.bookSort >= 0) {
+            group.bookSort
+        } else {
+            sort
+        }
+        val isDescending = sortOrder == 1
+
+        return when (bookSort) {
+            1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
+            else list.sortedBy { it.latestChapterTime }
+
+            2 -> if (isDescending)
+                list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) }
+            else
+                list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
+
+            3 -> if (isDescending) list.sortedByDescending { it.order }
+            else list.sortedBy { it.order }
+
+            4 -> if (isDescending) list.sortedByDescending {
+                max(
+                    it.latestChapterTime,
+                    it.durChapterTime
+                )
+            }
+            else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) }
+
+            5 -> if (isDescending)
+                list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) }
+            else
+                list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
+
+            else -> if (isDescending) list.sortedByDescending { it.durChapterTime }
+            else list.sortedBy { it.durChapterTime }
+        }
+    }
+}

+ 0 - 14
app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt

@@ -7,7 +7,6 @@ import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.help.source.exploreKinds
-import io.legado.app.model.webBook.WebBook
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
@@ -18,7 +17,6 @@ interface ExploreRepository {
     fun getExploreGroups(): Flow<List<String>>
     fun getExploreSources(query: String, selectedGroup: String): Flow<List<BookSourcePart>>
     suspend fun getBookSource(url: String): BookSource?
-    suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>>
     suspend fun saveSearchBooks(books: List<SearchBook>)
     suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind>
     suspend fun topSource(bookSource: BookSourcePart)
@@ -77,18 +75,6 @@ class ExploreRepositoryImpl(
         return appDb.bookSourceDao.getBookSource(url)
     }
 
-    override suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>> {
-        return withContext(IO) {
-            try {
-                val books = WebBook.exploreBookSuspend(source, url, page)
-                Result.success(books)
-            } catch (e: Exception) {
-                e.printStackTrace()
-                Result.failure(e)
-            }
-        }
-    }
-
     override suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind> = withContext(IO) {
         val source = appDb.bookSourceDao.getBookSource(sourceUrl)
         return@withContext source?.exploreKinds() ?: emptyList()

+ 94 - 0
app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt

@@ -0,0 +1,94 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.HomepageCustomSetDao
+import io.legado.app.data.dao.HomepageModuleDao
+import io.legado.app.data.entities.HomepageCustomSet
+import io.legado.app.data.entities.HomepageModule
+import io.legado.app.domain.gateway.HomepageModulesGateway
+import io.legado.app.domain.model.CustomSetItem
+import io.legado.app.domain.model.ModuleItem
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class HomepageModulesRepository(
+    private val moduleDao: HomepageModuleDao,
+    private val customSetDao: HomepageCustomSetDao,
+) : HomepageModulesGateway {
+
+    override fun flowEnabled(): Flow<List<ModuleItem>> =
+        moduleDao.flowEnabled().map { list -> list.map { it.toModuleItem() } }
+
+    override fun flowAll(): Flow<List<ModuleItem>> =
+        moduleDao.flowAll().map { list -> list.map { it.toModuleItem() } }
+
+    override fun flowBySource(sourceUrl: String): Flow<List<ModuleItem>> =
+        moduleDao.flowBySource(sourceUrl).map { list -> list.map { it.toModuleItem() } }
+
+    override suspend fun getById(id: String): ModuleItem? =
+        moduleDao.getById(id)?.toModuleItem()
+
+    override suspend fun upsertAll(modules: List<ModuleItem>) =
+        moduleDao.upsertAll(modules.map { it.toModuleEntity() })
+
+    override suspend fun setEnabled(id: String, enabled: Boolean) =
+        moduleDao.setEnabled(id, enabled)
+
+    override suspend fun setSortOrder(id: String, order: Int) = moduleDao.setSortOrder(id, order)
+    override suspend fun setCustomSetId(id: String, setId: String?) =
+        moduleDao.setCustomSetId(id, setId)
+
+    override suspend fun setCustomSetTitle(id: String, title: String?) =
+        moduleDao.setCustomSetTitle(id, title)
+
+    override suspend fun delete(id: String) = moduleDao.delete(id)
+    override suspend fun deleteStale(sourceUrl: String, currentIds: List<String>) =
+        moduleDao.deleteStale(sourceUrl, currentIds)
+
+    override fun flowCustomSets(): Flow<List<CustomSetItem>> =
+        customSetDao.flowAll().map { list -> list.map { it.toCustomSetItem() } }
+
+    override suspend fun getCustomSetById(id: String): CustomSetItem? =
+        customSetDao.getById(id)?.toCustomSetItem()
+
+    override suspend fun upsertCustomSet(set: CustomSetItem) =
+        customSetDao.upsert(set.toCustomSetEntity())
+
+    override suspend fun setCustomSetSortOrder(id: String, order: Int) =
+        customSetDao.setSortOrder(id, order)
+
+    override suspend fun createCustomSet(name: String): CustomSetItem {
+        val entity = HomepageCustomSet(
+            id = "cs_${System.currentTimeMillis()}", name = name
+        )
+        customSetDao.upsert(entity)
+        return entity.toCustomSetItem()
+    }
+
+    override suspend fun renameCustomSet(id: String, name: String) = customSetDao.rename(id, name)
+    override suspend fun deleteCustomSet(id: String) {
+        moduleDao.deleteByCustomSetId(id)
+        customSetDao.delete(id)
+    }
+
+    private fun HomepageModule.toModuleItem() = ModuleItem(
+        id = id, sourceUrl = sourceUrl, moduleKey = moduleKey, type = type,
+        title = title, customTitle = customTitle, customSetTitle = customSetTitle,
+        args = args, layoutConfig = layoutConfig, url = url, isEnabled = isEnabled,
+        customSetId = customSetId, isUserCreated = isUserCreated,
+        sortOrder = sortOrder, sourceJsonHash = sourceJsonHash, syncedAt = syncedAt,
+    )
+
+    private fun ModuleItem.toModuleEntity() = HomepageModule(
+        id = id, sourceUrl = sourceUrl, moduleKey = moduleKey, type = type,
+        title = title, customTitle = customTitle, customSetTitle = customSetTitle,
+        args = args, layoutConfig = layoutConfig, url = url, isEnabled = isEnabled,
+        customSetId = customSetId, isUserCreated = isUserCreated,
+        sortOrder = sortOrder, sourceJsonHash = sourceJsonHash, syncedAt = syncedAt,
+    )
+
+    private fun HomepageCustomSet.toCustomSetItem() =
+        CustomSetItem(id = id, name = name, sortOrder = sortOrder)
+
+    private fun CustomSetItem.toCustomSetEntity() =
+        HomepageCustomSet(id = id, name = name, sortOrder = sortOrder)
+}

+ 12 - 5
app/src/main/java/io/legado/app/data/repository/SearchRepository.kt

@@ -7,7 +7,6 @@ import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.usecase.BookShelfKey
-import io.legado.app.help.book.isNotShelf
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
@@ -26,6 +25,8 @@ interface SearchRepository {
     suspend fun saveSearchKeyword(keyword: String)
     suspend fun deleteSearchKeyword(item: SearchKeyword)
     suspend fun clearSearchKeywords()
+    suspend fun saveSearchBooks(books: List<SearchBook>)
+    suspend fun saveSearchBook(book: SearchBook)
 }
 
 class SearchRepositoryImpl(
@@ -59,7 +60,7 @@ class SearchRepositoryImpl(
         }
     }
 
-    override suspend fun saveSearchKeyword(keyword: String) = withContext(Dispatchers.IO) {
+    override suspend fun saveSearchKeyword(keyword: String): Unit = withContext(Dispatchers.IO) {
         val key = keyword.trim()
         if (key.isBlank()) return@withContext
 
@@ -70,11 +71,12 @@ class SearchRepositoryImpl(
         } ?: appDb.searchKeywordDao.insert(SearchKeyword(word = key, usage = 1))
     }
 
-    override suspend fun deleteSearchKeyword(item: SearchKeyword) = withContext(Dispatchers.IO) {
+    override suspend fun deleteSearchKeyword(item: SearchKeyword): Unit =
+        withContext(Dispatchers.IO) {
         appDb.searchKeywordDao.delete(item)
     }
 
-    override suspend fun clearSearchKeywords() = withContext(Dispatchers.IO) {
+    override suspend fun clearSearchKeywords(): Unit = withContext(Dispatchers.IO) {
         appDb.searchKeywordDao.deleteAll()
     }
 
@@ -103,9 +105,14 @@ class SearchRepositoryImpl(
         appDb.bookSourceDao.getBookSource(sourceUrl)
     }
 
-    override suspend fun saveSearchBooks(books: List<SearchBook>) = withContext(Dispatchers.IO) {
+    override suspend fun saveSearchBooks(books: List<SearchBook>): Unit =
+        withContext(Dispatchers.IO) {
         if (books.isNotEmpty()) {
             appDb.searchBookDao.insert(books)
         }
     }
+
+    override suspend fun saveSearchBook(book: SearchBook): Unit = withContext(Dispatchers.IO) {
+        appDb.searchBookDao.insert(book)
+    }
 }

+ 25 - 1
app/src/main/java/io/legado/app/di/appDatabaseModule.kt

@@ -2,7 +2,29 @@ package io.legado.app.di
 
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.appDb
-import io.legado.app.data.dao.*
+import io.legado.app.data.dao.BookChapterDao
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.BookGroupDao
+import io.legado.app.data.dao.BookSourceDao
+import io.legado.app.data.dao.BookmarkDao
+import io.legado.app.data.dao.CacheDao
+import io.legado.app.data.dao.CookieDao
+import io.legado.app.data.dao.DictRuleDao
+import io.legado.app.data.dao.HomepageCustomSetDao
+import io.legado.app.data.dao.HomepageModuleDao
+import io.legado.app.data.dao.HttpTTSDao
+import io.legado.app.data.dao.KeyboardAssistsDao
+import io.legado.app.data.dao.ReadRecordDao
+import io.legado.app.data.dao.ReplaceRuleDao
+import io.legado.app.data.dao.RssArticleDao
+import io.legado.app.data.dao.RssReadRecordDao
+import io.legado.app.data.dao.RssSourceDao
+import io.legado.app.data.dao.RssStarDao
+import io.legado.app.data.dao.RuleSubDao
+import io.legado.app.data.dao.SearchBookDao
+import io.legado.app.data.dao.SearchKeywordDao
+import io.legado.app.data.dao.ServerDao
+import io.legado.app.data.dao.TxtTocRuleDao
 import org.koin.dsl.module
 
 /**
@@ -36,4 +58,6 @@ val appDatabaseModule = module {
     factory<DictRuleDao> { get<AppDatabase>().dictRuleDao }
     factory<KeyboardAssistsDao> { get<AppDatabase>().keyboardAssistsDao }
     factory<ServerDao> { get<AppDatabase>().serverDao }
+    factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }
+    factory<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
 }

+ 24 - 3
app/src/main/java/io/legado/app/di/appModule.kt

@@ -12,12 +12,15 @@ import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceCallbackRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.BookshelfRepository
 import io.legado.app.data.repository.CacheBookDownloadRepository
 import io.legado.app.data.repository.DatabaseMaintenanceRepository
 import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.data.repository.DirectLinkUploadRepository
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
+import io.legado.app.data.repository.HomepageModulesRepository
 import io.legado.app.data.repository.LocalBookRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
@@ -35,20 +38,27 @@ import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
+import io.legado.app.domain.gateway.HomepageModulesGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
+import io.legado.app.domain.usecase.AddBookUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
 import io.legado.app.domain.usecase.DeleteBooksUseCase
+import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
+import io.legado.app.domain.usecase.ExportBookshelfUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
+import io.legado.app.domain.usecase.ImportBookshelfUseCase
+import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
@@ -93,6 +103,7 @@ import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.explore.ExploreViewModel
+import io.legado.app.ui.main.homepage.HomepageViewModel
 import io.legado.app.ui.main.my.MyViewModel
 import io.legado.app.ui.main.rss.RssViewModel
 import io.legado.app.ui.replace.ReplaceEditRoute
@@ -120,11 +131,15 @@ val appModule = module {
     singleOf(::ReadRecordRepository)
     singleOf(::BookRepository)
     singleOf(::BookGroupRepository)
+    singleOf(::BookSourceRepository)
+    singleOf(::BookshelfRepository)
     singleOf(::DictRuleRepository)
     singleOf(::SearchContentRepository)
     singleOf(::RemoteBookRepository)
     singleOf(::SettingsRepository)
+    singleOf(::ExploreBooksUseCase)
     singleOf(::ExploreKindUiUseCase)
+    singleOf(::SaveSearchBooksUseCase)
     singleOf(::AppStartupMaintenanceUseCase)
     singleOf(::BatchCacheDownloadUseCase)
     singleOf(::CacheBookChaptersUseCase)
@@ -136,6 +151,10 @@ val appModule = module {
     singleOf(::UpdateBooksGroupUseCase)
     singleOf(::UploadReadingProgressUseCase)
     singleOf(::ResolveBookShelfStateUseCase)
+    singleOf(::RefreshTocUseCase)
+    singleOf(::AddBookUseCase)
+    singleOf(::ImportBookshelfUseCase)
+    singleOf(::ExportBookshelfUseCase)
     factory { GetReadRecordOverviewUseCase() }
     singleOf(::ShrinkDatabaseUseCase)
     singleOf(::WebDavBackupUseCase)
@@ -150,6 +169,7 @@ val appModule = module {
     single<DatabaseMaintenanceGateway> { DatabaseMaintenanceRepository(get()) }
     single<WebDavBackupGateway> { WebDavBackupRepository() }
     single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
+    single<HomepageModulesGateway> { HomepageModulesRepository(get(), get()) }
     single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
     single<ExploreRepository> { ExploreRepositoryImpl(get()) }
     singleOf(::RssRepository)
@@ -190,6 +210,7 @@ val appModule = module {
     viewModelOf(::MyViewModel)
     viewModelOf(::BookshelfViewModel)
     viewModelOf(::MainViewModel)
+    viewModelOf(::HomepageViewModel)
     viewModelOf(::AboutViewModel)
     viewModelOf(::GroupViewModel)
     viewModelOf(::ReplaceRuleViewModel)
@@ -219,9 +240,9 @@ val appModule = module {
     viewModel {
         BookshelfManageScreenViewModel(
             application = get(),
-            bookDao = get(),
-            bookGroupDao = get(),
-            bookChapterDao = get(),
+            bookRepository = get(),
+            bookGroupRepository = get(),
+            searchRepository = get(),
             bookshelfManageScreenConfig = get(),
             batchCacheDownloadUseCase = get(),
             cacheBookChaptersUseCase = get(),

+ 33 - 0
app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt

@@ -0,0 +1,33 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.CustomSetItem
+import io.legado.app.domain.model.ModuleItem
+import kotlinx.coroutines.flow.Flow
+
+interface HomepageModulesGateway {
+    // Module queries
+    fun flowEnabled(): Flow<List<ModuleItem>>
+    fun flowAll(): Flow<List<ModuleItem>>
+    fun flowBySource(sourceUrl: String): Flow<List<ModuleItem>>
+    suspend fun getById(id: String): ModuleItem?
+
+    // Module mutations
+    suspend fun upsertAll(modules: List<ModuleItem>)
+    suspend fun setEnabled(id: String, enabled: Boolean)
+    suspend fun setSortOrder(id: String, order: Int)
+    suspend fun setCustomSetId(id: String, setId: String?)
+    suspend fun setCustomSetTitle(id: String, title: String?)
+    suspend fun delete(id: String)
+    suspend fun deleteStale(sourceUrl: String, currentIds: List<String>)
+
+    // Custom set queries
+    fun flowCustomSets(): Flow<List<CustomSetItem>>
+    suspend fun getCustomSetById(id: String): CustomSetItem?
+
+    // Custom set mutations
+    suspend fun upsertCustomSet(set: CustomSetItem)
+    suspend fun setCustomSetSortOrder(id: String, order: Int)
+    suspend fun createCustomSet(name: String): CustomSetItem
+    suspend fun renameCustomSet(id: String, name: String)
+    suspend fun deleteCustomSet(id: String)
+}

+ 71 - 0
app/src/main/java/io/legado/app/domain/model/HomepageModels.kt

@@ -0,0 +1,71 @@
+package io.legado.app.domain.model
+
+import androidx.compose.runtime.Immutable
+
+/** 供 Gateway 和 ViewModel 使用的不可变模块模型 */
+@Immutable
+data class ModuleItem(
+    val id: String = "",
+    val sourceUrl: String = "",
+    val moduleKey: String = "",
+    val type: String = "",
+    val title: String = "",
+    val customTitle: String? = null,
+    val customSetTitle: String? = null,
+    val args: String? = null,
+    val layoutConfig: String? = null,
+    val url: String? = null,
+    val isEnabled: Boolean = true,
+    val customSetId: String? = null,
+    val isUserCreated: Boolean = false,
+    val sortOrder: Int = 0,
+    val sourceJsonHash: String? = null,
+    val syncedAt: Long = 0,
+) {
+    val displayTitle: String get() = customTitle ?: title
+}
+
+@Immutable
+data class CustomSetItem(
+    val id: String = "",
+    val name: String = "",
+    val sortOrder: Int = 0,
+)
+
+/** 模块定义(来自书源 JSON 解析或用户手动添加) */
+data class ModuleDef(
+    val key: String = "",
+    val type: String = "",
+    val title: String = "",
+    val args: String? = null,
+    val layoutConfig: String? = null,
+    val url: String? = null,
+    val sourceUrl: String = "",
+) {
+    val globalId: String get() = globalIdOf(sourceUrl, key)
+
+    companion object {
+        fun globalIdOf(sourceUrl: String, key: String, setId: String? = null): String {
+            val targetSetId = setId ?: "src_$sourceUrl"
+            return "$targetSetId::$sourceUrl::$key"
+        }
+    }
+}
+
+/** 首页模块类型枚举 — 定义在 Domain 层以便 UseCase 和 ViewModel 共享 */
+enum class HomepageModuleType(val key: String, val title: String) {
+    Banner("banner", "横滑轮播"),
+    Ranking("ranking", "排行榜"),
+    GridRanking("gridRanking", "网格排行榜"),
+    Grid("grid", "网格"),
+    Card("card", "推荐卡片"),
+    InfiniteGrid("infiniteGrid", "无限网格"),
+    ButtonGroup("buttonGroup", "按钮组"),
+    Waterfall("waterfall", "错位瀑布流"),
+    Unknown("", "未知");
+
+    companion object {
+        fun fromKey(key: String?): HomepageModuleType =
+            entries.find { it.key == key } ?: Unknown
+    }
+}

+ 71 - 0
app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt

@@ -0,0 +1,71 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.utils.NetworkUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class AddBookUseCase(
+    private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository
+) {
+    suspend fun execute(
+        bookUrls: String,
+        onProgress: suspend (Int) -> Unit = {}
+    ): Int = withContext(Dispatchers.IO) {
+        var successCount = 0
+        val urls = bookUrls.split("\n")
+        val hasBookUrlPattern = bookSourceRepository.getHasBookUrlPattern()
+
+        for (url in urls) {
+            val bookUrl = url.trim()
+            if (bookUrl.isEmpty()) continue
+            if (bookRepository.getBook(bookUrl) != null) {
+                successCount++
+                onProgress(successCount)
+                continue
+            }
+            val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue
+            var source = bookSourceRepository.getBookSourceAddBook(baseUrl)
+            if (source == null) {
+                for (bookSourcePart in hasBookUrlPattern) {
+                    try {
+                        val bs = bookSourcePart.getBookSource()!!
+                        if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) {
+                            source = bs
+                            break
+                        }
+                    } catch (_: Exception) {
+                    }
+                }
+            }
+            val bookSource = source ?: continue
+            val book = Book(
+                bookUrl = bookUrl,
+                origin = bookSource.bookSourceUrl,
+                originName = bookSource.bookSourceName
+            )
+
+            kotlin.runCatching {
+                WebBook.getBookInfoAwait(bookSource, book)
+            }.onSuccess {
+                val dbBook = bookRepository.getBook(it.name, it.author)
+                if (dbBook != null) {
+                    val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow()
+                    dbBook.migrateTo(it, toc)
+                    bookRepository.insert(it)
+                    bookRepository.insertChapters(*toc.toTypedArray())
+                } else {
+                    it.order = bookRepository.getMinOrder() - 1
+                    bookRepository.insert(it)
+                }
+                successCount++
+                onProgress(successCount)
+            }
+        }
+        successCount
+    }
+}

+ 71 - 0
app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt

@@ -0,0 +1,71 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.model.webBook.WebBook
+
+class ExploreBooksUseCase(
+    private val bookSourceRepository: BookSourceRepository,
+) {
+    companion object {
+        /** 排名类模块自动加载的最大书本数 */
+        const val MAX_RANKING_BOOKS = 20
+
+        /** 排名类模块自动加载的最大页数 */
+        const val MAX_RANKING_PAGES = 3
+    }
+
+    suspend fun execute(
+        sourceUrl: String,
+        moduleUrl: String?,
+        args: String?,
+        page: Int = 1
+    ): ExploreResult {
+        val base = bookSourceRepository.getBookSource(sourceUrl)
+            ?: throw SourceNotFound(sourceUrl)
+        val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base
+        val resolvedUrl = moduleUrl ?: source.exploreUrl
+        ?: throw NoExploreUrl(sourceUrl)
+        if (!resolvedUrl.startsWith("http", ignoreCase = true)
+            && !resolvedUrl.startsWith("data:", ignoreCase = true)
+            && !resolvedUrl.startsWith("{{")
+        ) {
+            throw InvalidUrl(resolvedUrl)
+        }
+        val books = WebBook.exploreBookSuspend(source, resolvedUrl, page)
+        return ExploreResult(resolvedUrl, books)
+    }
+
+    suspend fun executeForRanking(
+        sourceUrl: String,
+        moduleUrl: String?,
+        args: String?
+    ): List<SearchBook> {
+        val result = execute(sourceUrl, moduleUrl, args)
+        var books = result.books
+        var page = 1
+        while (books.size < MAX_RANKING_BOOKS && page < MAX_RANKING_PAGES) {
+            page++
+            val next = try {
+                WebBook.exploreBookSuspend(
+                    bookSourceRepository.getBookSource(sourceUrl)
+                        ?.let { s -> args?.let { s.copy().also { x -> x.setVariable(it) } } ?: s }
+                        ?: return books.take(MAX_RANKING_BOOKS),
+                    result.resolvedUrl,
+                    page,
+                )
+            } catch (_: Exception) {
+                emptyList()
+            }
+            if (next.isEmpty()) break
+            books = (books + next)
+        }
+        return books.take(MAX_RANKING_BOOKS)
+    }
+
+    data class ExploreResult(val resolvedUrl: String, val books: List<SearchBook>)
+
+    class SourceNotFound(url: String) : Exception("Source not found: ${url.take(60)}")
+    class NoExploreUrl(url: String) : Exception("No explore URL for source: ${url.take(60)}")
+    class InvalidUrl(url: String) : Exception("Invalid explore URL: ${url.take(80)}")
+}

+ 79 - 0
app/src/main/java/io/legado/app/domain/usecase/ExportBookshelfUseCase.kt

@@ -0,0 +1,79 @@
+package io.legado.app.domain.usecase
+
+import android.content.Context
+import android.net.Uri
+import com.google.gson.stream.JsonWriter
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.ui.main.bookshelf.BookUiItem
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.io.OutputStreamWriter
+
+class ExportBookshelfUseCase(
+    private val context: Context,
+    private val bookRepository: BookRepository
+) {
+    suspend fun exportToUri(uri: Uri, items: List<BookUiItem>): Result<Unit> =
+        withContext(Dispatchers.IO) {
+            kotlin.runCatching {
+                context.contentResolver.openOutputStream(uri)?.use { out ->
+                    val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
+                    writer.setIndent("  ")
+                    writer.beginArray()
+                    items.forEach {
+                        val bookMap = hashMapOf<String, String?>()
+                        bookMap["name"] = it.book.name
+                        bookMap["author"] = it.book.author
+                        val fullBook = bookRepository.getBook(it.book.bookUrl)
+                        bookMap["intro"] = fullBook?.getDisplayIntro()
+                        GSON.toJson(bookMap, bookMap::class.java, writer)
+                    }
+                    writer.endArray()
+                    writer.close()
+                } ?: throw Exception("Failed to open output stream")
+            }
+        }
+
+    suspend fun exportToFile(items: List<BookUiItem>): Result<File> = withContext(Dispatchers.IO) {
+        kotlin.runCatching {
+            val path = "${context.filesDir}/books.json"
+            FileUtils.delete(path)
+            val file = FileUtils.createFileWithReplace(path)
+            FileOutputStream(file).use { out ->
+                val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
+                writer.setIndent("  ")
+                writer.beginArray()
+                items.forEach {
+                    val bookMap = hashMapOf<String, String?>()
+                    bookMap["name"] = it.book.name
+                    bookMap["author"] = it.book.author
+                    val fullBook = bookRepository.getBook(it.book.bookUrl)
+                    bookMap["intro"] = fullBook?.getDisplayIntro()
+                    GSON.toJson(bookMap, bookMap::class.java, writer)
+                }
+                writer.endArray()
+                writer.close()
+            }
+            file
+        }
+    }
+
+    suspend fun exportToJson(items: List<BookUiItem>): Result<String> =
+        withContext(Dispatchers.IO) {
+            kotlin.runCatching {
+                val list = items.map {
+                    val bookMap = hashMapOf<String, String?>()
+                    bookMap["name"] = it.book.name
+                    bookMap["author"] = it.book.author
+                    val fullBook = bookRepository.getBook(it.book.bookUrl)
+                    bookMap["intro"] = fullBook?.getDisplayIntro()
+                    bookMap
+                }
+                GSON.toJson(list)
+            }
+        }
+}

+ 109 - 0
app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt

@@ -0,0 +1,109 @@
+package io.legado.app.domain.usecase
+
+import android.content.Context
+import android.net.Uri
+import io.legado.app.data.entities.Book
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.http.decompressed
+import io.legado.app.help.http.newCallResponseBody
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.text
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.isJsonArray
+import io.legado.app.utils.readText
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+
+class ImportBookshelfUseCase(
+    private val context: Context,
+    private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository
+) {
+    suspend fun import(
+        str: String,
+        groupId: Long,
+        onProgress: suspend (String) -> Unit = {}
+    ): Result<Unit> = kotlin.runCatching {
+        val text = str.trim()
+        when {
+            text.isAbsUrl() -> {
+                val downloadedText = okHttpClient.newCallResponseBody {
+                    url(text)
+                }.decompressed().text()
+                import(downloadedText, groupId, onProgress).getOrThrow()
+            }
+
+            text.isJsonArray() -> {
+                importByJson(text, groupId, onProgress)
+            }
+
+            else -> {
+                throw NoStackTraceException("格式不对")
+            }
+        }
+    }
+
+    suspend fun import(
+        uri: Uri,
+        groupId: Long,
+        onProgress: suspend (String) -> Unit = {}
+    ): Result<Unit> = kotlin.runCatching {
+        val text = uri.readText(context)
+        import(text, groupId, onProgress).getOrThrow()
+    }
+
+    private suspend fun importByJson(
+        json: String,
+        groupId: Long,
+        onProgress: suspend (String) -> Unit
+    ) {
+        onProgress("导入中...")
+        val bookSourceParts = bookSourceRepository.getAllEnabledPart()
+        val semaphore = Semaphore(AppConfig.threadCount)
+        val books = GSON.fromJsonArray<Map<String, String?>>(json).getOrThrow()
+
+        withContext(Dispatchers.IO) {
+            books.forEach { bookInfo ->
+                val name = bookInfo["name"] ?: ""
+                val author = bookInfo["author"] ?: ""
+                if (name.isEmpty() || bookRepository.getBook(name, author) != null) {
+                    return@forEach
+                }
+                semaphore.withPermit {
+                    var foundBook: Book? = null
+                    for (s in bookSourceParts) {
+                        ensureActive()
+                        val source = s.getBookSource() ?: continue
+                        foundBook = WebBook.preciseSearchAwait(source, name, author).getOrNull()
+                        if (foundBook != null) break
+                    }
+                    if (foundBook != null) {
+                        val book = foundBook
+                        if (groupId > 0) {
+                            book.group = groupId
+                        }
+                        if (bookRepository.getBook(book.bookUrl) != null) {
+                            bookRepository.update(book)
+                        } else {
+                            bookRepository.insert(book)
+                        }
+                    } else {
+                        withContext(Dispatchers.Main) {
+                            context.toastOnUi("没有搜索到<$name>$author")
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 61 - 0
app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt

@@ -0,0 +1,61 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.constant.BookType
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.addType
+import io.legado.app.help.book.isUpError
+import io.legado.app.help.book.removeType
+import io.legado.app.help.book.sync
+import io.legado.app.model.ReadBook
+import io.legado.app.model.webBook.WebBook
+import kotlinx.coroutines.ensureActive
+
+class RefreshTocUseCase(
+    private val bookRepository: BookRepository,
+    private val bookSourceRepository: BookSourceRepository
+) {
+    suspend fun execute(
+        bookUrl: String,
+        onSuccess: suspend (BookSource, Book) -> Unit = { _, _ -> }
+    ): Result<Unit> = kotlin.runCatching {
+        val book = bookRepository.getBook(bookUrl) ?: throw Exception("Book not found")
+        val source = bookSourceRepository.getBookSource(book.origin)
+        if (source == null) {
+            if (!book.isUpError) {
+                book.addType(BookType.updateError)
+                bookRepository.update(book)
+            }
+            throw Exception("Source not found")
+        }
+
+        val oldBook = book.copy()
+        if (book.tocUrl.isBlank()) {
+            WebBook.getBookInfoAwait(source, book)
+        } else {
+            WebBook.runPreUpdateJs(source, book)
+        }
+        val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
+        book.sync(oldBook)
+        book.removeType(BookType.updateError)
+        if (book.bookUrl == bookUrl) {
+            bookRepository.update(book)
+        } else {
+            bookRepository.replace(oldBook, book)
+            BookHelp.updateCacheFolder(oldBook, book)
+        }
+        bookRepository.deleteChaptersByBook(bookUrl)
+        bookRepository.insertChapters(*toc.toTypedArray())
+        ReadBook.onChapterListUpdated(book)
+        onSuccess(source, book)
+    }.onFailure {
+        kotlin.coroutines.coroutineContext.ensureActive()
+        bookRepository.getBook(bookUrl)?.let { book ->
+            book.addType(BookType.updateError)
+            bookRepository.update(book)
+        }
+    }
+}

+ 11 - 0
app/src/main/java/io/legado/app/domain/usecase/SaveSearchBooksUseCase.kt

@@ -0,0 +1,11 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.repository.SearchRepository
+
+class SaveSearchBooksUseCase(
+    private val searchRepository: SearchRepository,
+) {
+    suspend fun save(book: SearchBook) = save(listOf(book))
+    suspend fun save(books: List<SearchBook>) = searchRepository.saveSearchBooks(books)
+}

+ 34 - 24
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt

@@ -6,10 +6,12 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.data.repository.ExploreRepository
+import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.usecase.BookShelfKey
+import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.help.config.AppConfig
-import io.legado.app.domain.model.BookShelfState
 import io.legado.app.utils.exploreLayoutGrid
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -50,7 +52,9 @@ data class ExploreBookItemUi(
 
 class ExploreShowViewModel(
     private val repository: ExploreRepository,
-    private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase
+    private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
+    private val exploreBooksUseCase: ExploreBooksUseCase,
+    private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
 ) : ViewModel() {
 
     private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
@@ -124,13 +128,13 @@ class ExploreShowViewModel(
     }
 
     fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
+        // 允许 incomingExploreUrl 为空,此时加载书源默认发现页
         if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
             return
         }
         sourceUrl = incomingSourceUrl
         exploreUrl = incomingExploreUrl
         page = 1
-        bookSource = null
         _rawBooks.value = emptyList()
         _isEndStateFlow.value = false
         _errorMsg.value = null
@@ -139,8 +143,13 @@ class ExploreShowViewModel(
         viewModelScope.launch {
             if (bookSource == null && incomingSourceUrl != null) {
                 bookSource = repository.getBookSource(incomingSourceUrl)
-                loadKinds(incomingSourceUrl)
             }
+
+            // 如果仍然没有发现 URL,且书源已加载,尝试使用书源的默认发现页
+            if (exploreUrl == null && bookSource != null) {
+                loadKinds(incomingSourceUrl!!)
+            }
+
             loadMore(isRefresh = true)
         }
     }
@@ -175,7 +184,7 @@ class ExploreShowViewModel(
 
     fun loadMore(isRefresh: Boolean = false) {
         val source = bookSource
-        val url = exploreUrl
+        val url = exploreUrl ?: source?.exploreUrl
         if (source == null || url == null || _isLoading.value || (_isEndStateFlow.value && !isRefresh)) return
 
         viewModelScope.launch {
@@ -188,29 +197,30 @@ class ExploreShowViewModel(
                 _rawBooks.value = emptyList()
             }
 
-            repository.exploreBook(source, url, page)
-                .onSuccess { newBooks ->
-                    if (newBooks.isEmpty()) {
+            kotlin.runCatching {
+                exploreBooksUseCase.execute(source.bookSourceUrl, url, args = null, page)
+            }.onSuccess { result ->
+                if (result.books.isEmpty()) {
+                    _isEndStateFlow.value = true
+                } else {
+                    saveSearchBooksUseCase.save(result.books)
+
+                    val currentList = _rawBooks.value
+                    val existingUrls = currentList.map { it.bookUrl }.toSet()
+
+                    val uniqueNewBooks = result.books
+                        .filter { it.bookUrl !in existingUrls }
+                        .distinctBy { it.bookUrl }
+
+                    if (uniqueNewBooks.isEmpty()) {
                         _isEndStateFlow.value = true
                     } else {
-                        repository.saveSearchBooks(newBooks)
-
-                        val currentList = _rawBooks.value
-                        val existingUrls = currentList.map { it.bookUrl }.toSet()
-
-                        val uniqueNewBooks = newBooks
-                            .filter { it.bookUrl !in existingUrls }
-                            .distinctBy { it.bookUrl }
-
-                        if (uniqueNewBooks.isEmpty()) {
-                            _isEndStateFlow.value = true
-                        } else {
-                            _rawBooks.value = currentList + uniqueNewBooks
-                            page++
-                            _isEndStateFlow.value = false
-                        }
+                        _rawBooks.value = currentList + uniqueNewBooks
+                        page++
+                        _isEndStateFlow.value = false
                     }
                 }
+            }
                 .onFailure {
                     _errorMsg.value = it.localizedMessage
                 }

+ 25 - 26
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt

@@ -4,14 +4,13 @@ import android.app.Application
 import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.BookType
-import io.legado.app.data.appDb
-import io.legado.app.data.dao.BookChapterDao
-import io.legado.app.data.dao.BookDao
-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.data.entities.BookSource
+import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.BatchChangeSourceCandidate
 import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem
@@ -145,9 +144,9 @@ sealed interface BookshelfManageScreenEffect {
 
 class BookshelfManageScreenViewModel(
     application: Application,
-    private val bookDao: BookDao,
-    private val bookGroupDao: BookGroupDao,
-    private val bookChapterDao: BookChapterDao,
+    private val bookRepository: BookRepository,
+    private val bookGroupRepository: BookGroupRepository,
+    private val searchRepository: SearchRepository,
     val bookshelfManageScreenConfig: BookshelfManageScreenConfig,
     private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
     private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
@@ -337,7 +336,7 @@ class BookshelfManageScreenViewModel(
     private fun observeGroups() {
         groupsJob?.cancel()
         groupsJob = viewModelScope.launch {
-            bookGroupDao.flowAll().collect { groups ->
+            bookGroupRepository.flowAll().collect { groups ->
                 _uiState.update { it.copy(groupList = groups) }
             }
         }
@@ -346,7 +345,7 @@ class BookshelfManageScreenViewModel(
     private fun observeBooks(groupId: Long) {
         booksJob?.cancel()
         booksJob = viewModelScope.launch {
-            bookDao.flowBookShelfByGroup(groupId).map { books ->
+            bookRepository.flowBookShelfByGroup(groupId).map { books ->
                 val booksDownload = books.filter { !it.isAudio }.map { it.toLightBook() }
                 val bookSort = bookshelfManageScreenConfig.getBookSortByGroupId(groupId)
                 val isDescending = bookshelfManageScreenConfig.bookshelfSortOrder == 1
@@ -475,7 +474,7 @@ class BookshelfManageScreenViewModel(
 
     private fun refreshGroupName(groupId: Long) {
         execute {
-            val title = bookGroupDao.getByID(groupId)?.groupName
+            val title = bookGroupRepository.getByID(groupId)?.groupName
             title ?: context.getString(io.legado.app.R.string.no_group)
         }.onSuccess { groupName ->
             _uiState.update { it.copy(groupName = groupName) }
@@ -532,7 +531,7 @@ class BookshelfManageScreenViewModel(
         val visibleBookUrls = uiState.value.books.mapTo(hashSetOf()) { it.bookUrl }
         cacheRefreshBookUrls.forEach { bookUrl ->
             if (visibleBookUrls.contains(bookUrl)) {
-                bookDao.getBook(bookUrl)?.let { book ->
+                bookRepository.getBook(bookUrl)?.let { book ->
                     cacheCounts[bookUrl] = calculateCacheCount(book)
                     changedBookUrls.add(bookUrl)
                 }
@@ -549,12 +548,12 @@ class BookshelfManageScreenViewModel(
         emitBooksChanged(changedBookUrls)
     }
 
-    private fun calculateCacheCount(book: Book): Int {
+    private suspend fun calculateCacheCount(book: Book): Int {
         val cacheNames = BookHelp.getChapterFiles(book)
         if (cacheNames.isEmpty()) return 0
-        val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
+        val totalCount = bookRepository.getChapterCount(book.bookUrl)
         val cachedFileCount = cacheNames.count { it.endsWith(".nb") }
-        return min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
+        return min(cachedFileCount + bookRepository.getVolumeCount(book.bookUrl), totalCount)
     }
 
     private fun Int?.orZero(): Int = this ?: 0
@@ -661,7 +660,7 @@ class BookshelfManageScreenViewModel(
             )
         }
         execute {
-            bookDao.update(*reorderedBooks.toTypedArray())
+            bookRepository.update(*reorderedBooks.toTypedArray())
         }.onError {
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("排序保存失败\n${it.localizedMessage}"))
         }
@@ -696,7 +695,7 @@ class BookshelfManageScreenViewModel(
         options: ChangeSourceMigrationOptions,
     ) {
         execute {
-            val oldBook = bookDao.getBook(oldBookUrl) ?: return@execute null
+            val oldBook = bookRepository.getBook(oldBookUrl) ?: return@execute null
             changeBookSourceUseCase.changeTo(oldBook, book, chapters, options)
         }.onSuccess { result ->
             result ?: return@onSuccess
@@ -734,7 +733,7 @@ class BookshelfManageScreenViewModel(
                     batchChangePreviewItems = emptyList()
                 )
             }
-            val books = bookUrls.mapNotNull { bookDao.getBook(it) }
+            val books = bookUrls.mapNotNull { bookRepository.getBook(it) }
             changeBookSourceUseCase.prepareBatchChange(
                 books = books,
                 sources = sources,
@@ -786,7 +785,7 @@ class BookshelfManageScreenViewModel(
         } ?: return
         val candidate = item.selectedCandidate ?: return
         execute {
-            val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook
+            val oldBook = bookRepository.getBook(oldBookUrl) ?: item.oldBook
             val chapters = changeBookSourceUseCase.loadCandidateChapters(
                 candidate.source,
                 candidate.book
@@ -885,10 +884,10 @@ class BookshelfManageScreenViewModel(
             ) ?: error("获取目录失败")
             candidate.book.removeType(BookType.notShelf)
             if (candidate.book.order == 0) {
-                candidate.book.order = bookDao.minOrder - 1
+                candidate.book.order = bookRepository.getMinOrder() - 1
             }
-            bookDao.insert(candidate.book)
-            bookChapterDao.insert(*chapters.toTypedArray())
+            bookRepository.insert(candidate.book)
+            bookRepository.insertChapters(*chapters.toTypedArray())
             candidate.book
         }.onSuccess {
             _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架"))
@@ -900,7 +899,7 @@ class BookshelfManageScreenViewModel(
     private fun openBookInfoPreview(book: Book, inBookshelf: Boolean) {
         execute {
             if (!inBookshelf) {
-                appDb.searchBookDao.insert(book.toSearchBook())
+                searchRepository.saveSearchBooks(listOf(book.toSearchBook()))
             }
             book
         }.onSuccess {
@@ -931,7 +930,7 @@ class BookshelfManageScreenViewModel(
                     it.copy(changeSourceProgress = "${index + 1} / ${items.size}  ${item.oldBook.name}")
                 }
                 val candidate = item.selectedCandidate ?: return@forEachIndexed
-                val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook
+                val oldBook = bookRepository.getBook(item.oldBook.bookUrl) ?: item.oldBook
                 val chapters = changeBookSourceUseCase.loadCandidateChapters(
                     candidate.source,
                     candidate.book
@@ -978,10 +977,10 @@ class BookshelfManageScreenViewModel(
                 ) ?: return@forEachIndexed
                 candidate.book.removeType(BookType.notShelf)
                 if (candidate.book.order == 0) {
-                    candidate.book.order = bookDao.minOrder - 1
+                    candidate.book.order = bookRepository.getMinOrder() - 1
                 }
-                bookDao.insert(candidate.book)
-                bookChapterDao.insert(*chapters.toTypedArray())
+                bookRepository.insert(candidate.book)
+                bookRepository.insertChapters(*chapters.toTypedArray())
             }
         }.onSuccess {
             _uiState.update { it.copy(batchChangePreviewItems = emptyList()) }

+ 19 - 4
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt

@@ -68,6 +68,7 @@ class BookSourceEditActivity :
     private val sourceEntities: ArrayList<EditEntity> = ArrayList()
     private val searchEntities: ArrayList<EditEntity> = ArrayList()
     private val exploreEntities: ArrayList<EditEntity> = ArrayList()
+    private val homepageEntities: ArrayList<EditEntity> = ArrayList()
     private val infoEntities: ArrayList<EditEntity> = ArrayList()
     private val tocEntities: ArrayList<EditEntity> = ArrayList()
     private val contentEntities: ArrayList<EditEntity> = ArrayList()
@@ -174,6 +175,9 @@ class BookSourceEditActivity :
         binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
             setText(R.string.source_tab_find)
         })
+        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
+            setText(R.string.source_tab_homepage)
+        })
         binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
             setText(R.string.source_tab_info)
         })
@@ -234,10 +238,10 @@ class BookSourceEditActivity :
         adapter.editEntities = when (tabPosition) {
             1 -> searchEntities
             2 -> exploreEntities
-            3 -> infoEntities
-            4 -> tocEntities
-            5 -> contentEntities
-//            6 -> reviewEntities
+            3 -> homepageEntities
+            4 -> infoEntities
+            5 -> tocEntities
+            6 -> contentEntities
             else -> sourceEntities
         }
         binding.recyclerView.scrollToPosition(0)
@@ -306,6 +310,11 @@ class BookSourceEditActivity :
             add(EditEntity("coverUrl", er.coverUrl, R.string.rule_cover_url))
             add(EditEntity("bookUrl", er.bookUrl, R.string.r_book_url))
         }
+        // 主页模块
+        homepageEntities.clear()
+        homepageEntities.apply {
+            add(EditEntity("homepageModules", bs.homepageModules, R.string.homepage_modules))
+        }
         // 详情页
         val ir = bs.getBookInfoRule()
         infoEntities.clear()
@@ -473,6 +482,12 @@ class BookSourceEditActivity :
                     viewModel.ruleComplete(it.value, exploreRule.bookList, 2)
             }
         }
+        homepageEntities.forEach {
+            it.value = it.value?.takeIf { s -> s.isNotBlank() }
+            when (it.key) {
+                "homepageModules" -> source.homepageModules = it.value
+            }
+        }
         infoEntities.forEach {
             it.value = it.value?.takeIf { s -> s.isNotBlank() }
             when (it.key) {

+ 2 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -170,6 +170,8 @@ object ThemeConfig {
 
     var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true)
 
+    var navIconHome by prefDelegate(PreferKey.navIconHome, "")
+
     var navIconBookshelf by prefDelegate(PreferKey.navIconBookshelf, "")
 
     var navIconExplore by prefDelegate(PreferKey.navIconExplore, "")

+ 7 - 1
app/src/main/java/io/legado/app/ui/main/MainDestination.kt

@@ -9,6 +9,11 @@ sealed class MainDestination(
     val route: String,
     @StringRes val labelId: Int
 ) {
+    object Home : MainDestination(
+        route = "home",
+        labelId = R.string.home
+    )
+
     object Bookshelf : MainDestination(
         route = "bookshelf",
         labelId = R.string.bookshelf
@@ -30,12 +35,13 @@ sealed class MainDestination(
     )
 
     companion object {
-        val mainDestinations = persistentListOf<MainDestination>(Bookshelf, Explore, Rss, My)
+        val mainDestinations = persistentListOf<MainDestination>(Home, Bookshelf, Explore, Rss, My)
     }
 }
 
 val MainDestination.customIconPath: String
     get() = when (this) {
+        MainDestination.Home -> ThemeConfig.navIconHome
         MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf
         MainDestination.Explore -> ThemeConfig.navIconExplore
         MainDestination.Rss -> ThemeConfig.navIconRss

+ 0 - 7
app/src/main/java/io/legado/app/ui/main/MainFragmentInterface.kt

@@ -1,7 +0,0 @@
-package io.legado.app.ui.main
-
-interface MainFragmentInterface {
-
-    val position: Int?
-
-}

+ 12 - 0
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -71,6 +71,7 @@ import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.main.bookshelf.BookshelfScreen
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.explore.ExploreScreen
+import io.legado.app.ui.main.homepage.HomepageScreen
 import io.legado.app.ui.main.my.MyScreen
 import io.legado.app.ui.main.my.PrefClickEvent
 import io.legado.app.ui.main.rss.RssScreen
@@ -366,6 +367,17 @@ fun MainScreen(
                     ) { page ->
                         val destination = destinations.getOrNull(page) ?: return@HorizontalPager
                         when (destination) {
+                            MainDestination.Home -> HomepageScreen(
+                                onBookClick = { name, author, bookUrl ->
+                                    onNavigateToBookInfo(name ?: "", author ?: "", bookUrl)
+                                },
+                                onModuleHeaderClick = { title, sourceUrl, exploreUrl ->
+                                    onNavigateToExploreShow(title, sourceUrl, exploreUrl)
+                                },
+                                sharedTransitionScope = sharedTransitionScope,
+                                animatedVisibilityScope = animatedVisibilityScope,
+                            )
+
                             MainDestination.Bookshelf -> BookshelfScreen(
                                 onBookClick = { book ->
                                     context.startActivityForBook(book)

+ 48 - 279
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -4,54 +4,38 @@ import android.app.Application
 import android.net.Uri
 import androidx.compose.runtime.snapshotFlow
 import androidx.lifecycle.viewModelScope
-import com.google.gson.stream.JsonWriter
 import io.legado.app.R
 import io.legado.app.base.BaseRuleEvent
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
-import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookGroup
 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.BookRepository
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.BookshelfRepository
 import io.legado.app.data.repository.UploadRepository
+import io.legado.app.domain.usecase.AddBookUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
+import io.legado.app.domain.usecase.ExportBookshelfUseCase
+import io.legado.app.domain.usecase.ImportBookshelfUseCase
+import io.legado.app.domain.usecase.RefreshTocUseCase
 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
-import io.legado.app.help.book.isUpError
-import io.legado.app.help.book.removeType
-import io.legado.app.help.book.sync
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.help.http.decompressed
-import io.legado.app.help.http.newCallResponseBody
-import io.legado.app.help.http.okHttpClient
-import io.legado.app.help.http.text
 import io.legado.app.model.CacheBook
-import io.legado.app.model.ReadBook
 import io.legado.app.model.SourceCallBack
-import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.CacheBookService
 import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
-import io.legado.app.utils.FileUtils
-import io.legado.app.utils.GSON
-import io.legado.app.utils.NetworkUtils
-import io.legado.app.utils.cnCompare
 import io.legado.app.utils.eventBus.FlowEventBus
-import io.legado.app.utils.fromJsonArray
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.move
 import io.legado.app.utils.onEachParallel
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.readText
 import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableMap
@@ -64,9 +48,7 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
-import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -87,23 +69,24 @@ import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Semaphore
-import kotlinx.coroutines.sync.withPermit
-import kotlinx.coroutines.withContext
 import java.io.File
-import java.io.FileOutputStream
-import java.io.OutputStreamWriter
 import java.util.LinkedList
 import java.util.concurrent.ConcurrentHashMap
-import kotlin.math.max
 import kotlin.math.min
 
 class BookshelfViewModel(
     application: Application,
+    private val bookRepository: BookRepository,
     private val bookGroupRepository: BookGroupRepository,
+    private val bookSourceRepository: BookSourceRepository,
+    private val bookshelfRepository: BookshelfRepository,
     private val uploadRepository: UploadRepository,
     private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
-    private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
+    private val updateBooksGroupUseCase: UpdateBooksGroupUseCase,
+    private val refreshTocUseCase: RefreshTocUseCase,
+    private val addBookUseCase: AddBookUseCase,
+    private val importBookshelfUseCase: ImportBookshelfUseCase,
+    private val exportBookshelfUseCase: ExportBookshelfUseCase
 ) : BaseViewModel(application) {
     private var addBookJob: Coroutine<*>? = null
 
@@ -199,14 +182,15 @@ class BookshelfViewModel(
     val booksFlow: Flow<List<BookUiItem>> = groupIdFlow
         .flatMapLatest { groupId ->
             combine(
-                appDb.bookDao.flowBookShelfByGroup(groupId),
+                bookRepository.flowBookShelfByGroup(groupId),
                 groupsFlow,
                 sortConfigFlow
             ) { list, groups, sortConfig ->
-                sortBooks(
+                bookshelfRepository.sortBooks(
                     list,
                     groups.find { it.groupId == groupId },
-                    sortConfig
+                    sortConfig.sort,
+                    sortConfig.sortOrder
                 ).map { it.toUiItem() }
             }
         }.distinctUntilChanged().flowOn(Dispatchers.Default)
@@ -221,8 +205,13 @@ class BookshelfViewModel(
             flowOf(emptyMap())
         } else {
             val flows = groups.map { group ->
-                appDb.bookDao.flowBookShelfByGroup(group.groupId).map { books ->
-                    group.groupId to sortBooks(books, group, sortConfig).map { it.toUiItem() }
+                bookRepository.flowBookShelfByGroup(group.groupId).map { books ->
+                    group.groupId to bookshelfRepository.sortBooks(
+                        books,
+                        group,
+                        sortConfig.sort,
+                        sortConfig.sortOrder
+                    ).map { it.toUiItem() }
                 }
             }
             combine(flows) { it.toMap() }
@@ -269,8 +258,8 @@ class BookshelfViewModel(
     private val groupPreviewsFlow = combine(
         groupsFlow,
         bookGroupStyleFlow,
-        appDb.bookDao.flowSystemGroupCounts(),
-        appDb.bookDao.flowAllBookShelfCount()
+        bookRepository.flowSystemGroupCounts(),
+        bookRepository.flowAllBookShelfCount()
     ) { groups, bookGroupStyle, systemCounts, totalCount ->
         DataForPreviews(
             groups,
@@ -291,11 +280,11 @@ class BookshelfViewModel(
         } else {
             val groupFlows = groups.map { group ->
                 val countFlow: Flow<Int> = if (group.groupId > 0) {
-                    appDb.bookDao.flowUserGroupBookCount(group.groupId)
+                    bookRepository.flowUserGroupBookCount(group.groupId)
                 } else {
                     flowOf(systemCountsMap[group.groupId] ?: 0)
                 }
-                val previewFlow = appDb.bookDao.flowGroupPreview(group.groupId)
+                val previewFlow = bookRepository.flowGroupPreview(group.groupId)
                 combine(countFlow, previewFlow) { count, preview ->
                     Triple(group.groupId, count, preview.map { it.toUiItem() })
                 }
@@ -572,48 +561,6 @@ class BookshelfViewModel(
         }
     }
 
-    private fun sortBooks(
-        list: List<BookShelfItem>,
-        group: BookGroup?,
-        sortConfig: BookshelfSortConfig
-    ): List<BookShelfItem> {
-        val bookSort = if (group != null && group.bookSort >= 0) {
-            group.bookSort
-        } else {
-            sortConfig.sort
-        }
-        val isDescending = sortConfig.sortOrder == 1
-
-        return when (bookSort) {
-            1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime }
-            else list.sortedBy { it.latestChapterTime }
-
-            2 -> if (isDescending)
-                list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) }
-            else
-                list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
-
-            3 -> if (isDescending) list.sortedByDescending { it.order }
-            else list.sortedBy { it.order }
-
-            4 -> if (isDescending) list.sortedByDescending {
-                max(
-                    it.latestChapterTime,
-                    it.durChapterTime
-                )
-            }
-            else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) }
-
-            5 -> if (isDescending)
-                list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) }
-            else
-                list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) }
-
-            else -> if (isDescending) list.sortedByDescending { it.durChapterTime }
-            else list.sortedBy { it.durChapterTime }
-        }
-    }
-
     private fun buildTitle(
         bookGroupStyle: Int,
         isInFolderRoot: Boolean,
@@ -824,8 +771,6 @@ class BookshelfViewModel(
     fun gotoTop() {
         scrollTrigger.tryEmit(Unit)
     }
-
-    // 更新逻辑移入
     fun upAllBookToc() {
         execute {
             addToWaitUp(appDb.bookDao.hasUpdateBooks)
@@ -951,51 +896,8 @@ class BookshelfViewModel(
     }
 
     private suspend fun updateToc(bookUrl: String) {
-        val book = appDb.bookDao.getBook(bookUrl) ?: return
-        val source = appDb.bookSourceDao.getBookSource(book.origin)
-        if (source == null) {
-            if (!book.isUpError) {
-                book.addType(BookType.updateError)
-                appDb.bookDao.update(book)
-            }
-            return
-        }
-        if (source.eventListener) {
-            if (eventListenerSource.putIfAbsent(source, true) == null) {
-                SourceCallBack.callBackSource(
-                    viewModelScope,
-                    SourceCallBack.START_SHELF_REFRESH,
-                    source
-                )
-            }
-        }
-        kotlin.runCatching {
-            val oldBook = book.copy()
-            if (book.tocUrl.isBlank()) {
-                WebBook.getBookInfoAwait(source, book)
-            } else {
-                WebBook.runPreUpdateJs(source, book)
-            }
-            val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
-            book.sync(oldBook)
-            book.removeType(BookType.updateError)
-            if (book.bookUrl == bookUrl) {
-                appDb.bookDao.update(book)
-            } else {
-                appDb.bookDao.replace(oldBook, book)
-                BookHelp.updateCacheFolder(oldBook, book)
-            }
-            appDb.bookChapterDao.delByBook(bookUrl)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
-            ReadBook.onChapterListUpdated(book)
+        refreshTocUseCase.execute(bookUrl) { source, book ->
             addDownload(source, book)
-        }.onFailure {
-            currentCoroutineContext().ensureActive()
-            AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it)
-            appDb.bookDao.getBook(book.bookUrl)?.let { book ->
-                book.addType(BookType.updateError)
-                appDb.bookDao.update(book)
-            }
         }
     }
 
@@ -1045,58 +947,11 @@ class BookshelfViewModel(
     }
 
     fun addBookByUrl(bookUrls: String) {
-        var successCount = 0
         loadingTextFlow.value = "添加中..."
         addBookJob = execute {
-            val hasBookUrlPattern: List<BookSourcePart> by lazy {
-                appDb.bookSourceDao.hasBookUrlPattern
-            }
-            val urls = bookUrls.split("\n")
-            for (url in urls) {
-                val bookUrl = url.trim()
-                if (bookUrl.isEmpty()) continue
-                if (appDb.bookDao.getBook(bookUrl) != null) {
-                    successCount++
-                    continue
-                }
-                val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue
-                var source = appDb.bookSourceDao.getBookSourceAddBook(baseUrl)
-                if (source == null) {
-                    for (bookSource in hasBookUrlPattern) {
-                        try {
-                            val bs = bookSource.getBookSource()!!
-                            if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) {
-                                source = bs
-                                break
-                            }
-                        } catch (_: Exception) {
-                        }
-                    }
-                }
-                val bookSource = source ?: continue
-                val book = Book(
-                    bookUrl = bookUrl,
-                    origin = bookSource.bookSourceUrl,
-                    originName = bookSource.bookSourceName
-                )
-                kotlin.runCatching {
-                    WebBook.getBookInfoAwait(bookSource, book)
-                }.onSuccess {
-                    val dbBook = appDb.bookDao.getBook(it.name, it.author)
-                    if (dbBook != null) {
-                        val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow()
-                        dbBook.migrateTo(it, toc)
-                        appDb.bookDao.insert(it)
-                        appDb.bookChapterDao.insert(*toc.toTypedArray())
-                    } else {
-                        it.order = appDb.bookDao.minOrder - 1
-                        it.save()
-                    }
-                    successCount++
-                    loadingTextFlow.value = "添加中... ($successCount)"
-                }
+            val successCount = addBookUseCase.execute(bookUrls) {
+                loadingTextFlow.value = "添加中... ($it)"
             }
-        }.onSuccess {
             if (successCount > 0) {
                 context.toastOnUi(R.string.success)
             } else {
@@ -1111,23 +966,7 @@ class BookshelfViewModel(
 
     fun exportToUri(uri: Uri, items: List<BookUiItem>) {
         execute {
-            context.contentResolver.openOutputStream(uri)?.use { out ->
-                val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
-                writer.setIndent("  ")
-                writer.beginArray()
-                items.forEach {
-                    val bookMap = hashMapOf<String, String?>()
-                    bookMap["name"] = it.book.name
-                    bookMap["author"] = it.book.author
-                    // intro is not in BookShelfItem, fetch from DB if needed or skip
-                    // For now, let's keep it simple and skip intro or fetch it
-                    val fullBook = appDb.bookDao.getBook(it.book.bookUrl)
-                    bookMap["intro"] = fullBook?.getDisplayIntro()
-                    GSON.toJson(bookMap, bookMap::class.java, writer)
-                }
-                writer.endArray()
-                writer.close()
-            }
+            exportBookshelfUseCase.exportToUri(uri, items).getOrThrow()
         }.onSuccess {
             _eventChannel.trySend(BaseRuleEvent.ShowSnackbar("导出成功"))
         }.onError {
@@ -1137,17 +976,7 @@ class BookshelfViewModel(
 
     fun uploadBookshelf(items: List<BookUiItem>) {
         execute {
-            val json = withContext(Dispatchers.Default) {
-                val list = items.map {
-                    val bookMap = hashMapOf<String, String?>()
-                    bookMap["name"] = it.book.name
-                    bookMap["author"] = it.book.author
-                    val fullBook = appDb.bookDao.getBook(it.book.bookUrl)
-                    bookMap["intro"] = fullBook?.getDisplayIntro()
-                    bookMap
-                }
-                GSON.toJson(list)
-            }
+            val json = exportBookshelfUseCase.exportToJson(items).getOrThrow()
             uploadRepository.upload(
                 fileName = "bookshelf.json",
                 file = json,
@@ -1172,27 +1001,8 @@ class BookshelfViewModel(
 
     fun exportBookshelf(items: List<BookUiItem>?, success: (file: File) -> Unit) {
         execute {
-            items?.let {
-                val path = "${context.filesDir}/books.json"
-                FileUtils.delete(path)
-                val file = FileUtils.createFileWithReplace(path)
-                FileOutputStream(file).use { out ->
-                    val writer = JsonWriter(OutputStreamWriter(out, "UTF-8"))
-                    writer.setIndent("  ")
-                    writer.beginArray()
-                    items.forEach {
-                        val bookMap = hashMapOf<String, String?>()
-                        bookMap["name"] = it.book.name
-                        bookMap["author"] = it.book.author
-                        val fullBook = appDb.bookDao.getBook(it.book.bookUrl)
-                        bookMap["intro"] = fullBook?.getDisplayIntro()
-                        GSON.toJson(bookMap, bookMap::class.java, writer)
-                    }
-                    writer.endArray()
-                    writer.close()
-                }
-                file
-            } ?: throw NoStackTraceException("书籍不能为空")
+            items ?: throw NoStackTraceException("书籍不能为空")
+            exportBookshelfUseCase.exportToFile(items).getOrThrow()
         }.onSuccess {
             success(it)
         }.onError {
@@ -1202,70 +1012,29 @@ class BookshelfViewModel(
 
     fun importBookshelf(str: String, groupId: Long) {
         execute {
-            val text = str.trim()
-            when {
-                text.isAbsUrl() -> {
-                    okHttpClient.newCallResponseBody {
-                        url(text)
-                    }.decompressed().text().let {
-                        importBookshelf(it, groupId)
-                    }
-                }
-
-                text.isJsonArray() -> {
-                    importBookshelfByJson(text, groupId)
-                }
-
-                else -> {
-                    throw NoStackTraceException("格式不对")
-                }
-            }
+            importBookshelfUseCase.import(str, groupId) {
+                loadingTextFlow.value = it
+            }.getOrThrow()
+        }.onSuccess {
+            context.toastOnUi(R.string.success)
         }.onError {
             context.toastOnUi(it.localizedMessage ?: "ERROR")
+        }.onFinally {
+            loadingTextFlow.value = null
         }
     }
 
     fun importBookshelf(uri: Uri, groupId: Long) {
         execute {
-            uri.readText(context)
+            importBookshelfUseCase.import(uri, groupId) {
+                loadingTextFlow.value = it
+            }.getOrThrow()
         }.onSuccess {
-            importBookshelf(it, groupId)
+            context.toastOnUi(R.string.success)
         }.onError {
             context.toastOnUi(it.localizedMessage ?: "ERROR")
-        }
-    }
-
-    private fun importBookshelfByJson(json: String, groupId: Long) {
-        loadingTextFlow.value = "导入中..."
-        execute {
-            val bookSourceParts = appDb.bookSourceDao.allEnabledPart
-            val semaphore = Semaphore(AppConfig.threadCount)
-            GSON.fromJsonArray<Map<String, String?>>(json).getOrThrow().forEach { bookInfo ->
-                val name = bookInfo["name"] ?: ""
-                val author = bookInfo["author"] ?: ""
-                if (name.isEmpty() || appDb.bookDao.has(name, author)) {
-                    return@forEach
-                }
-                semaphore.withPermit {
-                    WebBook.preciseSearch(
-                        this, bookSourceParts, name, author,
-                        semaphore = semaphore
-                    ).onSuccess {
-                        val book = it.first
-                        if (groupId > 0) {
-                            book.group = groupId
-                        }
-                        book.save()
-                    }.onError { e ->
-                        context.toastOnUi(e.localizedMessage)
-                    }
-                }
-            }
-        }.onError {
-            it.printOnDebug()
         }.onFinally {
             loadingTextFlow.value = null
-            context.toastOnUi(R.string.success)
         }
     }
 

+ 26 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt

@@ -0,0 +1,26 @@
+package io.legado.app.ui.main.homepage
+
+import androidx.compose.runtime.State
+import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.prefStateDelegate
+
+/**
+ * 首页配置 — 仅保留无法入库或无需入库的顶层 UI 设置。
+ * 模块级配置(开关、排序、自定义集)已迁移到 homepage_modules 表。
+ */
+object HomepageConfig {
+
+    /**
+     * 首页布局模式 0: 混合列表 1: 分源Tab
+     */
+    private val _homepageLayoutMode = prefStateDelegate(PreferKey.homepageLayoutMode, 0)
+    var homepageLayoutMode by _homepageLayoutMode
+    val homepageLayoutModeState: State<Int> get() = _homepageLayoutMode.state
+
+    /**
+     * 首页书源隐藏
+     */
+    private val _homepageSourceHidden = prefStateDelegate("homepageSourceHidden", "")
+    var homepageSourceHidden by _homepageSourceHidden
+
+}

+ 76 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt

@@ -0,0 +1,76 @@
+package io.legado.app.ui.main.homepage
+
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.entities.rule.ExploreKind
+import io.legado.app.domain.model.HomepageModuleType
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class HomepageUiState(
+    val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
+    val isManageMode: Boolean = false,
+    val isConfigMode: Boolean = false,
+    val isRefreshing: Boolean = false,
+)
+
+@Stable
+data class HomepageSourceManageUi(
+    val sourceUrl: String,
+    val sourceName: String,
+    val sourceGroup: String?,
+    val isSelected: Boolean = false,
+    val moduleCount: Int = 0,
+    val isCustomSet: Boolean = false,
+)
+
+@Stable
+data class HomepageModuleManageUi(
+    val id: String,
+    val sourceUrl: String,
+    val moduleKey: String,
+    val title: String,
+    val customSetTitle: String? = null,
+    val customSetId: String? = null,
+    val isVisible: Boolean,
+    val type: String = "card",
+    val url: String? = null,
+    val args: String? = null,
+    val layoutConfig: String? = null,
+    val originalTitle: String = "",
+)
+
+@Stable
+data class HomepageModuleUi(
+    val sourceUrl: String,
+    val setName: String,
+    val globalId: String,
+    val type: HomepageModuleType,
+    val title: String,
+    val exploreUrl: String? = null,
+    val customSetId: String? = null,
+    val layoutConfig: String? = null,
+    val state: ModuleLoadState = ModuleLoadState.Loading,
+    val config: Map<String, String> = emptyMap()
+)
+
+@Stable
+sealed interface ModuleLoadState {
+    @Stable
+    data object Loading : ModuleLoadState
+
+    @Stable
+    data class Loaded(
+        val books: ImmutableList<SearchBook>,
+        val hasMore: Boolean = false,
+        val isLoadingMore: Boolean = false,
+        val page: Int = 1
+    ) : ModuleLoadState
+
+    @Stable
+    data class Buttons(val kinds: ImmutableList<ExploreKind>) : ModuleLoadState
+
+    @Stable
+    data class Error(val message: String) : ModuleLoadState
+}

+ 17 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt

@@ -0,0 +1,17 @@
+package io.legado.app.ui.main.homepage
+
+sealed interface HomepageEffect {
+    data class NavigateToBookInfo(
+        val name: String?,
+        val author: String?,
+        val bookUrl: String,
+    ) : HomepageEffect
+
+    data class NavigateToExploreShow(
+        val title: String?,
+        val sourceUrl: String,
+        val exploreUrl: String?,
+    ) : HomepageEffect
+
+    data class ShowSnackbar(val message: String) : HomepageEffect
+}

+ 51 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt

@@ -0,0 +1,51 @@
+package io.legado.app.ui.main.homepage
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+
+@Composable
+fun HomepageLayoutSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit,
+    layoutMode: Int,
+    onLayoutModeChange: (Int) -> Unit,
+) {
+    HomepageLayoutSheet(
+        data = if (show) Unit else null,
+        onDismissRequest = onDismissRequest,
+        layoutMode = layoutMode,
+        onLayoutModeChange = onLayoutModeChange,
+    )
+}
+
+@Composable
+fun <T> HomepageLayoutSheet(
+    data: T?,
+    onDismissRequest: () -> Unit,
+    layoutMode: Int,
+    onLayoutModeChange: (Int) -> Unit,
+) {
+    AppModalBottomSheet(
+        data = data,
+        onDismissRequest = onDismissRequest,
+        title = "布局设置",
+    ) {
+        Column {
+            DropdownListSettingItem(
+                title = "首页布局模式",
+                selectedValue = layoutMode.toString(),
+                displayEntries = arrayOf("混合列表", "分源Tab"),
+                entryValues = arrayOf("0", "1"),
+                onValueChange = { onLayoutModeChange(it.toInt()) }
+            )
+
+            Spacer(modifier = Modifier.height(16.dp))
+        }
+    }
+}

+ 1089 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt

@@ -0,0 +1,1089 @@
+package io.legado.app.ui.main.homepage
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.DriveFileRenameOutline
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.google.gson.JsonParser
+import io.legado.app.domain.model.HomepageModuleType
+import io.legado.app.domain.model.ModuleDef
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.JsonConfigEditor
+import io.legado.app.ui.widget.components.JsonKeyEditorConfig
+import io.legado.app.ui.widget.components.JsonRawEditor
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.SecondaryButton
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.card.SelectionItemCard
+import io.legado.app.ui.widget.components.divider.PillDivider
+import io.legado.app.ui.widget.components.icon.AppIcon
+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.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
+import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.move
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun <T> HomepageModuleManageSheet(
+    data: T?,
+    onDismissRequest: () -> Unit,
+    sets: List<HomepageSourceManageUi>,
+    browseSources: List<HomepageSourceManageUi>,
+    onToggleSet: (String, Boolean) -> Unit,
+    onGetModulesInSet: (String) -> List<HomepageModuleManageUi>,
+    onGetSourceModules: (String, String?) -> List<HomepageModuleManageUi>,
+    onToggleModule: (String, Boolean) -> Unit,
+    onJoinModule: (String, String?, ModuleDef) -> Unit,
+    onAddCustomModule: (String, String?, ModuleDef) -> Unit,
+    onAddButtonGroupFromKinds: (String, String?, String, List<String>) -> Unit,
+    onGetExploreKinds: (String) -> List<Pair<String, String>>,
+    onUpdateModule: (String, ModuleDef) -> Unit,
+    onDeleteModule: (String) -> Unit,
+    onReorderModules: (List<String>) -> Unit,
+    onReorderSets: (List<String>) -> Unit = {},
+    onSetCustomSetTitle: (String, String?) -> Unit,
+    onCreateCustomSet: (String) -> Unit,
+    onRenameCustomSet: (String, String) -> Unit,
+    onDeleteCustomSet: (String) -> Unit,
+    onGetAllModulesGroupedBySource: () -> Map<String, List<HomepageModuleManageUi>> = { emptyMap() },
+    onGetSourceName: (String) -> String = { it },
+    onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> },
+) {
+    var selectingSetUrl by remember(data != null) { mutableStateOf<String?>(null) }
+    var browsingSourceUrl by remember(data != null) { mutableStateOf<String?>(null) }
+    var showSourceBrowser by remember(data != null) { mutableStateOf(false) }
+    var renameSetId by remember(data != null) { mutableStateOf<String?>(null) }
+    var showCreateSetDialog by remember(data != null) { mutableStateOf(false) }
+    var addDialogPrefill by remember(data != null) { mutableStateOf<AddDialogPrefill?>(null) }
+    var editingModule by remember(data != null) { mutableStateOf<HomepageModuleManageUi?>(null) }
+    var deleteConfirmId by remember(data != null) { mutableStateOf<String?>(null) }
+    var deleteSetConfirmId by remember(data != null) { mutableStateOf<String?>(null) }
+    var customSetTitleEdit: Pair<String, String>? by remember(data != null) { mutableStateOf(null) }
+
+    var groupFilter by remember(data != null) { mutableStateOf<String?>(null) }
+    var browsingDetail by remember(data != null) { mutableStateOf(false) }
+    var browseTab by remember(data != null) { mutableIntStateOf(0) }
+    var browseModuleType by remember(data != null) { mutableStateOf("card") }
+    var selectedKindTitles by remember(data != null) { mutableStateOf<Set<String>>(emptySet()) }
+    var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) }
+    var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) }
+    var tempButtonGroupTitle by remember(data != null) { mutableStateOf("快捷操作") }
+
+    val currentTargetSetId = remember(selectingSetUrl) {
+        selectingSetUrl?.let { HomepageViewModel.customSetIdFromUrl(it) }
+    }
+
+    val allGroups = remember(browseSources) {
+        browseSources.flatMap { it.sourceGroup?.split(",") ?: emptyList() }
+            .filter { it.isNotBlank() }.distinct().sorted()
+    }
+
+    val filteredBrowseSources = remember(browseSources, groupFilter) {
+        if (groupFilter == null) browseSources
+        else browseSources.filter { it.sourceGroup?.split(",")?.contains(groupFilter) == true }
+    }
+
+    AppModalBottomSheet(
+        data = data,
+        onDismissRequest = {
+            onDismissRequest()
+            selectingSetUrl = null
+            browsingSourceUrl = null
+            showSourceBrowser = false
+            groupFilter = null
+        },
+        title = when {
+            showCustomSetAddModules -> "添加模块"
+            browsingSourceUrl != null && browsingDetail ->
+                browseSources.find { it.sourceUrl == browsingSourceUrl }?.sourceName ?: "模块列表"
+
+            showSourceBrowser || browsingSourceUrl != null -> "浏览书源模块"
+            selectingSetUrl != null && HomepageViewModel.isCustomSetUrl(selectingSetUrl!!) ->
+                (sets.find { it.sourceUrl == selectingSetUrl }?.sourceName ?: "集详情")
+
+            else -> "首页模块管理"
+        },
+        startAction = {
+            if (showCustomSetAddModules) {
+                SmallIconButton(
+                    onClick = { showCustomSetAddModules = false },
+                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
+                )
+            } else if (browsingSourceUrl != null || showSourceBrowser) {
+                SmallIconButton(
+                    onClick = {
+                        if (browsingDetail) browsingDetail = false
+                        else if (showSourceBrowser) showSourceBrowser = false
+                        else browsingSourceUrl = null
+                    },
+                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
+                )
+            } else if (selectingSetUrl != null) {
+                SmallIconButton(
+                    onClick = { selectingSetUrl = null },
+                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
+                )
+            }
+        },
+        endAction = {
+            if (browsingDetail && browseTab == 2 && browseModuleType == "buttonGroup" && selectedKindTitles.isNotEmpty()) {
+                SmallIconButton(
+                    onClick = { showAddButtonGroupDialog = true },
+                    imageVector = Icons.Default.Check
+                )
+            } else if ((showSourceBrowser || browsingSourceUrl != null) && !browsingDetail) {
+                var expanded by remember { mutableStateOf(false) }
+                Box {
+                    SmallIconButton(
+                        onClick = { expanded = true },
+                        imageVector = Icons.Default.FilterList
+                    )
+                    RoundDropdownMenu(
+                        expanded = expanded,
+                        onDismissRequest = { expanded = false }) {
+                        RoundDropdownMenuItem(
+                            text = "全部分组",
+                            onClick = { groupFilter = null; expanded = false },
+                            trailingIcon = if (groupFilter == null) {
+                                { AppIcon(Icons.Default.Check, null, Modifier.size(18.dp)) }
+                            } else null
+                        )
+                        allGroups.forEach { group ->
+                            RoundDropdownMenuItem(
+                                text = group,
+                                onClick = { groupFilter = group; expanded = false },
+                                trailingIcon = if (groupFilter == group) {
+                                    { AppIcon(Icons.Default.Check, null, Modifier.size(18.dp)) }
+                                } else null
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    ) {
+        val setUrl = selectingSetUrl
+        val browseUrl = browsingSourceUrl
+        val isBrowsing = showSourceBrowser || browseUrl != null
+        when {
+            browseUrl != null && browsingDetail -> {
+                // 三级:浏览书源的模块列表(已加入 / 书源模块 / 发现)
+                val displaySetUrl =
+                    selectingSetUrl ?: HomepageViewModel.customSetUrl("src_$browseUrl")
+                val currentSetId = HomepageViewModel.customSetIdFromUrl(displaySetUrl)
+                val joinedModules = onGetModulesInSet(displaySetUrl)
+
+                val standardModules =
+                    joinedModules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
+                val infiniteModules =
+                    joinedModules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
+
+                val joinedKeys = joinedModules.map { it.moduleKey }.toSet()
+                val sourceModules = onGetSourceModules(browseUrl, currentSetId)
+                val exploreKinds = remember(browseUrl) { onGetExploreKinds(browseUrl) }
+
+                Column {
+                    AppTabRow(
+                        tabTitles = listOf("已加入", "书源模块", "发现"),
+                        selectedTabIndex = browseTab,
+                        onTabSelected = { browseTab = it }
+                    )
+                    when (browseTab) {
+                        0 -> {
+                            if (joinedModules.isEmpty()) {
+                                Box(
+                                    modifier = Modifier
+                                        .fillMaxWidth()
+                                        .padding(24.dp),
+                                    contentAlignment = Alignment.Center
+                                ) {
+                                    AppText("暂无已加入的模块")
+                                }
+                            } else {
+                                var listData by remember(displaySetUrl) {
+                                    mutableStateOf(
+                                        standardModules
+                                    )
+                                }
+                                val listState = rememberLazyListState()
+                                val reorderableState =
+                                    rememberReorderableLazyListState(listState) { from, to ->
+                                        listData = listData.toMutableList().apply {
+                                            val fromIndex = (from.index - 1).coerceIn(0, lastIndex)
+                                            val toIndex = (to.index - 1).coerceIn(0, lastIndex)
+                                            move(fromIndex, toIndex)
+                                        }
+                                    }
+                                LaunchedEffect(standardModules) {
+                                    if (!reorderableState.isAnyItemDragging) listData =
+                                        standardModules.distinctBy { it.id }
+                                }
+                                LaunchedEffect(reorderableState.isAnyItemDragging) {
+                                    if (!reorderableState.isAnyItemDragging) {
+                                        val orderedIds =
+                                            listData.map { it.id } + infiniteModules.map { it.id }
+                                        if (orderedIds != joinedModules.map { it.id }) {
+                                            onReorderModules(orderedIds)
+                                        }
+                                    }
+                                }
+                                LazyColumn(
+                                    state = listState,
+                                    modifier = Modifier.fillMaxWidth(),
+                                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                                ) {
+                                    item(key = "header_standard") {
+                                        AppText(
+                                            text = "标准模块 (可拖拽排序)",
+                                            style = MaterialTheme.typography.labelMedium,
+                                            color = MaterialTheme.colorScheme.primary,
+                                            modifier = Modifier.padding(
+                                                horizontal = 16.dp,
+                                                vertical = 4.dp
+                                            )
+                                        )
+                                    }
+
+                                    items(listData, key = { it.id }) { module ->
+                                        ReorderableSelectionItem(
+                                            state = reorderableState,
+                                            key = module.id,
+                                            title = module.title,
+                                            subtitle = HomepageModuleType.fromKey(module.type).title,
+                                            isEnabled = module.isVisible,
+                                            containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                            onEnabledChange = { enabled ->
+                                                onToggleModule(module.id, enabled)
+                                                listData = listData.map {
+                                                    if (it.id == module.id) it.copy(isVisible = enabled) else it
+                                                }
+                                            },
+                                            trailingAction = {
+                                                SmallIconButton(
+                                                    onClick = { editingModule = module },
+                                                    imageVector = Icons.Default.Edit
+                                                )
+                                                SmallIconButton(
+                                                    onClick = { deleteConfirmId = module.id },
+                                                    imageVector = Icons.Default.Delete
+                                                )
+                                            },
+                                            modifier = Modifier.padding(horizontal = 4.dp)
+                                        )
+                                    }
+
+                                    if (infiniteModules.isNotEmpty()) {
+                                        item(key = "header_infinite") {
+                                            PillDivider(
+                                                modifier = Modifier.padding(
+                                                    vertical = 8.dp,
+                                                    horizontal = 16.dp
+                                                )
+                                            )
+                                            AppText(
+                                                text = "底栏无限模块",
+                                                style = MaterialTheme.typography.labelMedium,
+                                                color = MaterialTheme.colorScheme.secondary,
+                                                modifier = Modifier.padding(
+                                                    horizontal = 16.dp,
+                                                    vertical = 4.dp
+                                                )
+                                            )
+                                        }
+
+                                        items(infiniteModules, key = { it.id }) { module ->
+                                            val isEffective =
+                                                infiniteModules.firstOrNull() == module
+                                            SelectionItemCard(
+                                                title = module.title,
+                                                subtitle = HomepageModuleType.fromKey(module.type).title + if (isEffective) " · 当前生效" else " · 已被屏蔽",
+                                                isEnabled = module.isVisible,
+                                                containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent,
+                                                onEnabledChange = { onToggleModule(module.id, it) },
+                                                trailingAction = {
+                                                    SmallIconButton(
+                                                        onClick = { editingModule = module },
+                                                        imageVector = Icons.Default.Edit
+                                                    )
+                                                    SmallIconButton(
+                                                        onClick = { deleteConfirmId = module.id },
+                                                        imageVector = Icons.Default.Delete
+                                                    )
+                                                },
+                                                modifier = Modifier.padding(horizontal = 4.dp)
+                                            )
+                                        }
+                                    }
+                                }
+                            }
+                        }
+
+                        1 -> {
+                            if (sourceModules.isEmpty()) {
+                                Box(
+                                    modifier = Modifier
+                                        .fillMaxWidth()
+                                        .padding(24.dp),
+                                    contentAlignment = Alignment.Center
+                                ) {
+                                    AppText("该书源的 homepageModules JSON 为空")
+                                }
+                            } else {
+                                LazyColumn(
+                                    modifier = Modifier.fillMaxWidth(),
+                                    verticalArrangement = Arrangement.spacedBy(4.dp)
+                                ) {
+                                    items(
+                                        sourceModules.distinctBy { it.id },
+                                        key = { it.id }) { module ->
+                                        val isJoined = joinedKeys.contains(module.moduleKey)
+                                        SelectionItemCard(
+                                            title = module.title,
+                                            subtitle = module.moduleKey + if (isJoined) " · 已加入" else "",
+                                            containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                            isSelected = isJoined,
+                                            inSelectionMode = true,
+                                            onToggleSelection = {
+                                                if (!isJoined) onJoinModule(
+                                                    browseUrl, currentTargetSetId, ModuleDef(
+                                                        key = module.moduleKey,
+                                                        type = module.type,
+                                                        title = module.title,
+                                                        sourceUrl = browseUrl,
+                                                    )
+                                                )
+                                            },
+                                            modifier = Modifier.padding(horizontal = 4.dp)
+                                        )
+                                    }
+                                }
+                            }
+                        }
+
+                        2 -> {
+                            val isButtonGroup = browseModuleType == "buttonGroup"
+                            val selectableKinds = exploreKinds
+                            Column {
+                                val typeList = remember {
+                                    HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
+                                }
+                                CompactDropdownSettingItem(
+                                    title = "模块类型",
+                                    selectedValue = browseModuleType,
+                                    displayEntries = typeList.map { it.title }.toTypedArray(),
+                                    entryValues = typeList.map { it.key }.toTypedArray(),
+                                    onValueChange = {
+                                        browseModuleType = it; selectedKindTitles = emptySet()
+                                    }
+                                )
+                                if (selectableKinds.isEmpty()) {
+                                    Box(
+                                        modifier = Modifier
+                                            .fillMaxWidth()
+                                            .padding(24.dp),
+                                        contentAlignment = Alignment.Center
+                                    ) {
+                                        AppText(
+                                            "该书源暂无发现项",
+                                            color = MaterialTheme.colorScheme.onSurfaceVariant
+                                        )
+                                    }
+                                } else {
+                                    AppText(
+                                        "选择项",
+                                        style = MaterialTheme.typography.labelMedium,
+                                        modifier = Modifier.padding(
+                                            horizontal = 16.dp,
+                                            vertical = 4.dp
+                                        )
+                                    )
+                                    LazyColumn(
+                                        modifier = Modifier
+                                            .fillMaxWidth()
+                                            .weight(1f),
+                                        verticalArrangement = Arrangement.spacedBy(4.dp)
+                                    ) {
+                                        items(
+                                            selectableKinds.distinctBy { it.first + it.second },
+                                            key = { it.first + it.second }) { (kindTitle, kindUrl) ->
+                                            if (isButtonGroup) {
+                                                val isSelected = kindTitle in selectedKindTitles
+                                                SelectionItemCard(
+                                                    title = kindTitle,
+                                                    subtitle = kindUrl.take(60),
+                                                    containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                                    isSelected = isSelected,
+                                                    inSelectionMode = true,
+                                                    onToggleSelection = {
+                                                        selectedKindTitles =
+                                                            if (isSelected) selectedKindTitles - kindTitle
+                                                            else selectedKindTitles + kindTitle
+                                                    },
+                                                    modifier = Modifier.padding(horizontal = 4.dp)
+                                                )
+                                            } else {
+                                                val isJoined = joinedKeys.contains(kindTitle)
+                                                SelectionItemCard(
+                                                    title = kindTitle,
+                                                    subtitle = kindUrl.take(60) + if (isJoined) " · 已加入" else "",
+                                                    containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                                    isSelected = isJoined,
+                                                    inSelectionMode = true,
+                                                    onToggleSelection = {
+                                                        if (!isJoined) addDialogPrefill =
+                                                            AddDialogPrefill(
+                                                                kindTitle,
+                                                                kindUrl,
+                                                                browseModuleType
+                                                            )
+                                                    },
+                                                    modifier = Modifier.padding(horizontal = 4.dp)
+                                                )
+                                            }
+                                        }
+                                    }
+                                }
+                                Spacer(modifier = Modifier.height(12.dp))
+                                SecondaryButton(
+                                    text = "+ 手动添加",
+                                    onClick = {
+                                        addDialogPrefill = AddDialogPrefill(type = browseModuleType)
+                                    },
+                                    modifier = Modifier.fillMaxWidth()
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+
+            showCustomSetAddModules -> {
+                // 自定义集添加模块
+                val currentSetUrl = selectingSetUrl!!
+                val currentSetId = HomepageViewModel.customSetIdFromUrl(currentSetUrl)
+                val initialJoined = onGetModulesInSet(currentSetUrl)
+                    .associateBy({ it.moduleKey }, { it.id })
+                var joinedInCurrent by remember(initialJoined) { mutableStateOf(initialJoined) }
+
+                val grouped = onGetAllModulesGroupedBySource()
+                LazyColumn(
+                    modifier = Modifier.fillMaxWidth(),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    grouped.forEach { (sourceUrl, modules) ->
+                        item(key = "header_$sourceUrl") {
+                            AppText(
+                                text = onGetSourceName(sourceUrl),
+                                style = MaterialTheme.typography.labelLarge,
+                                modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+                            )
+                        }
+                        items(modules, key = { it.sourceUrl + it.moduleKey }) { module ->
+                            val instanceIdInCurrentSet = joinedInCurrent[module.moduleKey]
+                            val inCurrentSet = instanceIdInCurrentSet != null
+                            SelectionItemCard(
+                                title = module.title,
+                                subtitle = module.moduleKey,
+                                containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                isSelected = inCurrentSet,
+                                inSelectionMode = true,
+                                onToggleSelection = {
+                                    if (inCurrentSet) {
+                                        onDeleteModule(instanceIdInCurrentSet!!)
+                                        joinedInCurrent = joinedInCurrent - module.moduleKey
+                                    } else {
+                                        onAssignModuleToCustomSet(module.id, currentSetId)
+                                        joinedInCurrent =
+                                            joinedInCurrent + (module.moduleKey to "temp_${module.id}")
+                                    }
+                                },
+                                modifier = Modifier.padding(horizontal = 4.dp)
+                            )
+                        }
+                    }
+                }
+            }
+
+            isBrowsing -> {
+                // 二级:浏览书源列表
+                LazyColumn(
+                    modifier = Modifier.fillMaxWidth(),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    items(filteredBrowseSources, key = { it.sourceUrl }) { source ->
+                        val moduleCount = onGetSourceModules(source.sourceUrl, null).size
+                        SelectionItemCard(
+                            title = source.sourceName,
+                            subtitle = "$moduleCount 个模块",
+                            containerColor = LegadoTheme.colorScheme.onSheetContent,
+                            onToggleSelection = {
+                                browsingSourceUrl = source.sourceUrl
+                                browsingDetail = true
+                            },
+                            modifier = Modifier.padding(horizontal = 4.dp)
+                        )
+                    }
+                }
+            }
+
+            setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl) -> {
+                // 二级:集详情
+                val setId = HomepageViewModel.customSetIdFromUrl(setUrl)
+                val modules = onGetModulesInSet(setUrl)
+
+                val standardModules =
+                    modules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
+                val infiniteModules =
+                    modules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) }
+
+                if (modules.isEmpty()) {
+                    Column(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(24.dp),
+                        horizontalAlignment = Alignment.CenterHorizontally
+                    ) {
+                        AppText("暂无模块")
+                        SecondaryButton(
+                            text = "浏览书源模块添加",
+                            onClick = {
+                                if (setId.startsWith("src_")) {
+                                    browsingSourceUrl = setId.removePrefix("src_")
+                                    browsingDetail = true
+                                } else {
+                                    showCustomSetAddModules = true
+                                }
+                            }
+                        )
+                    }
+                } else {
+                    var listData by remember(setUrl) { mutableStateOf(standardModules) }
+                    val listState = rememberLazyListState()
+                    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+                        listData = listData.toMutableList().apply {
+                            val fromIndex = (from.index - 1).coerceIn(0, lastIndex)
+                            val toIndex = (to.index - 1).coerceIn(0, lastIndex)
+                            move(fromIndex, toIndex)
+                        }
+                    }
+
+                    LaunchedEffect(standardModules) {
+                        if (!reorderableState.isAnyItemDragging) listData =
+                            standardModules.distinctBy { it.id }
+                    }
+                    LaunchedEffect(reorderableState.isAnyItemDragging) {
+                        if (!reorderableState.isAnyItemDragging) {
+                            val orderedIds = listData.map { it.id } + infiniteModules.map { it.id }
+                            if (orderedIds != modules.map { it.id }) onReorderModules(orderedIds)
+                        }
+                    }
+
+                    LazyColumn(
+                        state = listState,
+                        modifier = Modifier.fillMaxWidth(),
+                        verticalArrangement = Arrangement.spacedBy(8.dp)
+                    ) {
+                        if (listData.isNotEmpty()) {
+                            item(key = "header_std_detail") {
+                                AppText(
+                                    text = "标准模块",
+                                    style = MaterialTheme.typography.labelMedium,
+                                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
+                                )
+                            }
+                            items(listData, key = { it.id }) { module ->
+                                ReorderableSelectionItem(
+                                    state = reorderableState,
+                                    key = module.id,
+                                    title = module.title,
+                                    subtitle = HomepageModuleType.fromKey(module.type).title,
+                                    isEnabled = module.isVisible,
+                                    containerColor = LegadoTheme.colorScheme.onSheetContent,
+                                    onEnabledChange = { enabled ->
+                                        onToggleModule(module.id, enabled)
+                                        listData = listData.map {
+                                            if (it.id == module.id) it.copy(isVisible = enabled) else it
+                                        }
+                                    },
+                                    trailingAction = {
+                                        SmallIconButton(
+                                            onClick = { editingModule = module },
+                                            imageVector = Icons.Default.Edit
+                                        )
+                                        SmallIconButton(
+                                            onClick = { deleteConfirmId = module.id },
+                                            imageVector = Icons.Default.Delete
+                                        )
+                                    },
+                                    modifier = Modifier.padding(horizontal = 4.dp)
+                                )
+                            }
+                        }
+
+                        if (infiniteModules.isNotEmpty()) {
+                            item(key = "header_inf_detail") {
+                                HorizontalDivider(
+                                    modifier = Modifier.padding(
+                                        vertical = 8.dp,
+                                        horizontal = 16.dp
+                                    )
+                                )
+                                AppText(
+                                    text = "无限模块槽位",
+                                    style = MaterialTheme.typography.labelMedium,
+                                    color = MaterialTheme.colorScheme.secondary,
+                                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
+                                )
+                            }
+                            items(infiniteModules, key = { it.id }) { module ->
+                                val isEffective = infiniteModules.firstOrNull() == module
+                                SelectionItemCard(
+                                    title = module.title,
+                                    subtitle = HomepageModuleType.fromKey(module.type).title,
+                                    isEnabled = module.isVisible,
+                                    containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent,
+                                    onEnabledChange = { onToggleModule(module.id, it) },
+                                    trailingAction = {
+                                        SmallIconButton(
+                                            onClick = { editingModule = module },
+                                            imageVector = Icons.Default.Edit
+                                        )
+                                        SmallIconButton(
+                                            onClick = { deleteConfirmId = module.id },
+                                            imageVector = Icons.Default.Delete
+                                        )
+                                    },
+                                    modifier = Modifier.padding(horizontal = 4.dp)
+                                )
+                            }
+                        }
+
+                        item(key = "browse_from_set") {
+                            SecondaryButton(
+                                text = "浏览书源模块",
+                                onClick = {
+                                    if (setId.startsWith("src_")) {
+                                        browsingSourceUrl = setId.removePrefix("src_")
+                                        browsingDetail = true
+                                    } else {
+                                        showCustomSetAddModules = true
+                                    }
+                                },
+                                modifier = Modifier.fillMaxWidth()
+                            )
+                        }
+                    }
+                }
+            }
+
+            else -> {
+                // 一级:集列表
+                var localSets by remember(data != null) { mutableStateOf(sets) }
+                val setsListState = rememberLazyListState()
+                val setsReorderableState =
+                    rememberReorderableLazyListState(setsListState) { from, to ->
+                        localSets = localSets.toMutableList().apply {
+                            val fromIndex = from.index.coerceIn(0, lastIndex)
+                            val toIndex = to.index.coerceIn(0, lastIndex)
+                            move(fromIndex, toIndex)
+                        }
+                    }
+
+                LaunchedEffect(sets) {
+                    if (!setsReorderableState.isAnyItemDragging) localSets = sets
+                }
+                LaunchedEffect(setsReorderableState.isAnyItemDragging) {
+                    if (!setsReorderableState.isAnyItemDragging) {
+                        val orderedUrls = localSets.map { it.sourceUrl }
+                        if (orderedUrls != sets.map { it.sourceUrl }) onReorderSets(orderedUrls)
+                    }
+                }
+
+                LazyColumn(
+                    state = setsListState,
+                    modifier = Modifier.fillMaxWidth(),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    items(localSets, key = { it.sourceUrl }) { set ->
+                        ReorderableSelectionItem(
+                            state = setsReorderableState,
+                            key = set.sourceUrl,
+                            title = set.sourceName,
+                            subtitle = "${set.moduleCount} 个模块",
+                            containerColor = LegadoTheme.colorScheme.onSheetContent,
+                            isEnabled = set.isSelected,
+                            onToggleSelection = { selectingSetUrl = set.sourceUrl },
+                            onEnabledChange = { enabled ->
+                                onToggleSet(set.sourceUrl, enabled)
+                                localSets = localSets.map {
+                                    if (it.sourceUrl == set.sourceUrl) it.copy(isSelected = enabled) else it
+                                }
+                            },
+                            trailingAction = {
+                                SmallIconButton(
+                                    onClick = { renameSetId = set.sourceUrl },
+                                    imageVector = Icons.Default.DriveFileRenameOutline
+                                )
+                                SmallIconButton(
+                                    onClick = { deleteSetConfirmId = set.sourceUrl },
+                                    imageVector = Icons.Default.Delete
+                                )
+                            },
+                            modifier = Modifier.padding(horizontal = 4.dp)
+                        )
+                    }
+                    item(key = "create_set") {
+                        SecondaryButton(
+                            text = "+ 新建自定义集",
+                            onClick = { showCreateSetDialog = true },
+                            modifier = Modifier.fillMaxWidth()
+                        )
+                    }
+                    item(key = "browse_sources") {
+                        SecondaryButton(
+                            text = "浏览书源模块",
+                            onClick = { showSourceBrowser = true },
+                            modifier = Modifier.fillMaxWidth()
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    var tempName by remember { mutableStateOf("") }
+    AppAlertDialog(
+        data = renameSetId,
+        onDismissRequest = { renameSetId = null },
+        title = "重命名自定义集",
+        content = { setId ->
+            val currentName =
+                remember(setId) { sets.find { it.sourceUrl == setId }?.sourceName ?: "" }
+            LaunchedEffect(setId) { tempName = currentName }
+            AppTextField(
+                value = tempName,
+                onValueChange = { tempName = it },
+                label = "名称",
+                modifier = Modifier.fillMaxWidth()
+            )
+        },
+        onConfirm = { setId ->
+            if (tempName.isNotBlank()) onRenameCustomSet(
+                HomepageViewModel.customSetIdFromUrl(setId),
+                tempName
+            )
+            renameSetId = null
+        },
+        confirmText = "确定",
+        dismissText = "取消",
+        onDismiss = { renameSetId = null }
+    )
+
+    AppAlertDialog(
+        data = if (showCreateSetDialog) Unit else null,
+        onDismissRequest = { showCreateSetDialog = false },
+        title = "新建自定义集",
+        content = {
+            LaunchedEffect(Unit) { tempName = "" }
+            AppTextField(
+                value = tempName,
+                onValueChange = { tempName = it },
+                label = "名称",
+                modifier = Modifier.fillMaxWidth()
+            )
+        },
+        onConfirm = {
+            if (tempName.isNotBlank()) onCreateCustomSet(tempName)
+            showCreateSetDialog = false
+        },
+        confirmText = "确定",
+        dismissText = "取消",
+        onDismiss = { showCreateSetDialog = false }
+    )
+
+    AppAlertDialog(
+        data = deleteSetConfirmId,
+        onDismissRequest = { deleteSetConfirmId = null },
+        title = "删除自定义集",
+        text = "确定要删除该集及其包含的所有模块副本吗?",
+        onConfirm = { setId ->
+            onDeleteCustomSet(HomepageViewModel.customSetIdFromUrl(setId))
+            deleteSetConfirmId = null
+        },
+        confirmText = "删除",
+        dismissText = "取消",
+        onDismiss = { deleteSetConfirmId = null }
+    )
+
+    AppAlertDialog(
+        data = deleteConfirmId,
+        onDismissRequest = { deleteConfirmId = null },
+        title = "移除模块",
+        text = "确定要从当前集中移除该模块吗?",
+        onConfirm = { id ->
+            onDeleteModule(id)
+            deleteConfirmId = null
+        },
+        confirmText = "移除",
+        dismissText = "取消",
+        onDismiss = { deleteConfirmId = null }
+    )
+
+    AddCustomModuleDialog(
+        data = addDialogPrefill,
+        sourceUrl = browsingSourceUrl ?: "",
+        targetSetId = currentTargetSetId ?: "",
+        prefillTitle = addDialogPrefill?.title ?: "",
+        prefillUrl = addDialogPrefill?.url ?: "",
+        prefillType = addDialogPrefill?.type ?: "card",
+        onDismissRequest = { addDialogPrefill = null },
+        onConfirm = { def ->
+            onAddCustomModule(browsingSourceUrl!!, currentTargetSetId, def)
+            addDialogPrefill = null
+        }
+    )
+
+    AddCustomModuleDialog(
+        data = editingModule,
+        sourceUrl = editingModule?.sourceUrl ?: "",
+        targetSetId = editingModule?.customSetId ?: "",
+        prefillTitle = editingModule?.title ?: "",
+        prefillUrl = editingModule?.url ?: "",
+        prefillType = editingModule?.type ?: "card",
+        prefillArgs = editingModule?.args ?: "",
+        prefillLayoutConfig = editingModule?.layoutConfig ?: "",
+        onDismissRequest = { editingModule = null },
+        onConfirm = { def ->
+            onUpdateModule(editingModule!!.id, def)
+            editingModule = null
+        }
+    )
+
+    var titleState by remember { mutableStateOf("") }
+    AppAlertDialog(
+        data = if (showAddButtonGroupDialog) Unit else null,
+        onDismissRequest = { showAddButtonGroupDialog = false },
+        title = "添加按钮组",
+        content = {
+            LaunchedEffect(Unit) { tempButtonGroupTitle = "快捷操作" }
+            AppTextField(
+                value = tempButtonGroupTitle,
+                onValueChange = { tempButtonGroupTitle = it },
+                label = "模块标题",
+                modifier = Modifier.fillMaxWidth()
+            )
+        },
+        onConfirm = {
+            onAddButtonGroupFromKinds(
+                browsingSourceUrl!!,
+                currentTargetSetId,
+                tempButtonGroupTitle,
+                selectedKindTitles.toList()
+            )
+            selectedKindTitles = emptySet()
+            showAddButtonGroupDialog = false
+        },
+        confirmText = "确定",
+        dismissText = "取消",
+        onDismiss = { showAddButtonGroupDialog = false }
+    )
+
+    AppAlertDialog(
+        data = customSetTitleEdit,
+        onDismissRequest = { customSetTitleEdit = null },
+        title = "自定义标题",
+        content = { (_, title) ->
+            LaunchedEffect(title) { titleState = title }
+            AppTextField(
+                value = titleState,
+                onValueChange = { titleState = it },
+                label = "标题",
+                modifier = Modifier.fillMaxWidth()
+            )
+        },
+        onConfirm = { (id, _) ->
+            onSetCustomSetTitle(id, titleState.takeIf { it.isNotBlank() })
+            customSetTitleEdit = null
+        },
+        confirmText = "确定",
+        dismissText = "取消",
+        onDismiss = { customSetTitleEdit = null }
+    )
+}
+
+data class AddDialogPrefill(
+    val title: String = "",
+    val url: String = "",
+    val type: String = "card"
+)
+
+@Composable
+fun <T> AddCustomModuleDialog(
+    data: T?,
+    sourceUrl: String = "",
+    targetSetId: String = "",
+    prefillTitle: String = "",
+    prefillUrl: String = "",
+    prefillType: String = "card",
+    prefillArgs: String = "",
+    prefillLayoutConfig: String = "",
+    onDismissRequest: () -> Unit,
+    onConfirm: (ModuleDef) -> Unit,
+) {
+    var title by remember(data) { mutableStateOf(prefillTitle) }
+    var url by remember(data) { mutableStateOf(prefillUrl) }
+    var type by remember(data) { mutableStateOf(prefillType) }
+    var args by remember(data) { mutableStateOf(prefillArgs) }
+    var layoutConfig by remember(data) { mutableStateOf(prefillLayoutConfig) }
+    var showRawLayoutConfig by remember(data) { mutableStateOf(false) }
+
+    val layoutKeyConfigs = remember {
+        mapOf(
+            "fullWidth" to JsonKeyEditorConfig.Switch,
+            "showTitle" to JsonKeyEditorConfig.Switch,
+            "showMore" to JsonKeyEditorConfig.Switch,
+            "isInfinite" to JsonKeyEditorConfig.Switch,
+            "aspectRatio" to JsonKeyEditorConfig.Dropdown(
+                displayEntries = arrayOf("默认", "1:1", "3:4", "2:3", "16:9"),
+                entryValues = arrayOf("", "1:1", "3:4", "2:3", "16:9")
+            )
+        )
+    }
+
+    val hasVisualizableKeys = remember(layoutConfig) {
+        runCatching {
+            val jsonObject = JsonParser.parseString(layoutConfig).asJsonObject
+            jsonObject.keySet().any { key ->
+                key == "columns" || key == "rows" || layoutKeyConfigs.containsKey(key)
+            }
+        }.getOrElse { false }
+    }
+
+    AppAlertDialog(
+        data = data,
+        onDismissRequest = onDismissRequest,
+        title = if (prefillTitle.isEmpty()) "添加模块" else "编辑模块",
+        content = {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(400.dp)
+                    .verticalScroll(rememberScrollState()),
+                verticalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
+                AppTextField(
+                    value = title,
+                    onValueChange = { title = it },
+                    label = "标题",
+                    modifier = Modifier.fillMaxWidth()
+                )
+                AppTextField(
+                    value = url,
+                    onValueChange = { url = it },
+                    label = "URL",
+                    modifier = Modifier.fillMaxWidth()
+                )
+                val typeList = remember {
+                    HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown }
+                }
+                DropdownListSettingItem(
+                    title = "类型",
+                    selectedValue = type,
+                    displayEntries = typeList.map { it.title }.toTypedArray(),
+                    entryValues = typeList.map { it.key }.toTypedArray(),
+                    onValueChange = { type = it }
+                )
+                AppTextField(
+                    value = args,
+                    onValueChange = { args = it },
+                    label = "Args (JSON)",
+                    modifier = Modifier.fillMaxWidth()
+                )
+                AppText(
+                    text = "布局配置",
+                    style = MaterialTheme.typography.labelMedium,
+                    color = MaterialTheme.colorScheme.primary,
+                    modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
+                )
+                if (hasVisualizableKeys) {
+                    JsonConfigEditor(
+                        jsonString = layoutConfig,
+                        onJsonStringChange = { layoutConfig = it },
+                        keyConfigs = layoutKeyConfigs,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                    CompactClickableSettingItem(
+                        title = "编辑原始 JSON (LayoutConfig)",
+                        onClick = { showRawLayoutConfig = !showRawLayoutConfig }
+                    )
+                    if (showRawLayoutConfig) {
+                        JsonRawEditor(
+                            value = layoutConfig,
+                            onValueChange = { layoutConfig = it },
+                            label = "LayoutConfig (JSON) RAW",
+                            modifier = Modifier.fillMaxWidth()
+                        )
+                    }
+                } else {
+                    JsonRawEditor(
+                        value = layoutConfig,
+                        onValueChange = { layoutConfig = it },
+                        label = "LayoutConfig (JSON)",
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                }
+            }
+        },
+        onConfirm = {
+            onConfirm(
+                ModuleDef(
+                    title = title,
+                    url = url,
+                    type = type,
+                    args = args,
+                    layoutConfig = layoutConfig
+                )
+            )
+        },
+        confirmText = "确定",
+        dismissText = "取消",
+        onDismiss = onDismissRequest
+    )
+}

+ 617 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt

@@ -0,0 +1,617 @@
+package io.legado.app.ui.main.homepage
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.clickable
+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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
+import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
+import androidx.compose.foundation.lazy.staggeredgrid.items
+import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.GridView
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.domain.model.HomepageModuleType
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.main.homepage.modules.BannerModule
+import io.legado.app.ui.main.homepage.modules.ButtonGroupModule
+import io.legado.app.ui.main.homepage.modules.CardModule
+import io.legado.app.ui.main.homepage.modules.GridModule
+import io.legado.app.ui.main.homepage.modules.GridRankingModule
+import io.legado.app.ui.main.homepage.modules.RankingModule
+import io.legado.app.ui.main.homepage.modules.WaterfallItem
+import io.legado.app.ui.widget.components.AppPullToRefresh
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.book.SearchBookGridItem
+import io.legado.app.ui.widget.components.button.SecondaryButton
+import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+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.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.utils.sendToClip
+import kotlinx.coroutines.launch
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(
+    ExperimentalMaterial3Api::class,
+    ExperimentalMaterial3ExpressiveApi::class,
+    ExperimentalSharedTransitionApi::class
+)
+@Composable
+fun HomepageScreen(
+    viewModel: HomepageViewModel = koinViewModel(),
+    onBookClick: (name: String?, author: String?, bookUrl: String) -> Unit,
+    onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val allSets by viewModel.setsFlow.collectAsStateWithLifecycle()
+    val browseSources by viewModel.browseSourcesFlow.collectAsStateWithLifecycle()
+    val scope = rememberCoroutineScope()
+    val context = LocalContext.current
+    var errorMsg by remember { mutableStateOf<String?>(null) }
+
+    val layoutMode = HomepageConfig.homepageLayoutModeState.value
+
+    val selectedSets = remember(allSets) { allSets.filter { it.isSelected } }
+    val pagerState = rememberPagerState(pageCount = {
+        if (layoutMode == 1) selectedSets.size.coerceAtLeast(1) else 1
+    })
+
+    val mixedGridState = rememberLazyStaggeredGridState()
+    val currentTitle by remember(
+        layoutMode,
+        pagerState.currentPage,
+        selectedSets,
+        uiState.modules
+    ) {
+        derivedStateOf {
+            if (layoutMode == 1) {
+                "首页"
+            } else {
+                val firstHeader = mixedGridState.layoutInfo.visibleItemsInfo.firstOrNull {
+                    (it.key as? String)?.startsWith("header_") == true
+                }
+                if (firstHeader != null) {
+                    val id = (firstHeader.key as? String).orEmpty().substringAfter("header_", "")
+                    uiState.modules.find { it.globalId == id }?.setName ?: "首页"
+                } else {
+                    "首页"
+                }
+            }
+        }
+    }
+
+    BackHandler(enabled = uiState.isManageMode || uiState.isConfigMode) {
+        if (uiState.isManageMode) viewModel.toggleManageMode()
+        else viewModel.toggleConfigMode()
+    }
+
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is HomepageEffect.NavigateToBookInfo ->
+                    onBookClick(effect.name, effect.author, effect.bookUrl)
+
+                is HomepageEffect.NavigateToExploreShow ->
+                    onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl)
+
+                is HomepageEffect.ShowSnackbar -> {}
+            }
+        }
+    }
+
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        contentWindowInsets = WindowInsets(0),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = currentTitle,
+                scrollBehavior = scrollBehavior,
+                actions = {
+                    TopBarActionButton(
+                        onClick = { viewModel.toggleConfigMode() },
+                        imageVector = Icons.Default.GridView,
+                        contentDescription = "Layout Settings",
+                    )
+                    TopBarActionButton(
+                        onClick = { viewModel.toggleManageMode() },
+                        imageVector = Icons.Default.Settings,
+                        contentDescription = "Manage Modules",
+                    )
+                },
+                bottomContent = {
+                    if (layoutMode == 1 && selectedSets.isNotEmpty()) {
+                        AppTabRow(
+                            tabTitles = selectedSets.map { it.sourceName },
+                            selectedTabIndex = pagerState.currentPage,
+                            onTabSelected = { index ->
+                                scope.launch { pagerState.animateScrollToPage(index) }
+                            }
+                        )
+                    }
+                }
+            )
+        },
+    ) { paddingValues ->
+        AppPullToRefresh(
+            isRefreshing = uiState.isRefreshing,
+            onRefresh = { viewModel.onRefresh() },
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(paddingValues),
+        ) {
+            if (layoutMode == 0) {
+                ModuleList(
+                    modules = uiState.modules,
+                    viewModel = viewModel,
+                    gridState = mixedGridState,
+                    modifier = Modifier.fillMaxSize(),
+                    onErrorClick = { errorMsg = it },
+                    sharedTransitionScope = sharedTransitionScope,
+                    animatedVisibilityScope = animatedVisibilityScope,
+                )
+            } else {
+                if (selectedSets.isEmpty()) {
+                    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+                        AppText("未选择任何书源集")
+                    }
+                } else {
+                    HorizontalPager(
+                        state = pagerState,
+                        modifier = Modifier.fillMaxSize(),
+                        key = { index -> selectedSets.getOrNull(index)?.sourceUrl ?: index }
+                    ) { pageIndex ->
+                        val source = selectedSets.getOrNull(pageIndex)
+                        val sourceModules = remember(uiState.modules, source) {
+                            uiState.modules.filter { module ->
+                                if (source?.isCustomSet == true) {
+                                    val setId =
+                                        HomepageViewModel.customSetIdFromUrl(source.sourceUrl)
+                                    module.customSetId == setId
+                                } else {
+                                    module.sourceUrl == source?.sourceUrl
+                                }
+                            }
+                        }
+                        ModuleList(
+                            modules = sourceModules,
+                            viewModel = viewModel,
+                            modifier = Modifier.fillMaxSize(),
+                            onErrorClick = { errorMsg = it },
+                            sharedTransitionScope = sharedTransitionScope,
+                            animatedVisibilityScope = animatedVisibilityScope,
+                        )
+                    }
+                }
+            }
+        }
+
+        AppAlertDialog(
+            data = errorMsg,
+            onDismissRequest = { errorMsg = null },
+            title = "模块错误",
+            confirmText = "复制",
+            onConfirm = {
+                context.sendToClip(it)
+                errorMsg = null
+            },
+            dismissText = "关闭",
+            onDismiss = { errorMsg = null }
+        )
+
+        HomepageModuleManageSheet(
+            data = if (uiState.isManageMode) Unit else null,
+            onDismissRequest = { viewModel.toggleManageMode() },
+            sets = allSets,
+            browseSources = browseSources,
+            onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) },
+            onGetModulesInSet = { viewModel.getJoinedModules(it) },
+            onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) },
+            onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) },
+            onJoinModule = { sourceUrl, targetSetId, def ->
+                viewModel.joinModule(
+                    sourceUrl,
+                    targetSetId,
+                    def
+                )
+            },
+            onAddCustomModule = { sourceUrl, targetSetId, def ->
+                viewModel.addCustomModule(
+                    sourceUrl,
+                    targetSetId,
+                    def
+                )
+            },
+            onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds ->
+                viewModel.addButtonGroupFromKinds(
+                    sourceUrl,
+                    targetSetId,
+                    title,
+                    kinds
+                )
+            },
+            onGetExploreKinds = { viewModel.getSourceExploreKinds(it) },
+            onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) },
+            onDeleteModule = { viewModel.deleteModule(it) },
+            onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) },
+            onReorderSets = { urls -> viewModel.reorderCustomSets(urls) },
+            onSetCustomSetTitle = { id, title -> viewModel.setModuleCustomSetTitle(id, title) },
+            onCreateCustomSet = { viewModel.createCustomSet(it) },
+            onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) },
+            onDeleteCustomSet = { viewModel.deleteCustomSet(it) },
+            onGetAllModulesGroupedBySource = { viewModel.getAllModulesGroupedBySource() },
+            onGetSourceName = { viewModel.getSourceName(it) },
+            onAssignModuleToCustomSet = { id, setId ->
+                viewModel.assignModuleToCustomSet(
+                    id,
+                    setId
+                )
+            }
+        )
+
+        HomepageLayoutSheet(
+            data = if (uiState.isConfigMode) Unit else null,
+            onDismissRequest = { viewModel.toggleConfigMode() },
+            layoutMode = layoutMode,
+            onLayoutModeChange = { viewModel.setLayoutMode(it) },
+        )
+    }
+}
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+private fun ModuleList(
+    modules: List<HomepageModuleUi>,
+    viewModel: HomepageViewModel,
+    modifier: Modifier = Modifier,
+    gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    onErrorClick: (String) -> Unit
+) {
+    if (modules.isEmpty()) {
+        Box(modifier = modifier, contentAlignment = Alignment.Center) {
+            AppText("请在书源中添加首页模块定义")
+        }
+    } else {
+        // 1. 过滤和重排模块:每个集只能有一个无限流模块,且必须在最下面
+        val processedModules = remember(modules) {
+            fun isInfinite(m: HomepageModuleUi): Boolean {
+                return m.type == HomepageModuleType.Waterfall ||
+                        m.type == HomepageModuleType.InfiniteGrid
+            }
+
+            val infinite = modules.firstOrNull { isInfinite(it) }
+            val others = modules.filter { !isInfinite(it) }
+            if (infinite != null) others + infinite else others
+        }
+
+        val gridColumns = remember(processedModules) {
+            val infiniteModule = processedModules.find { m ->
+                m.type == HomepageModuleType.Waterfall ||
+                        m.type == HomepageModuleType.InfiniteGrid
+            }
+            infiniteModule?.config?.get("layout_columns")?.toIntOrNull() ?: 2
+        }
+
+        LazyVerticalStaggeredGrid(
+            columns = StaggeredGridCells.Fixed(gridColumns),
+            state = gridState,
+            modifier = modifier,
+            verticalItemSpacing = 16.dp,
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 80.dp),
+        ) {
+            processedModules.forEach { moduleUi ->
+                // 1. 头部 (全宽)
+                item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) {
+                    ModuleHeader(
+                        title = moduleUi.title,
+                        onNavigate = {
+                            viewModel.onModuleHeaderClick(
+                                moduleUi.sourceUrl,
+                                moduleUi.exploreUrl,
+                                moduleUi.title,
+                            )
+                        },
+                    )
+                }
+
+                // 2. 内容正文
+                when (val state = moduleUi.state) {
+                    is ModuleLoadState.Loading -> {
+                        item(
+                            key = "loading_${moduleUi.globalId}",
+                            span = StaggeredGridItemSpan.FullLine
+                        ) {
+                            Box(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .height(120.dp),
+                                contentAlignment = Alignment.Center,
+                            ) {
+                                AppCircularProgressIndicator()
+                            }
+                        }
+                    }
+
+                    is ModuleLoadState.Error -> {
+                        item(
+                            key = "error_${moduleUi.globalId}",
+                            span = StaggeredGridItemSpan.FullLine
+                        ) {
+                            Column(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .height(80.dp)
+                                    .clickable { onErrorClick(state.message) },
+                                horizontalAlignment = Alignment.CenterHorizontally,
+                                verticalArrangement = Arrangement.Center,
+                            ) {
+                                AppText(
+                                    text = state.message,
+                                    color = MaterialTheme.colorScheme.error,
+                                    maxLines = 2,
+                                    overflow = TextOverflow.Ellipsis,
+                                    textAlign = TextAlign.Center,
+                                    modifier = Modifier.padding(horizontal = 16.dp)
+                                )
+                                Spacer(modifier = Modifier.height(4.dp))
+                                SecondaryButton(
+                                    text = "重试",
+                                    onClick = {
+                                        viewModel.retryModule(moduleUi.globalId)
+                                    }
+                                )
+                            }
+                        }
+                    }
+
+                    is ModuleLoadState.Buttons -> {
+                        item(
+                            key = "buttons_${moduleUi.globalId}",
+                            span = StaggeredGridItemSpan.FullLine
+                        ) {
+                            ButtonGroupModule(
+                                kinds = state.kinds,
+                                sourceUrl = moduleUi.sourceUrl,
+                                globalId = moduleUi.globalId,
+                                viewModel = viewModel,
+                                modifier = Modifier.fillMaxWidth(),
+                                layoutConfig = moduleUi.layoutConfig
+                            )
+                        }
+                    }
+
+                    is ModuleLoadState.Loaded -> {
+                        val config = moduleUi.config
+                        when (moduleUi.type) {
+                            HomepageModuleType.Waterfall -> {
+                                items(
+                                    state.books,
+                                    key = { "wf_${moduleUi.globalId}_${it.bookUrl}" }) { book ->
+                                    WaterfallItem(
+                                        book = book,
+                                        onClick = { viewModel.onBookClick(book) },
+                                        sharedTransitionScope = sharedTransitionScope,
+                                        animatedVisibilityScope = animatedVisibilityScope,
+                                    )
+                                }
+
+                                if (state.hasMore) {
+                                    item(
+                                        key = "wf_more_${moduleUi.globalId}",
+                                        span = StaggeredGridItemSpan.FullLine
+                                    ) {
+                                        LaunchedEffect(state.books.size) {
+                                            viewModel.loadMoreModule(moduleUi.globalId)
+                                        }
+                                        Box(
+                                            modifier = Modifier
+                                                .fillMaxWidth()
+                                                .padding(16.dp),
+                                            contentAlignment = Alignment.Center
+                                        ) {
+                                            AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
+                                        }
+                                    }
+                                }
+                            }
+
+                            HomepageModuleType.InfiniteGrid -> {
+                                items(
+                                    state.books,
+                                    key = { "inf_grid_${moduleUi.globalId}_${it.bookUrl}" }) { book ->
+                                    SearchBookGridItem(
+                                        book = book,
+                                        shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF,
+                                        onClick = { viewModel.onBookClick(book) },
+                                        sharedTransitionScope = sharedTransitionScope,
+                                        animatedVisibilityScope = animatedVisibilityScope,
+                                        sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                                    )
+                                }
+
+                                if (state.hasMore) {
+                                    item(
+                                        key = "inf_grid_more_${moduleUi.globalId}",
+                                        span = StaggeredGridItemSpan.FullLine
+                                    ) {
+                                        LaunchedEffect(state.books.size) {
+                                            viewModel.loadMoreModule(moduleUi.globalId)
+                                        }
+                                        Box(
+                                            modifier = Modifier
+                                                .fillMaxWidth()
+                                                .padding(16.dp),
+                                            contentAlignment = Alignment.Center
+                                        ) {
+                                            AppCircularProgressIndicator(modifier = Modifier.size(24.dp))
+                                        }
+                                    }
+                                }
+                            }
+
+                            HomepageModuleType.Grid -> {
+                                val rows = config["layout_rows"]?.toIntOrNull() ?: 2
+                                val columns = config["layout_columns"]?.toIntOrNull() ?: 3
+                                item(
+                                    key = "content_${moduleUi.globalId}",
+                                    span = StaggeredGridItemSpan.FullLine
+                                ) {
+                                    GridModule(
+                                        books = state.books,
+                                        onClick = { viewModel.onBookClick(it) },
+                                        modifier = Modifier.fillMaxWidth(),
+                                        columns = columns,
+                                        maxRows = rows,
+                                        sharedTransitionScope = sharedTransitionScope,
+                                        animatedVisibilityScope = animatedVisibilityScope,
+                                    )
+                                }
+                            }
+
+                            else -> {
+                                when (moduleUi.type) {
+                                    HomepageModuleType.Banner -> {
+                                        item(
+                                            key = "content_${moduleUi.globalId}",
+                                            span = StaggeredGridItemSpan.FullLine
+                                        ) {
+                                            BannerModule(
+                                                books = state.books,
+                                                onClick = { viewModel.onBookClick(it) },
+                                                modifier = Modifier.fillMaxWidth(),
+                                                sharedTransitionScope = sharedTransitionScope,
+                                                animatedVisibilityScope = animatedVisibilityScope,
+                                            )
+                                        }
+                                    }
+
+                                    HomepageModuleType.Ranking -> {
+                                        item(
+                                            key = "content_${moduleUi.globalId}",
+                                            span = StaggeredGridItemSpan.FullLine
+                                        ) {
+                                            RankingModule(
+                                                books = state.books,
+                                                onClick = { viewModel.onBookClick(it) },
+                                                modifier = Modifier.fillMaxWidth(),
+                                                sharedTransitionScope = sharedTransitionScope,
+                                                animatedVisibilityScope = animatedVisibilityScope,
+                                            )
+                                        }
+                                    }
+
+                                    HomepageModuleType.GridRanking -> {
+                                        item(
+                                            key = "content_${moduleUi.globalId}",
+                                            span = StaggeredGridItemSpan.FullLine
+                                        ) {
+                                            GridRankingModule(
+                                                books = state.books,
+                                                onClick = { viewModel.onBookClick(it) },
+                                                modifier = Modifier.fillMaxWidth(),
+                                                rows = config["layout_rows"]?.toIntOrNull() ?: 4,
+                                                sharedTransitionScope = sharedTransitionScope,
+                                                animatedVisibilityScope = animatedVisibilityScope,
+                                            )
+                                        }
+                                    }
+
+                                    HomepageModuleType.Card -> {
+                                        item(
+                                            key = "content_${moduleUi.globalId}",
+                                            span = StaggeredGridItemSpan.FullLine
+                                        ) {
+                                            CardModule(
+                                                books = state.books,
+                                                onClick = { viewModel.onBookClick(it) },
+                                                modifier = Modifier.fillMaxWidth(),
+                                                sharedTransitionScope = sharedTransitionScope,
+                                                animatedVisibilityScope = animatedVisibilityScope,
+                                            )
+                                        }
+                                    }
+
+                                    else -> {}
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ModuleHeader(
+    title: String,
+    onNavigate: () -> Unit,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(top = 12.dp, bottom = 4.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        AppText(
+            text = title,
+            style = MaterialTheme.typography.titleMedium,
+            fontWeight = FontWeight.Bold,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            modifier = Modifier.weight(1f),
+        )
+        SmallTonalIconButton(
+            onClick = onNavigate,
+            imageVector = Icons.AutoMirrored.Filled.ArrowForward
+        )
+    }
+}

+ 92 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt

@@ -0,0 +1,92 @@
+package io.legado.app.ui.main.homepage
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.card.SelectionItemCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+
+@Composable
+fun HomepageSourceSelectSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit,
+    sources: List<HomepageSourceManageUi>,
+    onToggleSource: (String) -> Unit,
+    onSelectAll: () -> Unit,
+) {
+    var filterText by remember(show) { mutableStateOf("") }
+
+    val filteredSources = remember(sources, filterText) {
+        if (filterText.isBlank()) sources else sources.filter {
+            it.sourceName.contains(filterText, ignoreCase = true) ||
+                    it.sourceGroup?.contains(filterText, ignoreCase = true) == true
+        }
+    }
+
+    val isAllSelected = remember(sources) {
+        sources.all { it.isSelected } || sources.none { it.isSelected }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = "筛选书源",
+    ) {
+        Column {
+            SearchBar(
+                query = filterText,
+                onQueryChange = { filterText = it },
+                placeholder = stringResource(R.string.screen),
+                autoFocus = false
+            )
+
+            Spacer(modifier = Modifier.height(8.dp))
+
+            SelectionItemCard(
+                title = stringResource(R.string.all_source),
+                isSelected = isAllSelected,
+                containerColor = LegadoTheme.colorScheme.onSheetContent,
+                inSelectionMode = true,
+                onToggleSelection = {
+                    onSelectAll()
+                }
+            )
+
+            Spacer(modifier = Modifier.height(8.dp))
+
+            LazyColumn(
+                modifier = Modifier.heightIn(max = 480.dp),
+                verticalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
+                items(filteredSources, key = { it.sourceUrl }) { source ->
+                    SelectionItemCard(
+                        title = source.sourceName,
+                        subtitle = source.sourceGroup?.takeIf { it.isNotBlank() },
+                        containerColor = LegadoTheme.colorScheme.onSheetContent,
+                        isSelected = source.isSelected,
+                        inSelectionMode = true,
+                        onToggleSelection = {
+                            onToggleSource(source.sourceUrl)
+                        }
+                    )
+                }
+            }
+            Spacer(modifier = Modifier.height(16.dp))
+        }
+    }
+}

+ 882 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt

@@ -0,0 +1,882 @@
+package io.legado.app.ui.main.homepage
+
+import android.app.Application
+import androidx.lifecycle.viewModelScope
+import io.legado.app.base.BaseViewModel
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.domain.gateway.HomepageModulesGateway
+import io.legado.app.domain.model.CustomSetItem
+import io.legado.app.domain.model.HomepageModuleType
+import io.legado.app.domain.model.ModuleDef
+import io.legado.app.domain.model.ModuleItem
+import io.legado.app.domain.usecase.ExploreBooksUseCase
+import io.legado.app.domain.usecase.SaveSearchBooksUseCase
+import io.legado.app.help.source.exploreKinds
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.security.MessageDigest
+
+class HomepageViewModel(
+    application: Application,
+    private val bookSourceRepository: BookSourceRepository,
+    private val gateway: HomepageModulesGateway,
+    private val exploreBooksUseCase: ExploreBooksUseCase,
+    private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
+) : BaseViewModel(application) {
+
+    companion object {
+        private const val CUSTOM_SET_URL_PREFIX = "custom://"
+        private const val HOMEPAGE_DEFAULT_GRID_ROWS = 2
+        private const val HOMEPAGE_MAX_BUTTON_GROUP_KINDS = 5
+
+        fun customSetUrl(id: String) = "$CUSTOM_SET_URL_PREFIX$id"
+        fun isCustomSetUrl(url: String) = url.startsWith(CUSTOM_SET_URL_PREFIX)
+        fun customSetIdFromUrl(url: String): String = url.removePrefix(CUSTOM_SET_URL_PREFIX)
+
+        fun isInfinite(type: String?, layoutConfig: String?): Boolean {
+            return type == HomepageModuleType.Waterfall.key
+                    || type == HomepageModuleType.InfiniteGrid.key
+        }
+
+        private fun parseModuleDefs(source: BookSource, json: String): List<ModuleDef> =
+            GSON.fromJsonArray<ModuleDef>(json).getOrDefault(emptyList())
+                .map { it.copy(sourceUrl = source.bookSourceUrl) }
+
+        private fun jsonHash(json: String): String {
+            val digest = MessageDigest.getInstance("MD5").digest(json.toByteArray(Charsets.UTF_8))
+            return digest.joinToString("") { "%02x".format(it) }
+        }
+
+        private fun List<ModuleItem>.groupBySourceOrdered(): Map<String, List<ModuleItem>> {
+            val result = linkedMapOf<String, MutableList<ModuleItem>>()
+            for (module in this) {
+                val key = module.customSetId?.let { customSetUrl(it) } ?: module.sourceUrl
+                result.getOrPut(key) { mutableListOf() }.add(module)
+            }
+            return result
+        }
+    }
+
+    private val _effects = MutableSharedFlow<HomepageEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private val loadJobs = mutableMapOf<String, Job>()
+    private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules()
+    private val exploreSourcesFlow = bookSourceRepository.flowExploreSources()
+
+    private val _isRefreshing = MutableStateFlow(false)
+    private val _isManageMode = MutableStateFlow(false)
+    private val _isConfigMode = MutableStateFlow(false)
+    private val _configVersion = MutableStateFlow(0L)
+    private val _moduleContentStates = MutableStateFlow<Map<String, ModuleLoadState>>(emptyMap())
+
+    private val localModulesFlow = gateway.flowEnabled()
+    private val _bookSourcesCache = MutableStateFlow<Map<String, BookSource>>(emptyMap())
+
+    val allModulesCache = gateway.flowAll()
+        .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
+    val customSetsFlow = gateway.flowCustomSets()
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+    private val orderedModuleDefsFlow = combine(localModulesFlow, _configVersion) { modules, _ ->
+        modules.groupBySourceOrdered()
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
+
+    private val uiFlagsFlow =
+        combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config ->
+            HomepageUiFlags(refreshing, manage, config)
+        }
+
+    val uiState: StateFlow<HomepageUiState> = combine(
+        orderedModuleDefsFlow,
+        _moduleContentStates,
+        uiFlagsFlow,
+        _bookSourcesCache,
+        customSetsFlow
+    ) { grouped, contentStates, flags, sourcesCache, customSets ->
+        val setNames = customSets.associate { it.id to it.name }
+        val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id }
+
+        // 按照集排序设置来排布模块
+        val displayModules = sortedSetIds.flatMap { setId ->
+            val setUrl = customSetUrl(setId)
+            val mods = grouped[setUrl] ?: emptyList()
+            mods.map { module ->
+                val source = sourcesCache[module.sourceUrl]
+                val sourceName = source?.bookSourceName ?: module.sourceUrl
+                val setName = module.customSetId?.let { setNames[it] } ?: sourceName
+                val exploreUrl = module.url ?: source?.exploreUrl
+
+                val configMap = mutableMapOf<String, String>()
+                module.layoutConfig?.let { configStr ->
+                    try {
+                        val json = GSON.fromJson(configStr, Map::class.java)
+                        json?.forEach { (k, v) ->
+                            configMap["layout_$k"] = v.toString()
+                        }
+                    } catch (_: Exception) {
+                    }
+                }
+
+                HomepageModuleUi(
+                    sourceUrl = module.sourceUrl,
+                    setName = setName,
+                    globalId = module.id,
+                    type = HomepageModuleType.fromKey(module.type),
+                    title = module.displayTitle,
+                    exploreUrl = exploreUrl,
+                    customSetId = module.customSetId,
+                    layoutConfig = module.layoutConfig,
+                    state = contentStates[module.id] ?: ModuleLoadState.Loading,
+                    config = configMap
+                )
+            }
+        }
+        HomepageUiState(
+            modules = displayModules.toImmutableList(),
+            isRefreshing = flags.isRefreshing,
+            isManageMode = flags.isManageMode,
+            isConfigMode = flags.isConfigMode,
+        )
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState())
+
+    val setsFlow = combine(
+        localModulesFlow,
+        allModulesCache,
+        customSetsFlow,
+        _configVersion
+    ) { _, allModules, customSets, _ ->
+        val hiddenSourceUrls = GSON.fromJsonArray<String>(HomepageConfig.homepageSourceHidden)
+            .getOrDefault(emptyList()).toSet()
+        val moduleCountsBySet =
+            allModules.mapNotNull { it.customSetId }.groupBy { it }.mapValues { it.value.size }
+
+        val list = mutableListOf<HomepageSourceManageUi>()
+        customSets.sortedBy { it.sortOrder }.forEach { set ->
+            list.add(
+                HomepageSourceManageUi(
+                    sourceUrl = customSetUrl(set.id),
+                    sourceName = set.name,
+                    sourceGroup = null,
+                    isSelected = customSetUrl(set.id) !in hiddenSourceUrls,
+                    moduleCount = moduleCountsBySet[set.id] ?: 0,
+                    isCustomSet = true,
+                )
+            )
+        }
+        list.toImmutableList()
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf())
+
+    /** 用于「浏览书源模块」:列出有 homepageModules 的书源 */
+    val browseSourcesFlow = exploreSourcesFlow.map { sources ->
+        sources.map { source ->
+            HomepageSourceManageUi(
+                sourceUrl = source.bookSourceUrl,
+                sourceName = source.bookSourceName,
+                sourceGroup = source.bookSourceGroup,
+            )
+        }.toImmutableList()
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf())
+
+    private val _exploreKindsCache =
+        MutableStateFlow<Map<String, List<Pair<String, String>>>>(emptyMap())
+    private val _pendingEnabled = MutableStateFlow<Map<String, Boolean>>(emptyMap())
+    private val _pendingUserModules = MutableStateFlow<List<ModuleItem>>(emptyList())
+
+    init {
+        // sync: 只处理有 homepageModules 的书源
+        viewModelScope.launch {
+            initModulesSyncFlow.collect { sources ->
+                sources.forEach { source -> syncModulesFromSource(source) }
+            }
+        }
+
+        // cache: 所有启用发现的书源(包括无 homepageModules 的)
+        viewModelScope.launch {
+            exploreSourcesFlow.collect { sources ->
+                _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl }
+                val kindsCache = mutableMapOf<String, List<Pair<String, String>>>()
+                for (source in sources) {
+                    kindsCache[source.bookSourceUrl] = try {
+                        withContext(Dispatchers.IO) {
+                            source.exploreKinds().map { it.title to (it.url ?: "") }
+                        }
+                    } catch (_: Exception) {
+                        emptyList()
+                    }
+                }
+                _exploreKindsCache.value = kindsCache
+            }
+        }
+
+        viewModelScope.launch {
+            uiState.map { it.modules }.collect { modules ->
+                modules.forEach { ui ->
+                    if (ui.state is ModuleLoadState.Loading && loadJobs[ui.globalId]?.isActive != true) {
+                        val module = gateway.getById(ui.globalId)
+                        if (module != null) loadModule(module)
+                    }
+                }
+            }
+        }
+
+        // 清理 _pendingUserModules 中已入库的条目
+        viewModelScope.launch {
+            allModulesCache.collect { modules ->
+                val dbIds = modules.map { it.id }.toSet()
+                _pendingUserModules.update { pending -> pending.filter { it.id !in dbIds } }
+            }
+        }
+
+        // 一次性迁移:将 customSetId=null 的存量模块归属到书源集,并确保所有集存在
+        viewModelScope.launch {
+            val allModules = allModulesCache.first()
+            val orphans = allModules.filter { it.customSetId == null }
+            if (orphans.isNotEmpty()) {
+                orphans.groupBy { it.sourceUrl }.forEach { (sourceUrl, modules) ->
+                    val source = bookSourceRepository.getBookSource(sourceUrl) ?: return@forEach
+                    ensureSetForSource(sourceUrl, source.bookSourceName)
+                    modules.forEach { m ->
+                        gateway.setCustomSetId(m.id, "src_$sourceUrl")
+                    }
+                }
+            }
+            // 确保所有 customSetId 对应的集都存在
+            allModules.mapNotNull { it.customSetId }.distinct().forEach { setId ->
+                val isSrcSet = setId.startsWith("src_")
+                if (isSrcSet && gateway.getCustomSetById(setId) == null) {
+                    val sourceUrl = setId.removePrefix("src_")
+                    val source = bookSourceRepository.getBookSource(sourceUrl)
+                    if (source != null) {
+                        ensureSetForSource(sourceUrl, source.bookSourceName)
+                    }
+                }
+            }
+        }
+    }
+
+    private suspend fun syncModulesFromSource(source: BookSource) {
+        val json = source.homepageModules ?: return
+        ensureSetForSource(source.bookSourceUrl, source.bookSourceName)
+        val parsedDefs = parseModuleDefs(source, json)
+        val newHash = jsonHash(json)
+
+        val existingModules = gateway.flowBySource(source.bookSourceUrl).first()
+        val existingById = existingModules.associateBy { it.id }
+        val parsedIds = parsedDefs.map { it.globalId }.toSet()
+
+        val toUpsert = mutableListOf<ModuleItem>()
+
+        for (i in parsedDefs.indices) {
+            val def = parsedDefs[i]
+            val existing = existingById[def.globalId]
+            if (existing != null) {
+                // 用户编辑过的模块不被 JSON 覆盖
+                if (existing.isUserCreated) continue
+                if (existing.sourceJsonHash == newHash) continue
+                toUpsert.add(
+                    existing.copy(
+                        type = def.type,
+                        title = def.title,
+                        args = def.args,
+                        url = def.url,
+                        sourceJsonHash = newHash,
+                        syncedAt = System.currentTimeMillis(),
+                    )
+                )
+            } else {
+                toUpsert.add(
+                    ModuleItem(
+                        id = def.globalId,
+                        sourceUrl = source.bookSourceUrl,
+                        moduleKey = def.key,
+                        type = def.type,
+                        title = def.title,
+                        args = def.args,
+                        url = def.url,
+                        isEnabled = true,
+                        customSetId = "src_${source.bookSourceUrl}",
+                        sortOrder = i,
+                        sourceJsonHash = newHash,
+                        syncedAt = System.currentTimeMillis(),
+                    )
+                )
+            }
+        }
+
+        if (toUpsert.isNotEmpty()) {
+            gateway.upsertAll(toUpsert)
+        }
+
+        if (parsedIds.isNotEmpty()) {
+            gateway.deleteStale(source.bookSourceUrl, parsedIds.toList())
+        }
+    }
+
+    private fun loadModule(module: ModuleItem) {
+        loadJobs[module.id]?.cancel()
+        if (module.type == HomepageModuleType.ButtonGroup.key) {
+            loadJobs[module.id] = viewModelScope.launch {
+                kotlin.runCatching {
+                    val source = bookSourceRepository.getBookSource(module.sourceUrl)
+                        ?: throw Exception("Source not found")
+                    val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() }
+
+                    val selectedTitles = module.args?.let { argsStr ->
+                        GSON.fromJsonArray<String>(argsStr).getOrNull()
+                    }
+
+                    if (selectedTitles.isNullOrEmpty()) {
+                        allKinds.take(HOMEPAGE_MAX_BUTTON_GROUP_KINDS)
+                    } else {
+                        selectedTitles.mapNotNull { t -> allKinds.find { it.title == t } }
+                    }
+                }.onSuccess { kinds ->
+                    _moduleContentStates.update { it + (module.id to ModuleLoadState.Buttons(kinds.toImmutableList())) }
+                }.onFailure { e ->
+                    _moduleContentStates.update {
+                        it + (module.id to ModuleLoadState.Error(
+                            e.message ?: "Unknown error"
+                        ))
+                    }
+                }
+            }.also {
+                it.invokeOnCompletion { loadJobs.remove(module.id) }
+            }
+            return
+        }
+        loadJobs[module.id] = viewModelScope.launch {
+            kotlin.runCatching {
+                val isRanking = module.type == HomepageModuleType.Ranking.key
+                        || module.type == HomepageModuleType.GridRanking.key
+                val books = if (isRanking) {
+                    exploreBooksUseCase.executeForRanking(module.sourceUrl, module.url, module.args)
+                } else {
+                    exploreBooksUseCase.execute(module.sourceUrl, module.url, module.args).books
+                }
+                val layout = try {
+                    GSON.fromJson(module.layoutConfig, Map::class.java)
+                } catch (_: Exception) {
+                    null
+                }
+                val rows = (layout?.get("rows") as? Number)?.toInt() ?: HOMEPAGE_DEFAULT_GRID_ROWS
+                val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty()
+                books to hasMore
+            }.onSuccess { (books, hasMore) ->
+                _moduleContentStates.update {
+                    it + (module.id to ModuleLoadState.Loaded(
+                        books.toImmutableList(),
+                        hasMore = hasMore,
+                        page = 1
+                    ))
+                }
+            }.onFailure { e ->
+                _moduleContentStates.update {
+                    it + (module.id to ModuleLoadState.Error(
+                        e.message ?: "Unknown error"
+                    ))
+                }
+            }
+        }.also {
+            it.invokeOnCompletion { loadJobs.remove(module.id) }
+        }
+    }
+
+    fun loadMoreModule(globalId: String) {
+        val currentState = _moduleContentStates.value[globalId] as? ModuleLoadState.Loaded ?: return
+        if (currentState.isLoadingMore || !currentState.hasMore) return
+
+        val nextPage = currentState.page + 1
+        _moduleContentStates.update { it + (globalId to currentState.copy(isLoadingMore = true)) }
+
+        viewModelScope.launch {
+            kotlin.runCatching {
+                val module = gateway.getById(globalId) ?: throw Exception("Module not found")
+                exploreBooksUseCase.execute(
+                    module.sourceUrl,
+                    module.url,
+                    module.args,
+                    page = nextPage
+                )
+            }.onSuccess { result ->
+                val newBooks = result.books
+                _moduleContentStates.update { states ->
+                    val lastState =
+                        states[globalId] as? ModuleLoadState.Loaded ?: return@update states
+                    val existingUrls = lastState.books.map { it.bookUrl }.toSet()
+                    val deduped = newBooks.filter { it.bookUrl !in existingUrls }
+                    val combinedBooks = (lastState.books + deduped).toImmutableList()
+                    states + (globalId to ModuleLoadState.Loaded(
+                        books = combinedBooks,
+                        hasMore = deduped.isNotEmpty(),
+                        isLoadingMore = false,
+                        page = nextPage
+                    ))
+                }
+            }.onFailure { e ->
+                _moduleContentStates.update { states ->
+                    val lastState =
+                        states[globalId] as? ModuleLoadState.Loaded ?: return@update states
+                    states + (globalId to lastState.copy(isLoadingMore = false))
+                }
+                _effects.tryEmit(HomepageEffect.ShowSnackbar("加载更多失败: ${e.message}"))
+            }
+        }
+    }
+
+    fun refreshButtonGroup(globalId: String) {
+        viewModelScope.launch {
+            val module = gateway.getById(globalId) ?: return@launch
+            loadModule(module)
+        }
+    }
+
+    fun onKindUrlClick(sourceUrl: String, url: String, title: String) =
+        _effects.tryEmit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, url))
+
+    fun onRefresh() {
+        viewModelScope.launch {
+            _isRefreshing.value = true
+            loadJobs.values.forEach { it.cancel() }
+            loadJobs.clear()
+            _moduleContentStates.value = emptyMap()
+            uiState.map { it.modules }.first { modules ->
+                modules.all { it.state !is ModuleLoadState.Loading }
+            }
+            _isRefreshing.value = false
+        }
+    }
+
+    fun retryModule(globalId: String) {
+        _moduleContentStates.update { it + (globalId to ModuleLoadState.Loading) }
+    }
+
+    fun toggleManageMode() = _isManageMode.update { !it }
+    fun toggleConfigMode() = _isConfigMode.update { !it }
+
+    fun setModuleVisible(id: String, visible: Boolean) {
+        _pendingEnabled.update { it + (id to visible) }
+        viewModelScope.launch {
+            val existing = gateway.getById(id)
+            if (existing != null) {
+                gateway.setEnabled(id, visible)
+            } else {
+                // 如果模块尚未入库(虚拟状态),则根据 ID 规则解析并入库
+                val parts = id.split("::")
+                if (parts.size >= 3) {
+                    val setId = parts[0]
+                    val sourceUrl = parts[1]
+                    val key = parts.subList(2, parts.size).joinToString("::")
+                    ensureModuleInDb(sourceUrl, key, id, setId)
+                    gateway.setEnabled(id, visible)
+                }
+            }
+            _pendingEnabled.update { it - id }
+            notifyConfigChanged()
+        }
+    }
+
+    fun toggleSourceFilter(sourceUrl: String, isEnabled: Boolean) {
+        val hidden = GSON.fromJsonArray<String>(HomepageConfig.homepageSourceHidden)
+            .getOrDefault(emptyList()).toMutableSet()
+        if (isEnabled) hidden.remove(sourceUrl) else hidden.add(sourceUrl)
+        HomepageConfig.homepageSourceHidden = GSON.toJson(hidden.toList())
+        notifyConfigChanged()
+    }
+
+    fun setLayoutMode(mode: Int) {
+        HomepageConfig.homepageLayoutMode = mode
+        notifyConfigChanged()
+    }
+
+    private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String {
+        val setId = "src_$sourceUrl"
+        if (gateway.getCustomSetById(setId) == null) {
+            gateway.upsertCustomSet(CustomSetItem(id = setId, name = sourceName))
+        }
+        return setId
+    }
+
+    fun addCustomModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) {
+        val key = def.key.ifBlank { def.title }
+        val setId = targetSetId ?: "src_$sourceUrl"
+
+        if (isInfinite(def.type, def.layoutConfig)) {
+            val hasInfinite = allModulesCache.value.any {
+                it.customSetId == setId && isInfinite(
+                    it.type,
+                    it.layoutConfig
+                )
+            }
+            if (hasInfinite) {
+                viewModelScope.launch {
+                    _effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块"))
+                }
+                return
+            }
+        }
+
+        val id = ModuleDef.globalIdOf(sourceUrl, key, setId)
+        val module = ModuleItem(
+            id = id,
+            sourceUrl = sourceUrl,
+            moduleKey = key,
+            type = def.type,
+            title = def.title,
+            args = def.args,
+            layoutConfig = def.layoutConfig,
+            url = def.url,
+            isEnabled = true,
+            isUserCreated = true,
+            customSetId = setId,
+            syncedAt = System.currentTimeMillis(),
+        )
+        viewModelScope.launch {
+            val source = bookSourceRepository.getBookSource(sourceUrl)
+            if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName)
+            gateway.upsertAll(listOf(module))
+            _pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module }
+            notifyConfigChanged()
+        }
+    }
+
+    fun getSourceExploreKinds(sourceUrl: String): List<Pair<String, String>> {
+        return _exploreKindsCache.value[sourceUrl].orEmpty()
+    }
+
+    fun updateModule(globalId: String, def: ModuleDef) {
+        viewModelScope.launch {
+            val existing = gateway.getById(globalId) ?: return@launch
+            gateway.upsertAll(
+                listOf(
+                    existing.copy(
+                        customTitle = def.title.takeIf { it != existing.title },
+                        type = def.type,
+                        url = def.url,
+                        args = def.args,
+                        layoutConfig = def.layoutConfig,
+                        isUserCreated = true,  // 标记为用户编辑,阻止 JSON 同步覆盖
+                        syncedAt = System.currentTimeMillis(),
+                    )
+                )
+            )
+            notifyConfigChanged()
+        }
+    }
+
+    fun setModuleCustomSetTitle(globalId: String, customSetTitle: String?) {
+        viewModelScope.launch {
+            gateway.setCustomSetTitle(globalId, customSetTitle)
+            notifyConfigChanged()
+        }
+    }
+
+    fun deleteModule(globalId: String) {
+        viewModelScope.launch {
+            gateway.delete(globalId)
+            _moduleContentStates.update { it - globalId }
+            loadJobs.remove(globalId)?.cancel()
+            _pendingEnabled.update { it - globalId }
+            _pendingUserModules.update { it.filter { m -> m.id != globalId } }
+            notifyConfigChanged()
+        }
+    }
+
+    fun reorderJoinedModules(orderedIds: List<String>) {
+        viewModelScope.launch {
+            orderedIds.forEachIndexed { index, id ->
+                gateway.setSortOrder(id, index)
+            }
+            notifyConfigChanged()
+        }
+    }
+
+    fun reorderCustomSets(orderedUrls: List<String>) {
+        viewModelScope.launch {
+            orderedUrls.forEachIndexed { index, url ->
+                val id = customSetIdFromUrl(url)
+                gateway.setCustomSetSortOrder(id, index)
+            }
+            notifyConfigChanged()
+        }
+    }
+
+    /** 获取指定集内的模块(sourceUrl 可以是书源 URL 或 custom://xxx) */
+    fun getJoinedModules(sourceUrl: String): List<HomepageModuleManageUi> {
+        val isSet = isCustomSetUrl(sourceUrl)
+        val setId = if (isSet) customSetIdFromUrl(sourceUrl) else null
+        val dbModules = if (isSet) {
+            allModulesCache.value.filter { it.customSetId == setId }
+        } else {
+            allModulesCache.value.filter { it.sourceUrl == sourceUrl }
+        }
+        val dbIds = dbModules.map { it.id }.toSet()
+        val pendingModules = _pendingUserModules.value.filter { pending ->
+            val matches =
+                if (isSet) pending.customSetId == setId else pending.sourceUrl == sourceUrl
+            matches && pending.id !in dbIds
+        }
+        return (dbModules + pendingModules).map { uiFromModule(it) }
+    }
+
+    /** 所有已添加的模块,按书源分组(用于自定义集添加模块) */
+    fun getAllModulesGroupedBySource(): Map<String, List<HomepageModuleManageUi>> {
+        return allModulesCache.value
+            .distinctBy { it.sourceUrl to it.moduleKey }
+            .map { uiFromModule(it) }
+            .groupBy { it.sourceUrl }
+    }
+
+    fun getSourceName(sourceUrl: String): String {
+        return _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl
+    }
+
+    fun assignModuleToCustomSet(moduleId: String, customSetId: String?) {
+        viewModelScope.launch {
+            val existing = gateway.getById(moduleId) ?: return@launch
+            if (customSetId == null) {
+                // 如果是取消分配,且它不是归属于书源默认集的,则直接删除该副本
+                if (existing.customSetId != "src_${existing.sourceUrl}") {
+                    gateway.delete(moduleId)
+                }
+            } else {
+                // 核心逻辑:分配 = 复制。生成带新 setId 的 ID
+                val newId =
+                    ModuleDef.globalIdOf(existing.sourceUrl, existing.moduleKey, customSetId)
+                val newModule = existing.copy(
+                    id = newId,
+                    customSetId = customSetId,
+                    isEnabled = true, // 分配到新集时默认开启
+                    isUserCreated = true // 标记为用户创建,避免被同步清理
+                )
+                gateway.upsertAll(listOf(newModule))
+            }
+            notifyConfigChanged()
+        }
+    }
+
+    /** 「书源模块」tab:仅 JSON,纯参考 */
+    fun getSourceModules(
+        sourceUrl: String,
+        targetSetId: String? = null
+    ): List<HomepageModuleManageUi> {
+        val source = resolveBookSource(sourceUrl) ?: return emptyList()
+        val json = source.homepageModules ?: return emptyList()
+        val jsonDefs = parseBookSourceModules(source, json)
+
+        val effectiveSetId = targetSetId ?: "src_$sourceUrl"
+        val joinedKeys = allModulesCache.value
+            .filter { it.sourceUrl == sourceUrl && it.customSetId == effectiveSetId }
+            .map { it.moduleKey }.toSet()
+
+        return jsonDefs.map { def ->
+            val id = ModuleDef.globalIdOf(sourceUrl, def.key, effectiveSetId)
+            HomepageModuleManageUi(
+                id = id,
+                sourceUrl = def.sourceUrl,
+                moduleKey = def.key,
+                title = def.title,
+                isVisible = joinedKeys.contains(def.key),
+                customSetId = if (joinedKeys.contains(def.key)) effectiveSetId else null,
+                originalTitle = def.title,
+                type = def.type,
+                url = def.url,
+                args = def.args,
+            )
+        }
+    }
+
+    /** 从书源模块「加入」→ 写入 DB,自动归属到该书源的集 */
+    /** 从发现页 Kind 创建一个 ButtonGroup 模块,args 存储选中 Kind 标题的 JSON 数组 */
+    fun addButtonGroupFromKinds(
+        sourceUrl: String,
+        targetSetId: String?,
+        title: String,
+        kindTitles: List<String>
+    ) {
+        val key = kindTitles.firstOrNull() ?: title
+        val setId = targetSetId ?: "src_$sourceUrl"
+        val id = ModuleDef.globalIdOf(sourceUrl, key, setId)
+        val module = ModuleItem(
+            id = id,
+            sourceUrl = sourceUrl,
+            moduleKey = key,
+            type = "buttonGroup",
+            title = title,
+            args = GSON.toJson(kindTitles),
+            isEnabled = true,
+            isUserCreated = true,
+            customSetId = setId,
+            syncedAt = System.currentTimeMillis(),
+        )
+        viewModelScope.launch {
+            val source = bookSourceRepository.getBookSource(sourceUrl)
+            if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName)
+            gateway.upsertAll(listOf(module))
+            _pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module }
+            notifyConfigChanged()
+        }
+    }
+
+    fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) {
+        val setId = targetSetId ?: "src_$sourceUrl"
+
+        if (isInfinite(def.type, def.layoutConfig)) {
+            val hasInfinite = allModulesCache.value.any {
+                it.customSetId == setId && isInfinite(
+                    it.type,
+                    it.layoutConfig
+                )
+            }
+            if (hasInfinite) {
+                viewModelScope.launch {
+                    _effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块"))
+                }
+                return
+            }
+        }
+
+        val id = ModuleDef.globalIdOf(sourceUrl, def.key, setId)
+        val module = ModuleItem(
+            id = id,
+            sourceUrl = sourceUrl,
+            moduleKey = def.key,
+            type = def.type,
+            title = def.title,
+            args = def.args,
+            layoutConfig = def.layoutConfig,
+            url = def.url,
+            isEnabled = true,
+            customSetId = setId,
+            syncedAt = System.currentTimeMillis(),
+        )
+        viewModelScope.launch {
+            val source = bookSourceRepository.getBookSource(sourceUrl)
+            if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName)
+            gateway.upsertAll(listOf(module))
+            _pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module }
+            notifyConfigChanged()
+        }
+    }
+
+    private fun uiFromModule(module: ModuleItem) = HomepageModuleManageUi(
+        id = module.id,
+        sourceUrl = module.sourceUrl,
+        moduleKey = module.moduleKey,
+        title = module.displayTitle,
+        customSetTitle = module.customSetTitle,
+        customSetId = module.customSetId,
+        isVisible = _pendingEnabled.value[module.id] ?: module.isEnabled,
+        type = module.type,
+        url = module.url,
+        args = module.args,
+        layoutConfig = module.layoutConfig,
+        originalTitle = module.title,
+    )
+
+    fun createCustomSet(name: String) {
+        viewModelScope.launch {
+            gateway.createCustomSet(name)
+            notifyConfigChanged()
+        }
+    }
+
+    fun renameCustomSet(id: String, name: String) {
+        viewModelScope.launch {
+            gateway.renameCustomSet(id, name)
+            notifyConfigChanged()
+        }
+    }
+
+    fun deleteCustomSet(id: String) {
+        viewModelScope.launch {
+            val moduleIds = allModulesCache.value.filter { it.customSetId == id }.map { it.id }
+            gateway.deleteCustomSet(id)
+            moduleIds.forEach { mid ->
+                _moduleContentStates.update { it - mid }
+                loadJobs.remove(mid)?.cancel()
+                _pendingEnabled.update { it - mid }
+            }
+            notifyConfigChanged()
+        }
+    }
+
+    fun onBookClick(book: SearchBook) {
+        viewModelScope.launch {
+            saveSearchBooksUseCase.save(book)
+            _effects.emit(HomepageEffect.NavigateToBookInfo(book.name, book.author, book.bookUrl))
+        }
+    }
+
+    fun onModuleHeaderClick(sourceUrl: String, exploreUrl: String?, title: String?) {
+        viewModelScope.launch {
+            _effects.emit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, exploreUrl))
+        }
+    }
+
+    private fun resolveBookSource(sourceUrl: String): BookSource? {
+        return _bookSourcesCache.value[sourceUrl]
+            ?: bookSourceRepository.getBookSourceSync(sourceUrl)
+    }
+
+    private suspend fun ensureModuleInDb(
+        sourceUrl: String,
+        moduleKey: String,
+        id: String,
+        setId: String
+    ) {
+        if (gateway.getById(id) != null) return
+        val source = resolveBookSource(sourceUrl) ?: return
+        val json = source.homepageModules ?: return
+        val defs = parseBookSourceModules(source, json)
+        val def = defs.find { it.key == moduleKey } ?: return
+        gateway.upsertAll(
+            listOf(
+                ModuleItem(
+                    id = id,
+                    sourceUrl = sourceUrl,
+                    moduleKey = moduleKey,
+                    type = def.type,
+                    title = def.title,
+                    args = def.args,
+                    url = def.url,
+                    isEnabled = true,
+                    customSetId = setId,
+                )
+            )
+        )
+    }
+
+    private fun notifyConfigChanged() {
+        _configVersion.update { it + 1 }
+    }
+
+    private fun parseBookSourceModules(source: BookSource, json: String): List<ModuleDef> =
+        parseModuleDefs(source, json)
+
+}
+
+private data class HomepageUiFlags(
+    val isRefreshing: Boolean,
+    val isManageMode: Boolean,
+    val isConfigMode: Boolean
+)

+ 57 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt

@@ -0,0 +1,57 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import kotlinx.collections.immutable.ImmutableList
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun BannerModule(
+    books: ImmutableList<SearchBook>,
+    onClick: (SearchBook) -> Unit,
+    modifier: Modifier = Modifier,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    if (books.isEmpty()) return
+
+    val lazyListState = rememberLazyListState()
+    LazyRow(
+        state = lazyListState,
+        modifier = modifier
+            .fillMaxWidth()
+            .fadingEdge(lazyListState, gradientWidth = 16.dp),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        items(books) { book ->
+            CoilBookCover(
+                name = book.name,
+                author = book.author,
+                path = book.coverUrl,
+                radius = 12.dp,
+                sourceOrigin = book.origin,
+                modifier = Modifier
+                    .width(96.dp)
+                    .clickable { onClick(book) },
+                sharedTransitionScope = sharedTransitionScope,
+                animatedVisibilityScope = animatedVisibilityScope,
+                sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+            )
+        }
+    }
+}

+ 171 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt

@@ -0,0 +1,171 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.rule.ExploreKind
+import io.legado.app.domain.usecase.ExploreKindUiUseCase
+import io.legado.app.help.source.getExploreInfoMap
+import io.legado.app.ui.main.homepage.HomepageViewModel
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.launch
+import org.koin.compose.koinInject
+
+@Composable
+fun ButtonGroupModule(
+    kinds: List<ExploreKind>,
+    sourceUrl: String,
+    globalId: String,
+    viewModel: HomepageViewModel,
+    modifier: Modifier = Modifier,
+    icon: String? = null,
+    layoutConfig: String? = null,
+) {
+    if (kinds.isEmpty()) return
+
+    val context = LocalContext.current
+    val activity = context as? AppCompatActivity
+    val useCase: ExploreKindUiUseCase = koinInject()
+    val scope = rememberCoroutineScope()
+    val infoMap = remember(sourceUrl) {
+        sourceUrl.takeIf { it.isNotBlank() }?.let { getExploreInfoMap(it) }
+    }
+
+    // 解析图标映射表和默认图标
+    val (iconMap, defaultIcon) = remember(layoutConfig) {
+        layoutConfig?.let {
+            try {
+                val json = GSON.fromJson(it, Map::class.java)
+
+                @Suppress("UNCHECKED_CAST")
+                val icons = json["icons"] as? Map<String, String>
+                val singleIcon = json["icon"] as? String
+                (icons ?: emptyMap()) to (singleIcon ?: icon)
+            } catch (_: Exception) {
+                emptyMap<String, String>() to icon
+            }
+        } ?: (emptyMap<String, String>() to icon)
+    }
+
+    // --- 动态布局计算逻辑 ---
+    val maxColumns = 5
+    val total = kinds.size
+    val numRows = (total + maxColumns - 1) / maxColumns
+    val actualColumns = (total + numRows - 1) / numRows
+    // -----------------------
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth(),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        kinds.chunked(actualColumns).forEach { rowKinds ->
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                rowKinds.forEach { kind ->
+                    var displayName by remember(kind.title) { mutableStateOf(kind.title) }
+
+                    LaunchedEffect(kind, sourceUrl, infoMap) {
+                        displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap)
+                    }
+
+                    val buttonIcon = iconMap[kind.title] ?: defaultIcon
+                    val hasIcon = !buttonIcon.isNullOrBlank()
+
+                    GlassCard(
+                        onClick = {
+                            when (kind.type) {
+                                ExploreKind.Type.url -> {
+                                    kind.url?.takeIf { it.isNotBlank() }?.let {
+                                        viewModel.onKindUrlClick(sourceUrl, it, kind.title)
+                                    }
+                                }
+
+                                ExploreKind.Type.button -> {
+                                    scope.launch {
+                                        useCase.executeAction(
+                                            action = kind.action,
+                                            title = kind.title,
+                                            sourceUrl = sourceUrl,
+                                            infoMap = infoMap,
+                                            activity = activity,
+                                            onRefreshKinds = {
+                                                viewModel.refreshButtonGroup(globalId)
+                                            }
+                                        )
+                                    }
+                                }
+                            }
+                        },
+                        cornerRadius = 8.dp,
+                        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+                        modifier = Modifier.weight(1f)
+                    ) {
+                        Column(
+                            horizontalAlignment = Alignment.CenterHorizontally,
+                            verticalArrangement = Arrangement.Center,
+                            modifier = Modifier
+                                .fillMaxSize()
+                                .padding(vertical = 12.dp, horizontal = 4.dp)
+                        ) {
+                            if (hasIcon) {
+                                SourceIcon(
+                                    path = buttonIcon,
+                                    modifier = Modifier.size(20.dp),
+                                    placeholderIcon = {
+
+                                    }
+                                )
+                                Spacer(modifier = Modifier.height(4.dp))
+                            }
+
+                            AppText(
+                                text = displayName,
+                                style = LegadoTheme.typography.labelMedium,
+                                textAlign = TextAlign.Center,
+                                maxLines = 1,
+                                overflow = TextOverflow.Clip,
+                                modifier = Modifier
+                                    .padding(horizontal = 4.dp)
+                                    .basicMarquee()
+                            )
+                        }
+                    }
+                }
+
+                if (rowKinds.size < actualColumns) {
+                    repeat(actualColumns - rowKinds.size) {
+                        Spacer(modifier = Modifier.weight(1f))
+                    }
+                }
+            }
+        }
+    }
+}

+ 101 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt

@@ -0,0 +1,101 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.collections.immutable.ImmutableList
+
+/**
+ * 卡片模块:横向滚动的推荐卡片
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun CardModule(
+    books: ImmutableList<SearchBook>,
+    onClick: (SearchBook) -> Unit,
+    modifier: Modifier = Modifier,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    if (books.isEmpty()) return
+    val lazyListState = rememberLazyListState()
+    LazyRow(
+        state = lazyListState,
+        modifier = modifier
+            .fillMaxWidth()
+            .fadingEdge(lazyListState, gradientWidth = 8.dp),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        items(books, key = { it.bookUrl }) { book ->
+            Column(
+                modifier = Modifier
+                    .width(120.dp)
+                    .clip(RoundedCornerShape(16.dp))
+                    .background(LegadoTheme.colorScheme.surfaceContainerLow)
+                    .clickable { onClick(book) }
+            ) {
+                CoilBookCover(
+                    name = book.name,
+                    author = book.author,
+                    path = book.coverUrl,
+                    radius = 16.dp,
+                    sourceOrigin = book.origin,
+                    modifier = Modifier
+                        .wrapContentWidth(),
+                    sharedTransitionScope = sharedTransitionScope,
+                    animatedVisibilityScope = animatedVisibilityScope,
+                    sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                )
+
+                AppText(
+                    text = book.name,
+                    style = LegadoTheme.typography.labelLargeEmphasized,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                    modifier = Modifier.padding(
+                        start = 8.dp,
+                        end = 8.dp,
+                        top = 8.dp,
+                        bottom = 2.dp
+                    ),
+                )
+
+                val intro = book.intro?.takeIf { it.isNotBlank() }
+                    ?.replace("\\s+".toRegex(), " ")
+                if (intro != null) {
+                    AppText(
+                        text = intro,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 12.dp),
+                    )
+                }
+            }
+        }
+    }
+}

+ 60 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt

@@ -0,0 +1,60 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookShelfState
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.widget.components.book.SearchBookGridItem
+import kotlinx.collections.immutable.ImmutableList
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun GridModule(
+    books: ImmutableList<SearchBook>,
+    onClick: (SearchBook) -> Unit,
+    modifier: Modifier = Modifier,
+    columns: Int = 3,
+    maxRows: Int? = null,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    if (books.isEmpty()) return
+    var rows = books.toList().chunked(columns)
+    if (maxRows != null) {
+        rows = rows.take(maxRows)
+    }
+    Column(
+        modifier = modifier.fillMaxWidth(),
+        verticalArrangement = Arrangement.spacedBy(4.dp),
+    ) {
+        for (row in rows) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                for (book in row) {
+                    SearchBookGridItem(
+                        book = book,
+                        shelfState = BookShelfState.NOT_IN_SHELF,
+                        onClick = { onClick(book) },
+                        modifier = Modifier.weight(1f),
+                        sharedTransitionScope = sharedTransitionScope,
+                        animatedVisibilityScope = animatedVisibilityScope,
+                        sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+                    )
+                }
+                repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
+            }
+        }
+    }
+}

+ 161 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt

@@ -0,0 +1,161 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.clickable
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.collections.immutable.ImmutableList
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun GridRankingModule(
+    books: ImmutableList<SearchBook>,
+    onClick: (SearchBook) -> Unit,
+    modifier: Modifier = Modifier,
+    rows: Int = 4,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    if (books.isEmpty()) return
+    // 限制最多显示 20 项
+    val limitedBooks = books.take(20)
+    val pages = limitedBooks.chunked(rows)
+    val pagerState = rememberPagerState(pageCount = { pages.size })
+
+    HorizontalPager(
+        state = pagerState,
+        // 由于父容器已经有 16.dp padding,这里 start 设为 0
+        contentPadding = PaddingValues(start = 0.dp, end = 100.dp),
+        pageSpacing = 12.dp,
+        modifier = modifier
+            .fillMaxWidth()
+            .fadingEdge(pagerState, gradientWidth = 16.dp),
+    ) { pageIndex ->
+        val page = pages[pageIndex]
+        GlassCard(
+            modifier = Modifier.fillMaxWidth(),
+            // 使用 MD3 标准容器色,增加微妙的深度感
+            containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+            cornerRadius = 20.dp
+        ) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = 12.dp, horizontal = 12.dp)
+            ) {
+                for (book in page) {
+                    GridRankingItem(
+                        rank = pages.flatten().indexOf(book) + 1,
+                        book = book,
+                        onClick = { onClick(book) },
+                        sharedTransitionScope = sharedTransitionScope,
+                        animatedVisibilityScope = animatedVisibilityScope,
+                    )
+                }
+                // 占位逻辑
+                repeat(rows - page.size) {
+                    Spacer(modifier = Modifier.height(76.dp))
+                }
+            }
+        }
+    }
+}
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+private fun GridRankingItem(
+    rank: Int,
+    book: SearchBook,
+    onClick: () -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(8.dp))
+            .clickable(onClick = onClick)
+            .padding(vertical = 4.dp, horizontal = 4.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        // 1. 封面
+        CoilBookCover(
+            name = book.name,
+            author = book.author,
+            path = book.coverUrl,
+            sourceOrigin = book.origin,
+            modifier = Modifier.width(48.dp),
+            sharedTransitionScope = sharedTransitionScope,
+            animatedVisibilityScope = animatedVisibilityScope,
+            sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+        )
+
+        // 2. 排名
+        AppText(
+            text = "$rank",
+            style = LegadoTheme.typography.titleMedium,
+            fontWeight = FontWeight.Black,
+            fontStyle = if (rank <= 3) FontStyle.Italic else FontStyle.Normal,
+            color = if (rank <= 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
+            modifier = Modifier.width(32.dp),
+            textAlign = TextAlign.Center
+        )
+
+        // 3. 文字信息
+        Column(
+            modifier = Modifier
+                .padding(start = 4.dp)
+                .weight(1f)
+        ) {
+            AppText(
+                text = book.name,
+                style = LegadoTheme.typography.bodyMedium,
+                fontWeight = FontWeight.Bold,
+                maxLines = 2,
+                overflow = TextOverflow.Ellipsis,
+            )
+            val subTitle = buildString {
+                append(book.kind?.split(",")?.firstOrNull() ?: "")
+                if (book.author.isNotBlank()) {
+                    if (isNotEmpty()) append(" · ")
+                    append(book.author)
+                }
+            }
+            AppText(
+                text = subTitle,
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+                modifier = Modifier.padding(top = 2.dp)
+            )
+        }
+    }
+}

+ 146 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt

@@ -0,0 +1,146 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookShelfState
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.book.SearchBookListItem
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.collections.immutable.ImmutableList
+
+private const val INITIAL_COUNT = 5
+private const val MAX_COUNT = 20
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun RankingModule(
+    books: ImmutableList<SearchBook>,
+    onClick: (SearchBook) -> Unit,
+    modifier: Modifier = Modifier,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) }
+    val displayBooks = books.take(visibleCount)
+
+    GlassCard(
+        modifier = modifier
+            .fillMaxWidth(),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        cornerRadius = 16.dp
+    ) {
+        Column(
+            modifier = Modifier
+                .padding(top = 12.dp)
+                .animateContentSize()
+        ) {
+            // 显示书籍列表
+            displayBooks.forEachIndexed { index, book ->
+                RankingItem(
+                    rank = index + 1,
+                    book = book,
+                    onClick = onClick,
+                    sharedTransitionScope = sharedTransitionScope,
+                    animatedVisibilityScope = animatedVisibilityScope,
+                )
+            }
+
+            if (books.size > INITIAL_COUNT) {
+                Box(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .clickable {
+                            visibleCount =
+                                if (visibleCount == INITIAL_COUNT) MAX_COUNT else INITIAL_COUNT
+                        }
+                        .padding(vertical = 12.dp),
+                    contentAlignment = Alignment.Center
+                ) {
+                    Row(verticalAlignment = Alignment.CenterVertically) {
+                        val isExpanded = visibleCount > INITIAL_COUNT
+                        AppIcon(
+                            imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.ExpandMore,
+                            contentDescription = null,
+                            tint = if (isExpanded) LegadoTheme.colorScheme.outline else LegadoTheme.colorScheme.primary,
+                            modifier = Modifier.size(20.dp)
+                        )
+                        AppText(
+                            text = if (isExpanded) "收起" else "显示全部",
+                            style = LegadoTheme.typography.labelMediumEmphasized,
+                            color = if (isExpanded) LegadoTheme.colorScheme.outline else LegadoTheme.colorScheme.primary,
+                            modifier = Modifier.padding(start = 4.dp)
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+private fun RankingItem(
+    rank: Int,
+    book: SearchBook,
+    onClick: (SearchBook) -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clickable { onClick(book) }
+            .padding(vertical = 4.dp, horizontal = 4.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        AppText(
+            text = "$rank",
+            style = LegadoTheme.typography.titleLarge,
+            fontWeight = FontWeight.Black,
+            fontStyle = if (rank <= 3) FontStyle.Italic else FontStyle.Normal,
+            color = if (rank <= 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
+            textAlign = TextAlign.Center,
+            modifier = Modifier
+                .width(42.dp)
+                .padding(start = 2.dp, end = 10.dp),
+        )
+        SearchBookListItem(
+            book = book,
+            shelfState = BookShelfState.NOT_IN_SHELF,
+            onClick = null,
+            showPadding = false,
+            modifier = Modifier.weight(1f),
+            sharedTransitionScope = sharedTransitionScope,
+            animatedVisibilityScope = animatedVisibilityScope,
+            sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+        )
+    }
+}

+ 126 - 0
app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt

@@ -0,0 +1,126 @@
+package io.legado.app.ui.main.homepage.modules
+
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.book.SearchBookTagChip
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.text.AppText
+
+/**
+ * 瀑布流单项组件
+ * 建议直接在 LazyVerticalStaggeredGrid 的 items 中使用,以获得最佳回收性能
+ */
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun WaterfallItem(
+    book: SearchBook,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    GlassCard(
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+    ) {
+        Column(
+            modifier = modifier
+                .fillMaxWidth()
+                .clickable(onClick = onClick)
+        ) {
+            CoilBookCover(
+                name = book.name,
+                author = book.author,
+                path = book.coverUrl,
+                radius = 16.dp,
+                sourceOrigin = book.origin,
+                modifier = Modifier
+                    .fillMaxWidth(),
+                sharedTransitionScope = sharedTransitionScope,
+                animatedVisibilityScope = animatedVisibilityScope,
+                sharedCoverKey = bookCoverSharedElementKey(book.bookUrl)
+            )
+
+            Spacer(modifier = Modifier.height(8.dp))
+
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 8.dp)
+                    .padding(bottom = 8.dp)
+            ) {
+                AppText(
+                    text = book.name,
+                    style = LegadoTheme.typography.bodyMedium,
+                    fontWeight = FontWeight.Bold,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+
+                val subTitle = buildString {
+                    if (book.author.isNotBlank()) append(book.author)
+                    val kind = book.kind?.split(",")?.firstOrNull()
+                    if (!kind.isNullOrBlank()) {
+                        if (isNotEmpty()) append(" · ")
+                        append(kind)
+                    }
+                }
+                if (subTitle.isNotBlank()) {
+                    AppText(
+                        text = subTitle,
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.onSurface,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier.padding(top = 2.dp)
+                    )
+                }
+
+                val intro = book.intro?.replace("\\s+".toRegex(), " ")
+                if (!intro.isNullOrBlank()) {
+                    AppText(
+                        text = intro,
+                        style = LegadoTheme.typography.labelSmallEmphasized,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier.padding(top = 4.dp)
+                    )
+                }
+
+                val kinds = book.getKindList()
+                if (kinds.isNotEmpty()) {
+                    Spacer(modifier = Modifier.height(4.dp))
+                    FlowRow(
+                        horizontalArrangement = Arrangement.spacedBy(4.dp),
+                        verticalArrangement = Arrangement.spacedBy(4.dp)
+                    ) {
+                        kinds.forEach { kind ->
+                            SearchBookTagChip(
+                                text = kind,
+                                color = LegadoTheme.colorScheme.surfaceContainerHigh
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 18 - 0
app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt

@@ -64,6 +64,24 @@ fun adaptiveContentPadding(
     )
 }
 
+@Composable
+fun adaptiveContentPadding(
+    top: Dp,
+    bottom: Dp,
+    miuixHorizontal: Dp,
+    m3Horizontal: Dp
+): PaddingValues {
+    val horizontal =
+        if (ThemeResolver.isMiuixEngine(composeEngine)) miuixHorizontal else m3Horizontal
+    val adjustedTop = if (ThemeResolver.isMiuixEngine(composeEngine)) top + 12.dp else top + 16.dp
+    return PaddingValues(
+        top = adjustedTop,
+        bottom = bottom,
+        start = horizontal,
+        end = horizontal
+    )
+}
+
 @Composable
 fun adaptiveContentPadding(
     top: Dp,

+ 48 - 15
app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt

@@ -2,11 +2,11 @@ package io.legado.app.ui.theme
 
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ScrollState
 import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.pager.PagerState
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.drawWithContent
 import androidx.compose.ui.graphics.BlendMode
@@ -52,29 +52,62 @@ fun Modifier.fadingEdge(
 
 /**
  * Convenience overload that derives fade alphas from a [LazyListState].
- * Left fade appears when scrolled past the start; right fade appears when more content is available.
  */
 @Composable
 fun Modifier.fadingEdge(
     listState: LazyListState,
     gradientWidth: Dp = 24.dp
 ): Modifier {
-    val showLeft by remember {
-        derivedStateOf {
-            listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
-        }
-    }
-    val showRight by remember {
-        derivedStateOf { listState.canScrollForward }
-    }
     val leftAlpha by animateFloatAsState(
-        targetValue = if (showLeft) 1f else 0f,
-        animationSpec = tween(200),
+        targetValue = if (listState.canScrollBackward) 1f else 0f,
+        animationSpec = tween(300),
+        label = "LeftFadeAlpha"
+    )
+    val rightAlpha by animateFloatAsState(
+        targetValue = if (listState.canScrollForward) 1f else 0f,
+        animationSpec = tween(300),
+        label = "RightFadeAlpha"
+    )
+    return fadingEdge(leftAlpha, rightAlpha, gradientWidth)
+}
+
+/**
+ * Convenience overload that derives fade alphas from a [PagerState].
+ */
+@Composable
+fun Modifier.fadingEdge(
+    pagerState: PagerState,
+    gradientWidth: Dp = 24.dp
+): Modifier {
+    val leftAlpha by animateFloatAsState(
+        targetValue = if (pagerState.canScrollBackward) 1f else 0f,
+        animationSpec = tween(300),
+        label = "LeftFadeAlpha"
+    )
+    val rightAlpha by animateFloatAsState(
+        targetValue = if (pagerState.canScrollForward) 1f else 0f,
+        animationSpec = tween(300),
+        label = "RightFadeAlpha"
+    )
+    return fadingEdge(leftAlpha, rightAlpha, gradientWidth)
+}
+
+/**
+ * Convenience overload that derives fade alphas from a [ScrollState].
+ */
+@Composable
+fun Modifier.fadingEdge(
+    scrollState: ScrollState,
+    gradientWidth: Dp = 24.dp
+): Modifier {
+    val leftAlpha by animateFloatAsState(
+        targetValue = if (scrollState.canScrollBackward) 1f else 0f,
+        animationSpec = tween(300),
         label = "LeftFadeAlpha"
     )
     val rightAlpha by animateFloatAsState(
-        targetValue = if (showRight) 1f else 0f,
-        animationSpec = tween(200),
+        targetValue = if (scrollState.canScrollForward) 1f else 0f,
+        animationSpec = tween(300),
         label = "RightFadeAlpha"
     )
     return fadingEdge(leftAlpha, rightAlpha, gradientWidth)

+ 2 - 1
app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt

@@ -74,7 +74,8 @@ data class LegadoColorScheme(
 
     val cardContainer: Color,
     val onCardContainer: Color,
-    val onSheetContent: Color
+    val onSheetContent: Color,
+    val cardPrimaryContainer: Color
 )
 
 data class LegadoTypography(

+ 2 - 1
app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt

@@ -74,7 +74,8 @@ fun ColorScheme.toLegadoColorScheme(
         onTertiaryFixedVariant = onTertiaryFixedVariant,
         cardContainer = primaryContainer.copy(alpha = 0.5f),
         onCardContainer = primary,
-        onSheetContent = surface
+        onSheetContent = surface,
+        cardPrimaryContainer = primaryContainer
     )
 }
 

+ 4 - 2
app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt

@@ -12,12 +12,12 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.text.font.FontFamily
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.theme.ThemeController
-import top.yukonga.miuix.kmp.theme.TextStyles
 
 @Composable
 fun rememberCustomFont(fontPath: String?): FontFamily? {
@@ -180,7 +180,9 @@ fun MiuixThemeWrapper(
 
                 cardContainer = miuixColorScheme.surfaceContainer,
                 onCardContainer = miuixColorScheme.onSurface,
-                onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f)
+                onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f),
+                cardPrimaryContainer = miuixColorScheme.primary.copy(alpha = 0.1f)
+                    .compositeOver(miuixColorScheme.surface)
             )
         }
 

+ 138 - 0
app/src/main/java/io/legado/app/ui/widget/components/JsonConfigEditor.kt

@@ -0,0 +1,138 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
+import io.legado.app.utils.GSON
+
+sealed class JsonKeyEditorConfig {
+    data class Slider(val range: ClosedFloatingPointRange<Float>, val steps: Int = 0) :
+        JsonKeyEditorConfig()
+
+    data class Dropdown(val displayEntries: Array<String>, val entryValues: Array<String>) :
+        JsonKeyEditorConfig() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is Dropdown) return false
+            return displayEntries.contentEquals(other.displayEntries) && entryValues.contentEquals(
+                other.entryValues
+            )
+        }
+
+        override fun hashCode(): Int {
+            return 31 * displayEntries.contentHashCode() + entryValues.contentHashCode()
+        }
+    }
+
+    object Switch : JsonKeyEditorConfig()
+}
+
+@Composable
+fun JsonConfigEditor(
+    jsonString: String,
+    onJsonStringChange: (String) -> Unit,
+    modifier: Modifier = Modifier,
+    keyConfigs: Map<String, JsonKeyEditorConfig> = emptyMap()
+) {
+    val jsonObject = remember(jsonString) {
+        runCatching {
+            JsonParser.parseString(jsonString).asJsonObject
+        }.getOrElse { JsonObject() }
+    }
+
+    Column(modifier = modifier) {
+        jsonObject.entrySet().forEach { entry ->
+            val key = entry.key
+            val value = entry.value
+            val config = keyConfigs[key]
+
+            val displayTitle = when (key) {
+                "columns" -> "列数 (Columns)"
+                "rows" -> "行数 (Rows)"
+                else -> key
+            }
+
+            when {
+                (config is JsonKeyEditorConfig.Slider || key.contains("columns") || key.contains("rows")) &&
+                        value.isJsonPrimitive && value.asJsonPrimitive.isNumber -> {
+                    val range = (config as? JsonKeyEditorConfig.Slider)?.range ?: 0f..10f
+                    val steps = (config as? JsonKeyEditorConfig.Slider)?.steps ?: 10
+                    CompactSliderSettingItem(
+                        title = displayTitle,
+                        value = value.asFloat,
+                        valueRange = range,
+                        steps = steps,
+                        onValueChange = {
+                            val newObj = jsonObject.deepCopy()
+                            if (it == it.toInt().toFloat()) {
+                                newObj.addProperty(key, it.toInt())
+                            } else {
+                                newObj.addProperty(key, it)
+                            }
+                            onJsonStringChange(GSON.toJson(newObj))
+                        }
+                    )
+                }
+
+                config is JsonKeyEditorConfig.Dropdown -> {
+                    CompactDropdownSettingItem(
+                        title = displayTitle,
+                        selectedValue = value.asString,
+                        displayEntries = config.displayEntries,
+                        entryValues = config.entryValues,
+                        onValueChange = {
+                            val newObj = jsonObject.deepCopy()
+                            newObj.addProperty(key, it)
+                            onJsonStringChange(GSON.toJson(newObj))
+                        }
+                    )
+                }
+
+                (config is JsonKeyEditorConfig.Switch || (value.isJsonPrimitive && value.asJsonPrimitive.isBoolean)) -> {
+                    CompactSwitchSettingItem(
+                        title = displayTitle,
+                        checked = value.asBoolean,
+                        onCheckedChange = {
+                            val newObj = jsonObject.deepCopy()
+                            newObj.addProperty(key, it)
+                            onJsonStringChange(GSON.toJson(newObj))
+                        }
+                    )
+                }
+
+                else -> {
+                    JsonRawEditor(
+                        value = if (value.isJsonPrimitive) value.asString else GSON.toJson(value),
+                        onValueChange = {
+                            val newObj = jsonObject.deepCopy()
+                            if (it.toLongOrNull() != null) {
+                                newObj.addProperty(key, it.toLong())
+                            } else if (it.toDoubleOrNull() != null) {
+                                newObj.addProperty(key, it.toDouble())
+                            } else if (it == "true" || it == "false") {
+                                newObj.addProperty(key, it.toBoolean())
+                            } else {
+                                try {
+                                    val element = JsonParser.parseString(it)
+                                    newObj.add(key, element)
+                                } catch (_: Exception) {
+                                    newObj.addProperty(key, it)
+                                }
+                            }
+                            onJsonStringChange(GSON.toJson(newObj))
+                        },
+                        label = displayTitle,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                }
+            }
+        }
+    }
+}

+ 88 - 0
app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt

@@ -0,0 +1,88 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoFixHigh
+import androidx.compose.material.icons.filled.Compress
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonParser
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.GSON
+
+@Composable
+fun JsonRawEditor(
+    value: String,
+    onValueChange: (String) -> Unit,
+    label: String,
+    modifier: Modifier = Modifier,
+) {
+    Column(modifier = modifier) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.SpaceBetween,
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppText(
+                text = label,
+                style = MaterialTheme.typography.labelMedium,
+                color = MaterialTheme.colorScheme.primary
+            )
+            Row {
+                SmallIconButton(
+                    onClick = {
+                        runCatching {
+                            val jsonElement = JsonParser.parseString(value)
+                            onValueChange(GSON.toJson(jsonElement))
+                        }
+                    },
+                    imageVector = Icons.Default.AutoFixHigh,
+                    contentDescription = "格式化"
+                )
+                SmallIconButton(
+                    onClick = {
+                        runCatching {
+                            val jsonElement = JsonParser.parseString(value)
+                            val compactGson = GsonBuilder().create()
+                            onValueChange(compactGson.toJson(jsonElement))
+                        }
+                    },
+                    imageVector = Icons.Default.Compress,
+                    contentDescription = "压缩"
+                )
+            }
+        }
+
+        TextField(
+            value = value,
+            onValueChange = onValueChange,
+            modifier = Modifier
+                .fillMaxWidth()
+                .heightIn(min = 150.dp, max = 400.dp),
+            textStyle = TextStyle(
+                fontFamily = FontFamily.Monospace,
+                fontSize = MaterialTheme.typography.bodySmall.fontSize
+            ),
+            colors = TextFieldDefaults.colors(
+                focusedContainerColor = Color.Transparent,
+                unfocusedContainerColor = Color.Transparent,
+                disabledContainerColor = Color.Transparent,
+            ),
+            maxLines = 1000
+        )
+    }
+}

+ 44 - 25
app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt

@@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.IntrinsicSize
@@ -16,15 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.Shuffle
-import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
@@ -32,6 +33,9 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.text.AppText
@@ -41,8 +45,9 @@ import io.legado.app.ui.widget.components.text.AppText
 fun SearchBookListItem(
     book: SearchBook,
     shelfState: BookShelfState,
-    onClick: () -> Unit,
+    onClick: (() -> Unit)?,
     modifier: Modifier = Modifier,
+    showPadding: Boolean = true,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
@@ -50,8 +55,8 @@ fun SearchBookListItem(
     Row(
         modifier = modifier
             .fillMaxWidth()
-            .clickable(onClick = onClick)
-            .padding(horizontal = 16.dp, vertical = 8.dp)
+            .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+            .then(if (showPadding) Modifier.adaptiveHorizontalPadding(vertical = 8.dp) else Modifier)
     ) {
         Box(modifier = Modifier
             .width(72.dp)
@@ -113,13 +118,14 @@ fun SearchBookListItem(
                     AppText(
                         text = " • ",
                         style = LegadoTheme.typography.bodySmall,
-                        color = Color.Gray,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
                         maxLines = 1,
                     )
 
                     AppText(
                         text = "最新: $latestChapter",
                         style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis,
                     )
@@ -133,7 +139,7 @@ fun SearchBookListItem(
                 AppText(
                     text = intro,
                     style = LegadoTheme.typography.labelSmall,
-                    color = Color.Gray,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
                     maxLines = 2,
                     minLines = 2,
                     overflow = TextOverflow.Ellipsis,
@@ -143,8 +149,14 @@ fun SearchBookListItem(
             val kinds = book.getKindList()
             if (kinds.isNotEmpty()) {
                 Spacer(modifier = Modifier.height(4.dp))
-                Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
-                    kinds.forEach { kind ->
+                val lazyListState = rememberLazyListState()
+                LazyRow(
+                    state = lazyListState,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .fadingEdge(lazyListState, gradientWidth = 8.dp)
+                ) {
+                    items(kinds) { kind ->
                         SearchBookTagChip(text = kind)
                         Spacer(modifier = Modifier.width(6.dp))
                     }
@@ -168,8 +180,8 @@ fun SearchBookGridItem(
     Column(
         modifier = modifier
             .width(IntrinsicSize.Min)
+            .clip(RoundedCornerShape(4.dp))
             .clickable(onClick = onClick)
-            .padding(4.dp)
     ) {
         Box(
             modifier = Modifier
@@ -207,28 +219,35 @@ fun SearchBookGridItem(
             }
         }
 
-        Spacer(modifier = Modifier.height(4.dp))
-
-        AppText(
-            text = book.name,
-            style = LegadoTheme.typography.bodySmall,
-            fontWeight = FontWeight.Bold,
-            maxLines = 2,
-            overflow = TextOverflow.Ellipsis,
-        )
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 4.dp, vertical = 4.dp)
+        ) {
+            AppText(
+                text = book.name,
+                style = LegadoTheme.typography.bodySmall,
+                fontWeight = FontWeight.Bold,
+                maxLines = 2,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
     }
 }
 
 @Composable
-private fun SearchBookTagChip(text: String) {
-    Surface(
-        color = LegadoTheme.colorScheme.cardContainer,
-        shape = RoundedCornerShape(4.dp),
+fun SearchBookTagChip(
+    text: String,
+    color: Color = LegadoTheme.colorScheme.surfaceContainerHigh
+) {
+    GlassCard(
+        containerColor = color,
+        cornerRadius = 4.dp
     ) {
         AppText(
             text = text,
             modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
-            style = LegadoTheme.typography.labelSmall,
+            style = LegadoTheme.typography.labelSmallEmphasized,
             color = LegadoTheme.colorScheme.onCardContainer,
         )
     }

+ 15 - 1
app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt

@@ -4,12 +4,14 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.LibraryBooks
 import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
+import androidx.compose.material.icons.filled.BugReport
 import androidx.compose.material.icons.filled.Clear
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.Explore
 import androidx.compose.material.icons.filled.FilterList
 import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.Home
 import androidx.compose.material.icons.filled.LocationSearching
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.MyLocation
@@ -19,6 +21,7 @@ import androidx.compose.material.icons.filled.RssFeed
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.outlined.Explore
+import androidx.compose.material.icons.outlined.Home
 import androidx.compose.material.icons.outlined.Person
 import androidx.compose.material.icons.outlined.RssFeed
 import androidx.compose.runtime.Composable
@@ -37,6 +40,7 @@ import top.yukonga.miuix.kmp.icon.extended.Edit
 import top.yukonga.miuix.kmp.icon.extended.Favorites
 import top.yukonga.miuix.kmp.icon.extended.Filter
 import top.yukonga.miuix.kmp.icon.extended.More
+import top.yukonga.miuix.kmp.icon.extended.Notes
 import top.yukonga.miuix.kmp.icon.extended.Pin
 import top.yukonga.miuix.kmp.icon.extended.Refresh
 import top.yukonga.miuix.kmp.icon.extended.Settings
@@ -81,6 +85,10 @@ object AppIcons {
         @Composable
         get() = if (isMiuix) MiuixIcons.Settings else Icons.Default.Settings
 
+    val BugReport: ImageVector
+        @Composable
+        get() = Icons.Default.BugReport
+
     val PrecisionSearch: ImageVector
         @Composable
         get() = if (isMiuix) MiuixIcons.Pin else Icons.Default.MyLocation
@@ -100,8 +108,14 @@ object AppIcons {
     @Composable
     fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
         return when (destination) {
-            MainDestination.Bookshelf -> if (isMiuix) {
+            MainDestination.Home -> if (isMiuix) {
                 if (selected) MiuixIcons.Regular.ContactsBook else MiuixIcons.Regular.ContactsBook
+            } else {
+                if (selected) Icons.Default.Home else Icons.Outlined.Home
+            }
+
+            MainDestination.Bookshelf -> if (isMiuix) {
+                if (selected) MiuixIcons.Regular.Notes else MiuixIcons.Regular.Notes
             } else {
                 if (selected) Icons.AutoMirrored.Filled.LibraryBooks else Icons.AutoMirrored.Outlined.LibraryBooks
             }

+ 51 - 44
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
@@ -37,6 +38,7 @@ import androidx.compose.ui.graphics.nativeCanvas
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.core.graphics.withSave
 import coil.compose.AsyncImage
@@ -51,6 +53,7 @@ fun CoilBookCover(
     name: String?,
     author: String?,
     path: String?,
+    radius: Dp = 4.dp,
     modifier: Modifier = Modifier.width(64.dp),
     sourceOrigin: String? = null,
     onLoadFinish: (() -> Unit)? = null,
@@ -75,7 +78,7 @@ fun CoilBookCover(
 
     val hasCustomDefault = !randomPath.isNullOrBlank()
     var isOnlineCoverLoaded by remember(path) {
-        mutableStateOf(sharedCoverKey != null && finalPath != null)
+        mutableStateOf(false)
     }
 
     Box(
@@ -94,61 +97,65 @@ fun CoilBookCover(
             )
             .then(
                 if (CoverConfig.coverShowShadow) {
-                    Modifier.shadow(4.dp, RoundedCornerShape(4.dp))
+                    Modifier.shadow(4.dp, RoundedCornerShape(radius))
                 } else Modifier
             )
             .background(
                 if (!hasCustomDefault && !isOnlineCoverLoaded) {
                     LegadoTheme.colorScheme.surfaceContainerLow
                 } else Color.Transparent,
-                RoundedCornerShape(4.dp)
+                RoundedCornerShape(radius)
             )
-            .clip(RoundedCornerShape(4.dp))
+            .clip(RoundedCornerShape(radius))
     ) {
         if (hasCustomDefault && !isOnlineCoverLoaded) {
-            AsyncImage(
-                model = buildCoverImageRequest(
-                    context = context,
-                    data = randomPath,
-                    sourceOrigin = null,
-                    loadOnlyWifi = false,
-                    crossfade = showLoadingPlaceholder,
-                    memoryCacheKey = randomPath,
-                ),
-                contentDescription = null,
-                imageLoader = koinInject(),
-                contentScale = ContentScale.Crop,
-                modifier = Modifier
-                    .fillMaxSize()
-                    .clip(RoundedCornerShape(4.dp))
-            )
+            key(randomPath) {
+                AsyncImage(
+                    model = buildCoverImageRequest(
+                        context = context,
+                        data = randomPath,
+                        sourceOrigin = null,
+                        loadOnlyWifi = false,
+                        crossfade = showLoadingPlaceholder,
+                        memoryCacheKey = randomPath,
+                    ),
+                    contentDescription = null,
+                    imageLoader = koinInject(),
+                    contentScale = ContentScale.Crop,
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .clip(RoundedCornerShape(radius))
+                )
+            }
         }
 
         if (finalPath != null) {
-            AsyncImage(
-                model = buildCoverImageRequest(
-                    context = context,
-                    data = finalPath,
-                    sourceOrigin = sourceOrigin,
-                    loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
-                    crossfade = showLoadingPlaceholder,
-                    memoryCacheKey = finalPath,
-                ),
-                contentDescription = null,
-                imageLoader = koinInject(),
-                contentScale = ContentScale.Crop,
-                modifier = Modifier
-                    .fillMaxSize()
-                    .clip(RoundedCornerShape(4.dp)),
-                onSuccess = {
-                    isOnlineCoverLoaded = true
-                    onLoadFinish?.invoke()
-                },
-                onError = {
-                    isOnlineCoverLoaded = false
-                    onLoadFinish?.invoke()
-                }
-            )
+            key(finalPath) {
+                AsyncImage(
+                    model = buildCoverImageRequest(
+                        context = context,
+                        data = finalPath,
+                        sourceOrigin = sourceOrigin,
+                        loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
+                        crossfade = showLoadingPlaceholder,
+                        memoryCacheKey = finalPath,
+                    ),
+                    contentDescription = null,
+                    imageLoader = koinInject(),
+                    contentScale = ContentScale.Crop,
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .clip(RoundedCornerShape(4.dp)),
+                    onSuccess = {
+                        isOnlineCoverLoaded = true
+                        onLoadFinish?.invoke()
+                    },
+                    onError = {
+                        isOnlineCoverLoaded = false
+                        onLoadFinish?.invoke()
+                    }
+                )
+            }
         } else {
             LaunchedEffect(Unit) {
                 onLoadFinish?.invoke()

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt

@@ -81,7 +81,6 @@ fun AppModalBottomSheet(
             backgroundColor = sheetContainerColor,
             dragHandleColor = sheetDragHandleColor,
             onDismissRequest = onDismissRequest,
-            onDismissFinished = onDismissRequest,
             enableWindowDim = true,
             allowDismiss = true
         ) {
@@ -90,6 +89,7 @@ fun AppModalBottomSheet(
                     Column(
                         modifier = Modifier
                             .fillMaxWidth()
+                            .padding(bottom = 24.dp)
                             .animateContentSize(),
                         content = content
                     )
@@ -120,7 +120,7 @@ fun AppModalBottomSheet(
                     Column(
                         modifier = Modifier
                             .fillMaxWidth()
-                            .padding(horizontal = 16.dp)
+                            .padding(start = 16.dp, end = 16.dp, bottom = 24.dp)
                             .heightIn(max = maxHeight)
                             .animateContentSize()
                             .then(modifier)

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt

@@ -12,7 +12,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.SplicedColumnDivider
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
-import top.yukonga.miuix.kmp.basic.SpinnerEntry
+import top.yukonga.miuix.kmp.basic.DropdownItem
 import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
 
 @Composable
@@ -31,7 +31,7 @@ fun DropdownListSettingItem(
     if (ThemeResolver.isMiuixEngine(composeEngine)) {
         val selectedIndex = entryValues.indexOf(selectedValue).coerceAtLeast(0)
         val spinnerItems = displayEntries.map { display ->
-            SpinnerEntry(title = display)
+            DropdownItem(title = display)
         }
 
         OverlaySpinnerPreference(

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

@@ -64,6 +64,8 @@
     <string name="enable">启用</string>
     <string name="replace_purify_search">搜索</string>
     <string name="bookshelf">书架</string>
+    <string name="home">首页</string>
+    <string name="homepage_modules">首页模块 (JSON)</string>
     <string name="favorites">收藏夹</string>
     <string name="favorite">收藏</string>
     <string name="in_favorites">已收藏</string>
@@ -608,6 +610,7 @@
     <string name="source_tab_base">基本</string>
     <string name="source_tab_search">搜索</string>
     <string name="source_tab_find">发现</string>
+    <string name="source_tab_homepage">主页</string>
     <string name="source_tab_info">详情</string>
     <string name="source_tab_toc">目录</string>
     <string name="source_tab_content">正文</string>

+ 3 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -37,6 +37,8 @@
     <string name="replace_purify_search">替換淨化-搜尋</string>
     <string name="custom_export_section">自定義Epub導出章節</string>
     <string name="bookshelf">書架</string>
+    <string name="home">首頁</string>
+    <string name="homepage_modules">首頁模塊 (JSON)</string>
     <string name="favorites">收藏夾</string>
     <string name="favorite">收藏</string>
     <string name="in_favorites">已收藏</string>
@@ -546,6 +548,7 @@
     <string name="source_tab_base">基本</string>
     <string name="source_tab_search">搜索</string>
     <string name="source_tab_find">發現</string>
+    <string name="source_tab_homepage">主頁</string>
     <string name="source_tab_info">詳情</string>
     <string name="source_tab_toc">目錄</string>
     <string name="source_tab_content">正文</string>

+ 3 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -39,6 +39,8 @@
     <string name="enable">啟用</string>
     <string name="replace_purify_search">取代淨化-搜尋</string>
     <string name="bookshelf">書架</string>
+    <string name="home">首頁</string>
+    <string name="homepage_modules">首頁模組 (JSON)</string>
     <string name="favorites">收藏夾</string>
     <string name="favorite">收藏</string>
     <string name="in_favorites">已收藏</string>
@@ -555,6 +557,7 @@
     <string name="source_tab_base">基本</string>
     <string name="source_tab_search">搜尋</string>
     <string name="source_tab_find">發現</string>
+    <string name="source_tab_homepage">主頁</string>
     <string name="source_tab_info">詳情</string>
     <string name="source_tab_toc">目錄</string>
     <string name="source_tab_content">正文</string>

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

@@ -65,6 +65,8 @@
     <string name="enable">Enable</string>
     <string name="replace_purify_search">Search Replacement</string>
     <string name="bookshelf">Bookshelf</string>
+    <string name="home">Home</string>
+    <string name="homepage_modules">Homepage Modules (JSON)</string>
     <string name="favorites">Favorites</string>
     <string name="favorite">Favorite</string>
     <string name="in_favorites">in Favorites</string>
@@ -629,6 +631,7 @@
     <string name="source_tab_base">Basic</string>
     <string name="source_tab_search">Search</string>
     <string name="source_tab_find">Discovery</string>
+    <string name="source_tab_homepage">Homepage</string>
     <string name="source_tab_info">Information</string>
     <string name="source_tab_toc">Chapters</string>
     <string name="source_tab_content">Content</string>

+ 2 - 2
app/version.properties

@@ -1,5 +1,5 @@
 VERSION_MAJOR=3
 VERSION_MINOR=26
-VERSION_PATCH=12
-VERSION_SUFFIX=0
+VERSION_PATCH=13
+VERSION_SUFFIX=1
 # 1 = Pre, 0 = Release