Преглед изворни кода

refactor(modules/web): Mirgrat to typescript; fix bugs

Xwite пре 2 година
родитељ
комит
7984c45cb9
81 измењених фајлова са 6407 додато и 3177 уклоњено
  1. 6 0
      modules/web/.editorconfig
  2. 0 96
      modules/web/.eslintrc-auto-import.json
  3. 0 20
      modules/web/.eslintrc.cjs
  4. 18 16
      modules/web/.gitignore
  5. 7 0
      modules/web/.prettierrc.json
  6. 1 1
      modules/web/README.md
  7. 3 0
      modules/web/env.d.ts
  8. 19 0
      modules/web/eslint.config.mjs
  9. 12 12
      modules/web/index.html
  10. 0 20
      modules/web/jsconfig.json
  11. 20 10
      modules/web/package.json
  12. 2844 0
      modules/web/pnpm-lock.yaml
  13. 219 0
      modules/web/src/api/api.ts
  14. 5 5
      modules/web/src/api/axios.ts
  15. 0 266
      modules/web/src/api/index.js
  16. 96 0
      modules/web/src/api/index.ts
  17. 10 10
      modules/web/src/assets/bookshelf.css
  18. 9 7
      modules/web/src/assets/code.css
  19. 2 2
      modules/web/src/assets/fonts/iconfont.css
  20. 2 2
      modules/web/src/assets/fonts/popfont.css
  21. 2 2
      modules/web/src/assets/fonts/shelffont.css
  22. 12 9
      modules/web/src/assets/kbd.css
  23. 13 13
      modules/web/src/assets/sourceeditor.css
  24. 95 79
      modules/web/src/auto-imports.d.ts
  25. 109 0
      modules/web/src/book.d.ts
  26. 30 21
      modules/web/src/components/BookItems.vue
  27. 17 13
      modules/web/src/components/CatalogItem.vue
  28. 80 73
      modules/web/src/components/ChapterContent.vue
  29. 49 45
      modules/web/src/components/PopCatalog.vue
  30. 117 131
      modules/web/src/components/ReadSettings.vue
  31. 22 22
      modules/web/src/components/SourceDebug.vue
  32. 3 3
      modules/web/src/components/SourceHelp.vue
  33. 19 16
      modules/web/src/components/SourceItem.vue
  34. 14 14
      modules/web/src/components/SourceJson.vue
  35. 75 81
      modules/web/src/components/SourceList.vue
  36. 19 11
      modules/web/src/components/SourceTabForm.vue
  37. 10 10
      modules/web/src/components/SourceTabTools.vue
  38. 142 145
      modules/web/src/components/ToolBar.vue
  39. 0 585
      modules/web/src/config/bookSourceEditConfig.js
  40. 585 0
      modules/web/src/config/bookSourceEditConfig.ts
  41. 0 216
      modules/web/src/config/rssSourceEditConfig.js
  42. 216 0
      modules/web/src/config/rssSourceEditConfig.ts
  43. 23 0
      modules/web/src/config/sourceConfig.d.ts
  44. 0 65
      modules/web/src/config/themeConfig.js
  45. 65 0
      modules/web/src/config/themeConfig.ts
  46. 2 2
      modules/web/src/hooks/loading.css
  47. 0 36
      modules/web/src/hooks/loading.js
  48. 40 0
      modules/web/src/hooks/loading.ts
  49. 0 18
      modules/web/src/main.js
  50. 18 0
      modules/web/src/main.ts
  51. 2 1
      modules/web/src/pages/bookshelf/README.md
  52. 10 10
      modules/web/src/pages/bookshelf/index.html
  53. 10 10
      modules/web/src/pages/bookshelf/main.js
  54. 3 2
      modules/web/src/pages/source/README.md
  55. 10 10
      modules/web/src/pages/source/index.html
  56. 6 6
      modules/web/src/pages/source/main.js
  57. 18 0
      modules/web/src/plugins/jump.d.ts
  58. 74 74
      modules/web/src/plugins/jump.js
  59. 0 23
      modules/web/src/router/bookRouter.js
  60. 22 0
      modules/web/src/router/bookRouter.ts
  61. 0 15
      modules/web/src/router/index.js
  62. 14 0
      modules/web/src/router/index.ts
  63. 9 9
      modules/web/src/router/sourceRouter.ts
  64. 162 26
      modules/web/src/source.d.ts
  65. 0 116
      modules/web/src/store/bookStore.js
  66. 127 0
      modules/web/src/store/bookStore.ts
  67. 0 5
      modules/web/src/store/index.js
  68. 5 0
      modules/web/src/store/index.ts
  69. 0 132
      modules/web/src/store/sourceStore.js
  70. 134 0
      modules/web/src/store/sourceStore.ts
  71. 49 38
      modules/web/src/utils/souce.ts
  72. 0 31
      modules/web/src/utils/utils.js
  73. 55 0
      modules/web/src/utils/utils.ts
  74. 300 275
      modules/web/src/views/BookChapter.vue
  75. 268 300
      modules/web/src/views/BookShelf.vue
  76. 12 11
      modules/web/src/views/SourceEditor.vue
  77. 14 0
      modules/web/src/web.d.ts
  78. 16 0
      modules/web/tsconfig.app.json
  79. 11 0
      modules/web/tsconfig.json
  80. 19 0
      modules/web/tsconfig.node.json
  81. 7 6
      modules/web/vite.config.ts

+ 6 - 0
modules/web/.editorconfig

@@ -0,0 +1,6 @@
+[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
+charset = utf-8
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true

+ 0 - 96
modules/web/.eslintrc-auto-import.json

@@ -1,96 +0,0 @@
-{
-  "globals": {
-    "Component": true,
-    "ComponentPublicInstance": true,
-    "ComputedRef": true,
-    "EffectScope": true,
-    "ElMessage": true,
-    "ElMessageBox": true,
-    "InjectionKey": true,
-    "PropType": true,
-    "Ref": true,
-    "VNode": true,
-    "acceptHMRUpdate": true,
-    "computed": true,
-    "createApp": true,
-    "createPinia": true,
-    "customRef": true,
-    "defineAsyncComponent": true,
-    "defineComponent": true,
-    "defineStore": true,
-    "effectScope": true,
-    "getActivePinia": true,
-    "getCurrentInstance": true,
-    "getCurrentScope": true,
-    "h": true,
-    "inject": true,
-    "isProxy": true,
-    "isReactive": true,
-    "isReadonly": true,
-    "isRef": true,
-    "mapActions": true,
-    "mapGetters": true,
-    "mapState": true,
-    "mapStores": true,
-    "mapWritableState": true,
-    "markRaw": true,
-    "nextTick": true,
-    "onActivated": true,
-    "onBeforeMount": true,
-    "onBeforeRouteLeave": true,
-    "onBeforeRouteUpdate": true,
-    "onBeforeUnmount": true,
-    "onBeforeUpdate": true,
-    "onDeactivated": true,
-    "onErrorCaptured": true,
-    "onMounted": true,
-    "onRenderTracked": true,
-    "onRenderTriggered": true,
-    "onScopeDispose": true,
-    "onServerPrefetch": true,
-    "onUnmounted": true,
-    "onUpdated": true,
-    "provide": true,
-    "reactive": true,
-    "readonly": true,
-    "ref": true,
-    "resolveComponent": true,
-    "setActivePinia": true,
-    "setMapStoreSuffix": true,
-    "shallowReactive": true,
-    "shallowReadonly": true,
-    "shallowRef": true,
-    "store": true,
-    "storeToRefs": true,
-    "toRaw": true,
-    "toRef": true,
-    "toRefs": true,
-    "triggerRef": true,
-    "unref": true,
-    "useAttrs": true,
-    "useBookStore": true,
-    "useCssModule": true,
-    "useCssVars": true,
-    "useLink": true,
-    "useRoute": true,
-    "useRouter": true,
-    "useSlots": true,
-    "useSourceStore": true,
-    "watch": true,
-    "watchEffect": true,
-    "watchPostEffect": true,
-    "watchSyncEffect": true,
-    "DirectiveBinding": true,
-    "ExtractDefaultPropTypes": true,
-    "ExtractPropTypes": true,
-    "ExtractPublicPropTypes": true,
-    "MaybeRef": true,
-    "MaybeRefOrGetter": true,
-    "WritableComputedRef": true,
-    "onWatcherCleanup": true,
-    "toValue": true,
-    "useId": true,
-    "useModel": true,
-    "useTemplateRef": true
-  }
-}

+ 0 - 20
modules/web/.eslintrc.cjs

@@ -1,20 +0,0 @@
-module.exports = {
-  root: true,
-  env: {
-    browser: true,
-    es2021: true,
-    node: true,
-  },
-  extends: [
-    "plugin:vue/vue3-essential",
-    "eslint:recommended",
-    "plugin:prettier/recommended",
-    "./.eslintrc-auto-import.json",
-  ],
-  parserOptions: {
-    ecmaVersion: "latest",
-  },
-  rules: {
-    "no-unused-vars": "warn",
-  },
-};

+ 18 - 16
modules/web/.gitignore

@@ -1,28 +1,30 @@
-.DS_Store
-node_modules
-/dist
-/doc
-
-
-# local env files
-.env.local
-.env.*.local
-
-# Log files
+# Logs
+logs
+*.log
 npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
 pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
 
 # Editor directories and files
-.vim
+.vscode/*
+!.vscode/extensions.json
 .idea
-.vscode
 *.suo
 *.ntvs*
 *.njsproj
 *.sln
 *.sw?
-/package-lock.json
-/yarn.lock
-pnpm-lock.yaml
+
+*.tsbuildinfo

+ 7 - 0
modules/web/.prettierrc.json

@@ -0,0 +1,7 @@
+
+{
+  "$schema": "https://json.schemastore.org/prettierrc",
+  "semi": false,
+  "singleQuote": true,
+  "arrowParens": "avoid"
+}

+ 1 - 1
modules/web/README.md

@@ -9,7 +9,7 @@
 
 | ![IE](https://cdn.jsdelivr.net/npm/@browser-logos/edge/edge_32x32.png) | ![Firefox](https://cdn.jsdelivr.net/npm/@browser-logos/firefox/firefox_32x32.png) | ![Chrome](https://cdn.jsdelivr.net/npm/@browser-logos/chrome/chrome_32x32.png) | ![Safari](https://cdn.jsdelivr.net/npm/@browser-logos/safari/safari_32x32.png) |
 | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
-| Edge ≥ 79                                                              | Firefox ≥ 78                                                                      | Chrome ≥ 64                                                                    | Safari ≥ 12                                                                    |
+| Edge ≥ 85                                                              | Firefox ≥ 79                                                                      | Chrome ≥ 85                                                                    | Safari ≥ 14.1                                                                    |
 
 ## 开发
 > 需要阅读app提供后端服务

+ 3 - 0
modules/web/env.d.ts

@@ -0,0 +1,3 @@
+/// <reference types="vite/client" />
+
+declare module "vue3-virtual-scroll-list";

+ 19 - 0
modules/web/eslint.config.mjs

@@ -0,0 +1,19 @@
+import pluginVue from 'eslint-plugin-vue'
+import vueTsEslintConfig from '@vue/eslint-config-typescript'
+import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
+
+export default [
+  {
+    name: 'app/files-to-lint',
+    files: ['**/*.{ts,mts,tsx,vue}'],
+  },
+
+  {
+    name: 'app/files-to-ignore',
+    ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', "src/plugins/jump.js"],
+  },
+
+  ...pluginVue.configs['flat/essential'],
+  ...vueTsEslintConfig(),
+  skipFormatting,
+]

+ 12 - 12
modules/web/index.html

@@ -1,13 +1,13 @@
-<!DOCTYPE html>
-<html lang="zh" class="">
-    <head>
-        <meta charset="UTF-8" />
-        <link rel="icon" href="/favicon.ico" />
-    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
-    </head>
-
-    <body>
-        <div id="app"></div>
-        <script type="module" src="./src/main.js"></script>
-    </body>
+<!DOCTYPE html>
+<html lang="zh" class="">
+    <head>
+        <meta charset="UTF-8" />
+        <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+    </head>
+
+    <body>
+        <div id="app"></div>
+        <script type="module" src="./src/main.ts"></script>
+    </body>
 </html>

+ 0 - 20
modules/web/jsconfig.json

@@ -1,20 +0,0 @@
-{
-  "compilerOptions": {
-    "types": ["@element-plus/icons-vue", "@vueuse/shared", "vite/client"],
-    "target": "ESNext",
-    "useDefineForClassFields": true,
-    "module": "ESNext",
-    "moduleResolution": "bundler",
-    "checkJs": true,
-    "lib": ["ESNext", "DOM"],
-    "skipLibCheck": true,
-    "noEmit": true,
-    "paths": {
-      "@/*": ["./src/*"],
-      "@api": ["./src/api"],
-      "@utils/*": ["./src/utils/*"]
-    }
-  },
-  //"exclude": ["node_modules", "dist"],
-  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.js", "src/**/*.vue"]
-}

+ 20 - 10
modules/web/package.json

@@ -9,9 +9,12 @@
   },
   "scripts": {
     "dev": "vite",
-    "build": "vite build && node scripts/sync.js",
+    "build": "run-p type-check \"build-only {@}\" --",
     "preview": "vite preview",
-    "lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
+    "build-only": "vite build",
+    "type-check": "vue-tsc --build --force",
+    "lint:fix": "eslint . --fix",
+    "format": "prettier --write src/"
   },
   "dependencies": {
     "@element-plus/icons-svg": "^2.3.1",
@@ -19,24 +22,31 @@
     "@vueuse/core": "^11.1.0",
     "@vueuse/shared": "^11.1.0",
     "axios": "^1.7.7",
-    "element-plus": "^2.8.4",
+    "element-plus": "^2.8.5",
     "hotkeys-js": "^3.13.7",
     "pinia": "^2.2.4",
-    "vue": "^3.5.11",
+    "vue": "^3.5.12",
     "vue-router": "^4.4.5",
     "vue3-virtual-scroll-list": "^0.2.1"
   },
   "devDependencies": {
+    "@eslint/compat": "^1.2.0",
+    "@eslint/js": "^9.12.0",
+    "@tsconfig/node20": "^20.1.4",
+    "@types/node": "^20.16.11",
     "@vitejs/plugin-vue": "^5.1.4",
-    "eslint": "^8.57.1",
-    "eslint-config-prettier": "^9.1.0",
-    "eslint-plugin-prettier": "^5.2.1",
-    "eslint-plugin-vue": "^9.28.0",
+    "@vue/eslint-config-prettier": "^10.0.0",
+    "@vue/eslint-config-typescript": "^14.0.1",
+    "@vue/tsconfig": "^0.5.1",
+    "eslint": "^9.12.0",
+    "eslint-plugin-vue": "^9.29.0",
+    "npm-run-all2": "^6.2.3",
     "prettier": "^3.3.3",
-    "sass": "^1.79.4",
+    "typescript": "~5.5.4",
     "unplugin-auto-import": "^0.18.3",
     "unplugin-icons": "^0.19.3",
     "unplugin-vue-components": "^0.27.4",
-    "vite": "^5.4.8"
+    "vite": "^5.4.8",
+    "vue-tsc": "^2.1.6"
   }
 }

+ 2844 - 0
modules/web/pnpm-lock.yaml

@@ -0,0 +1,2844 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@element-plus/icons-svg':
+        specifier: ^2.3.1
+        version: 2.3.1
+      '@element-plus/icons-vue':
+        specifier: ^2.3.1
+        version: 2.3.1(vue@3.5.12(typescript@5.5.4))
+      '@vueuse/core':
+        specifier: ^11.1.0
+        version: 11.1.0(vue@3.5.12(typescript@5.5.4))
+      '@vueuse/shared':
+        specifier: ^11.1.0
+        version: 11.1.0(vue@3.5.12(typescript@5.5.4))
+      axios:
+        specifier: ^1.7.7
+        version: 1.7.7
+      element-plus:
+        specifier: ^2.8.5
+        version: 2.8.5(vue@3.5.12(typescript@5.5.4))
+      hotkeys-js:
+        specifier: ^3.13.7
+        version: 3.13.7
+      pinia:
+        specifier: ^2.2.4
+        version: 2.2.4(typescript@5.5.4)(vue@3.5.12(typescript@5.5.4))
+      vue:
+        specifier: ^3.5.12
+        version: 3.5.12(typescript@5.5.4)
+      vue-router:
+        specifier: ^4.4.5
+        version: 4.4.5(vue@3.5.12(typescript@5.5.4))
+      vue3-virtual-scroll-list:
+        specifier: ^0.2.1
+        version: 0.2.1(vue@3.5.12(typescript@5.5.4))
+    devDependencies:
+      '@eslint/compat':
+        specifier: ^1.2.0
+        version: 1.2.0(eslint@9.12.0)
+      '@eslint/js':
+        specifier: ^9.12.0
+        version: 9.12.0
+      '@tsconfig/node20':
+        specifier: ^20.1.4
+        version: 20.1.4
+      '@types/node':
+        specifier: ^20.16.11
+        version: 20.16.11
+      '@vitejs/plugin-vue':
+        specifier: ^5.1.4
+        version: 5.1.4(vite@5.4.8(@types/node@20.16.11)(sass@1.79.4))(vue@3.5.12(typescript@5.5.4))
+      '@vue/eslint-config-prettier':
+        specifier: ^10.0.0
+        version: 10.0.0(eslint@9.12.0)(prettier@3.3.3)
+      '@vue/eslint-config-typescript':
+        specifier: ^14.0.1
+        version: 14.0.1(eslint-plugin-vue@9.29.0(eslint@9.12.0))(eslint@9.12.0)(typescript@5.5.4)
+      '@vue/tsconfig':
+        specifier: ^0.5.1
+        version: 0.5.1
+      eslint:
+        specifier: ^9.12.0
+        version: 9.12.0
+      eslint-plugin-vue:
+        specifier: ^9.29.0
+        version: 9.29.0(eslint@9.12.0)
+      npm-run-all2:
+        specifier: ^6.2.3
+        version: 6.2.3
+      prettier:
+        specifier: ^3.3.3
+        version: 3.3.3
+      typescript:
+        specifier: ~5.5.4
+        version: 5.5.4
+      unplugin-auto-import:
+        specifier: ^0.18.3
+        version: 0.18.3(@vueuse/core@11.1.0(vue@3.5.12(typescript@5.5.4)))(rollup@4.24.0)(webpack-sources@3.2.3)
+      unplugin-icons:
+        specifier: ^0.19.3
+        version: 0.19.3(@vue/compiler-sfc@3.5.12)(webpack-sources@3.2.3)
+      unplugin-vue-components:
+        specifier: ^0.27.4
+        version: 0.27.4(@babel/parser@7.25.8)(rollup@4.24.0)(vue@3.5.12(typescript@5.5.4))(webpack-sources@3.2.3)
+      vite:
+        specifier: ^5.4.8
+        version: 5.4.8(@types/node@20.16.11)(sass@1.79.4)
+      vue-tsc:
+        specifier: ^2.1.6
+        version: 2.1.6(typescript@5.5.4)
+
+packages:
+
+  '@antfu/install-pkg@0.4.1':
+    resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==}
+
+  '@antfu/utils@0.7.10':
+    resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
+
+  '@babel/helper-string-parser@7.25.7':
+    resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.25.7':
+    resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/parser@7.25.8':
+    resolution: {integrity: sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==}
+    engines: {node: '>=6.0.0'}
+    hasBin: true
+
+  '@babel/types@7.25.8':
+    resolution: {integrity: sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==}
+    engines: {node: '>=6.9.0'}
+
+  '@ctrl/tinycolor@3.6.1':
+    resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
+    engines: {node: '>=10'}
+
+  '@element-plus/icons-svg@2.3.1':
+    resolution: {integrity: sha512-mDrMx3zgAdJRUA26t4WB2A0DdRlw0iyvr2oamWhkhhE0VEuChLXagqJLx+noRSAzjJ1XACQRqSIGC9VwjBOLGA==}
+
+  '@element-plus/icons-vue@2.3.1':
+    resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==}
+    peerDependencies:
+      vue: ^3.2.0
+
+  '@esbuild/aix-ppc64@0.21.5':
+    resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+    engines: {node: '>=12'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.21.5':
+    resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.21.5':
+    resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.21.5':
+    resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.21.5':
+    resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.21.5':
+    resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.21.5':
+    resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.21.5':
+    resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.21.5':
+    resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.21.5':
+    resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+    engines: {node: '>=12'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.21.5':
+    resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.21.5':
+    resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+    engines: {node: '>=12'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.21.5':
+    resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+    engines: {node: '>=12'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.21.5':
+    resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+    engines: {node: '>=12'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.21.5':
+    resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+    engines: {node: '>=12'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.21.5':
+    resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+    engines: {node: '>=12'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.21.5':
+    resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-x64@0.21.5':
+    resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-x64@0.21.5':
+    resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/sunos-x64@0.21.5':
+    resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.21.5':
+    resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+    engines: {node: '>=12'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.21.5':
+    resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+    engines: {node: '>=12'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.21.5':
+    resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+    engines: {node: '>=12'}
+    cpu: [x64]
+    os: [win32]
+
+  '@eslint-community/eslint-utils@4.4.0':
+    resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    peerDependencies:
+      eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+  '@eslint-community/regexpp@4.11.1':
+    resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==}
+    engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+  '@eslint/compat@1.2.0':
+    resolution: {integrity: sha512-CkPWddN7J9JPrQedEr2X7AjK9y1jaMJtxZ4A/+jTMFA2+n5BWhcKHW/EbJyARqg2zzQfgtWUtVmG3hrG6+nGpg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^9.10.0
+    peerDependenciesMeta:
+      eslint:
+        optional: true
+
+  '@eslint/config-array@0.18.0':
+    resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/core@0.6.0':
+    resolution: {integrity: sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/eslintrc@3.1.0':
+    resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/js@9.12.0':
+    resolution: {integrity: sha512-eohesHH8WFRUprDNyEREgqP6beG6htMeUYeCpkEgBCieCMme5r9zFWjzAJp//9S+Kub4rqE+jXe9Cp1a7IYIIA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/object-schema@2.1.4':
+    resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/plugin-kit@0.2.0':
+    resolution: {integrity: sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@floating-ui/core@1.6.8':
+    resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
+
+  '@floating-ui/dom@1.6.11':
+    resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==}
+
+  '@floating-ui/utils@0.2.8':
+    resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
+
+  '@humanfs/core@0.19.0':
+    resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==}
+    engines: {node: '>=18.18.0'}
+
+  '@humanfs/node@0.16.5':
+    resolution: {integrity: sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==}
+    engines: {node: '>=18.18.0'}
+
+  '@humanwhocodes/module-importer@1.0.1':
+    resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+    engines: {node: '>=12.22'}
+
+  '@humanwhocodes/retry@0.3.1':
+    resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
+    engines: {node: '>=18.18'}
+
+  '@iconify/types@2.0.0':
+    resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+
+  '@iconify/utils@2.1.33':
+    resolution: {integrity: sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw==}
+
+  '@jridgewell/sourcemap-codec@1.5.0':
+    resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+
+  '@nodelib/fs.scandir@2.1.5':
+    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.stat@2.0.5':
+    resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.walk@1.2.8':
+    resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+    engines: {node: '>= 8'}
+
+  '@pkgr/core@0.1.1':
+    resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
+    engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+
+  '@rollup/pluginutils@5.1.2':
+    resolution: {integrity: sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/rollup-android-arm-eabi@4.24.0':
+    resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.24.0':
+    resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.24.0':
+    resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.24.0':
+    resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.24.0':
+    resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.24.0':
+    resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.24.0':
+    resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.24.0':
+    resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
+    resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.24.0':
+    resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-s390x-gnu@4.24.0':
+    resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.24.0':
+    resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.24.0':
+    resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-win32-arm64-msvc@4.24.0':
+    resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.24.0':
+    resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.24.0':
+    resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==}
+    cpu: [x64]
+    os: [win32]
+
+  '@sxzz/popperjs-es@2.11.7':
+    resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
+
+  '@tsconfig/node20@20.1.4':
+    resolution: {integrity: sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==}
+
+  '@types/estree@1.0.6':
+    resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
+
+  '@types/json-schema@7.0.15':
+    resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+  '@types/lodash-es@4.17.12':
+    resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
+
+  '@types/lodash@4.17.10':
+    resolution: {integrity: sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==}
+
+  '@types/node@20.16.11':
+    resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==}
+
+  '@types/web-bluetooth@0.0.16':
+    resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
+
+  '@types/web-bluetooth@0.0.20':
+    resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
+
+  '@typescript-eslint/eslint-plugin@8.8.1':
+    resolution: {integrity: sha512-xfvdgA8AP/vxHgtgU310+WBnLB4uJQ9XdyP17RebG26rLtDrQJV3ZYrcopX91GrHmMoH8bdSwMRh2a//TiJ1jQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0
+      eslint: ^8.57.0 || ^9.0.0
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@typescript-eslint/parser@8.8.1':
+    resolution: {integrity: sha512-hQUVn2Lij2NAxVFEdvIGxT9gP1tq2yM83m+by3whWFsWC+1y8pxxxHUFE1UqDu2VsGi2i6RLcv4QvouM84U+ow==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^8.57.0 || ^9.0.0
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@typescript-eslint/scope-manager@8.8.1':
+    resolution: {integrity: sha512-X4JdU+66Mazev/J0gfXlcC/dV6JI37h+93W9BRYXrSn0hrE64IoWgVkO9MSJgEzoWkxONgaQpICWg8vAN74wlA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@typescript-eslint/type-utils@8.8.1':
+    resolution: {integrity: sha512-qSVnpcbLP8CALORf0za+vjLYj1Wp8HSoiI8zYU5tHxRVj30702Z1Yw4cLwfNKhTPWp5+P+k1pjmD5Zd1nhxiZA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@typescript-eslint/types@8.8.1':
+    resolution: {integrity: sha512-WCcTP4SDXzMd23N27u66zTKMuEevH4uzU8C9jf0RO4E04yVHgQgW+r+TeVTNnO1KIfrL8ebgVVYYMMO3+jC55Q==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@typescript-eslint/typescript-estree@8.8.1':
+    resolution: {integrity: sha512-A5d1R9p+X+1js4JogdNilDuuq+EHZdsH9MjTVxXOdVFfTJXunKJR/v+fNNyO4TnoOn5HqobzfRlc70NC6HTcdg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@typescript-eslint/utils@8.8.1':
+    resolution: {integrity: sha512-/QkNJDbV0bdL7H7d0/y0qBbV2HTtf0TIyjSDTvvmQEzeVx8jEImEbLuOA4EsvE8gIgqMitns0ifb5uQhMj8d9w==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^8.57.0 || ^9.0.0
+
+  '@typescript-eslint/visitor-keys@8.8.1':
+    resolution: {integrity: sha512-0/TdC3aeRAsW7MDvYRwEc1Uwm0TIBfzjPFgg60UU2Haj5qsCs9cc3zNgY71edqE3LbWfF/WoZQd3lJoDXFQpag==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@vitejs/plugin-vue@5.1.4':
+    resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    peerDependencies:
+      vite: ^5.0.0
+      vue: ^3.2.25
+
+  '@volar/language-core@2.4.6':
+    resolution: {integrity: sha512-FxUfxaB8sCqvY46YjyAAV6c3mMIq/NWQMVvJ+uS4yxr1KzOvyg61gAuOnNvgCvO4TZ7HcLExBEsWcDu4+K4E8A==}
+
+  '@volar/source-map@2.4.6':
+    resolution: {integrity: sha512-Nsh7UW2ruK+uURIPzjJgF0YRGP5CX9nQHypA2OMqdM2FKy7rh+uv3XgPnWPw30JADbKvZ5HuBzG4gSbVDYVtiw==}
+
+  '@volar/typescript@2.4.6':
+    resolution: {integrity: sha512-NMIrA7y5OOqddL9VtngPWYmdQU03htNKFtAYidbYfWA0TOhyGVd9tfcP4TsLWQ+RBWDZCbBqsr8xzU0ZOxYTCQ==}
+
+  '@vue/compiler-core@3.5.12':
+    resolution: {integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==}
+
+  '@vue/compiler-dom@3.5.12':
+    resolution: {integrity: sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==}
+
+  '@vue/compiler-sfc@3.5.12':
+    resolution: {integrity: sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==}
+
+  '@vue/compiler-ssr@3.5.12':
+    resolution: {integrity: sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==}
+
+  '@vue/compiler-vue2@2.7.16':
+    resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
+
+  '@vue/devtools-api@6.6.4':
+    resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+  '@vue/eslint-config-prettier@10.0.0':
+    resolution: {integrity: sha512-iDEjsfT+UXQTJfe+4mstb/B5BSZ5RpL6FO3F97XxElIXdD04gkH+F7PR4fBMEVyJi4892G4LQVPQ8oXxVyp8Dw==}
+    peerDependencies:
+      eslint: '>= 8.21.0'
+      prettier: '>= 3.0.0'
+
+  '@vue/eslint-config-typescript@14.0.1':
+    resolution: {integrity: sha512-jE5myCG9OiXQOuTSiwCqmM5ktGuiL/xI2SC1DUf+AntvFTDOW5+RZtGKw8L+Af5jlSe0k45RNVh0a/wklcckGQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^9.10.0
+      eslint-plugin-vue: ^9.28.0
+      typescript: '>=4.8.4'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@vue/language-core@2.1.6':
+    resolution: {integrity: sha512-MW569cSky9R/ooKMh6xa2g1D0AtRKbL56k83dzus/bx//RDJk24RHWkMzbAlXjMdDNyxAaagKPRquBIxkxlCkg==}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@vue/reactivity@3.5.12':
+    resolution: {integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==}
+
+  '@vue/runtime-core@3.5.12':
+    resolution: {integrity: sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==}
+
+  '@vue/runtime-dom@3.5.12':
+    resolution: {integrity: sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==}
+
+  '@vue/server-renderer@3.5.12':
+    resolution: {integrity: sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==}
+    peerDependencies:
+      vue: 3.5.12
+
+  '@vue/shared@3.5.12':
+    resolution: {integrity: sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==}
+
+  '@vue/tsconfig@0.5.1':
+    resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==}
+
+  '@vueuse/core@11.1.0':
+    resolution: {integrity: sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==}
+
+  '@vueuse/core@9.13.0':
+    resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
+
+  '@vueuse/metadata@11.1.0':
+    resolution: {integrity: sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==}
+
+  '@vueuse/metadata@9.13.0':
+    resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
+
+  '@vueuse/shared@11.1.0':
+    resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==}
+
+  '@vueuse/shared@9.13.0':
+    resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
+
+  acorn-jsx@5.3.2:
+    resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+    peerDependencies:
+      acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+  acorn@8.12.1:
+    resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==}
+    engines: {node: '>=0.4.0'}
+    hasBin: true
+
+  ajv@6.12.6:
+    resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  ansi-styles@6.2.1:
+    resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
+    engines: {node: '>=12'}
+
+  anymatch@3.1.3:
+    resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+    engines: {node: '>= 8'}
+
+  argparse@2.0.1:
+    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+  async-validator@4.2.5:
+    resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
+
+  asynckit@0.4.0:
+    resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+  axios@1.7.7:
+    resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==}
+
+  balanced-match@1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+  binary-extensions@2.3.0:
+    resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+    engines: {node: '>=8'}
+
+  boolbase@1.0.0:
+    resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+
+  brace-expansion@1.1.11:
+    resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+
+  brace-expansion@2.0.1:
+    resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
+
+  braces@3.0.3:
+    resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+    engines: {node: '>=8'}
+
+  callsites@3.1.0:
+    resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+    engines: {node: '>=6'}
+
+  chalk@4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+
+  chokidar@3.6.0:
+    resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+    engines: {node: '>= 8.10.0'}
+
+  chokidar@4.0.1:
+    resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==}
+    engines: {node: '>= 14.16.0'}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  combined-stream@1.0.8:
+    resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+    engines: {node: '>= 0.8'}
+
+  computeds@0.0.1:
+    resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==}
+
+  concat-map@0.0.1:
+    resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+  confbox@0.1.8:
+    resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
+
+  cross-spawn@7.0.3:
+    resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
+    engines: {node: '>= 8'}
+
+  cssesc@3.0.0:
+    resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+    engines: {node: '>=4'}
+    hasBin: true
+
+  csstype@3.1.3:
+    resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+
+  dayjs@1.11.13:
+    resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
+
+  de-indent@1.0.2:
+    resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+
+  debug@4.3.7:
+    resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  deep-is@0.1.4:
+    resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+  delayed-stream@1.0.0:
+    resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+    engines: {node: '>=0.4.0'}
+
+  element-plus@2.8.5:
+    resolution: {integrity: sha512-Px+kPbRTVvn5oa5+9saa7QEOnUweKXm0JVI7yJHzKF/doQGixwcFMsQEF2+3Fy62EA/7dRRKVuhsNGGZYNk3cw==}
+    peerDependencies:
+      vue: ^3.2.0
+
+  entities@4.5.0:
+    resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+    engines: {node: '>=0.12'}
+
+  esbuild@0.21.5:
+    resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+    engines: {node: '>=12'}
+    hasBin: true
+
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+  escape-string-regexp@4.0.0:
+    resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+    engines: {node: '>=10'}
+
+  escape-string-regexp@5.0.0:
+    resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+    engines: {node: '>=12'}
+
+  eslint-config-prettier@9.1.0:
+    resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==}
+    hasBin: true
+    peerDependencies:
+      eslint: '>=7.0.0'
+
+  eslint-plugin-prettier@5.2.1:
+    resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      '@types/eslint': '>=8.0.0'
+      eslint: '>=8.0.0'
+      eslint-config-prettier: '*'
+      prettier: '>=3.0.0'
+    peerDependenciesMeta:
+      '@types/eslint':
+        optional: true
+      eslint-config-prettier:
+        optional: true
+
+  eslint-plugin-vue@9.29.0:
+    resolution: {integrity: sha512-hamyjrBhNH6Li6R1h1VF9KHfshJlKgKEg3ARbGTn72CMNDSMhWbgC7NdkRDEh25AFW+4SDATzyNM+3gWuZii8g==}
+    engines: {node: ^14.17.0 || >=16.0.0}
+    peerDependencies:
+      eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
+
+  eslint-scope@7.2.2:
+    resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+  eslint-scope@8.1.0:
+    resolution: {integrity: sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  eslint-visitor-keys@3.4.3:
+    resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+  eslint-visitor-keys@4.1.0:
+    resolution: {integrity: sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  eslint@9.12.0:
+    resolution: {integrity: sha512-UVIOlTEWxwIopRL1wgSQYdnVDcEvs2wyaO6DGo5mXqe3r16IoCNWkR29iHhyaP4cICWjbgbmFUGAhh0GJRuGZw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    hasBin: true
+    peerDependencies:
+      jiti: '*'
+    peerDependenciesMeta:
+      jiti:
+        optional: true
+
+  espree@10.2.0:
+    resolution: {integrity: sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  espree@9.6.1:
+    resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+  esquery@1.6.0:
+    resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
+    engines: {node: '>=0.10'}
+
+  esrecurse@4.3.0:
+    resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+    engines: {node: '>=4.0'}
+
+  estraverse@5.3.0:
+    resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+    engines: {node: '>=4.0'}
+
+  estree-walker@2.0.2:
+    resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+  estree-walker@3.0.3:
+    resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+  esutils@2.0.3:
+    resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+    engines: {node: '>=0.10.0'}
+
+  fast-deep-equal@3.1.3:
+    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+  fast-diff@1.3.0:
+    resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
+
+  fast-glob@3.3.2:
+    resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
+    engines: {node: '>=8.6.0'}
+
+  fast-json-stable-stringify@2.1.0:
+    resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+  fast-levenshtein@2.0.6:
+    resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+  fastq@1.17.1:
+    resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
+
+  file-entry-cache@8.0.0:
+    resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+    engines: {node: '>=16.0.0'}
+
+  fill-range@7.1.1:
+    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+    engines: {node: '>=8'}
+
+  find-up@5.0.0:
+    resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+    engines: {node: '>=10'}
+
+  flat-cache@4.0.1:
+    resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+    engines: {node: '>=16'}
+
+  flatted@3.3.1:
+    resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
+
+  follow-redirects@1.15.9:
+    resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
+    engines: {node: '>=4.0'}
+    peerDependencies:
+      debug: '*'
+    peerDependenciesMeta:
+      debug:
+        optional: true
+
+  form-data@4.0.1:
+    resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
+    engines: {node: '>= 6'}
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  glob-parent@5.1.2:
+    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+    engines: {node: '>= 6'}
+
+  glob-parent@6.0.2:
+    resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+    engines: {node: '>=10.13.0'}
+
+  globals@13.24.0:
+    resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
+    engines: {node: '>=8'}
+
+  globals@14.0.0:
+    resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+    engines: {node: '>=18'}
+
+  graphemer@1.4.0:
+    resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+
+  has-flag@4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+
+  he@1.2.0:
+    resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+    hasBin: true
+
+  hotkeys-js@3.13.7:
+    resolution: {integrity: sha512-ygFIdTqqwG4fFP7kkiYlvayZppeIQX2aPpirsngkv1xM1lP0piDY5QEh68nQnIKvz64hfocxhBaD/uK3sSK1yQ==}
+
+  ignore@5.3.2:
+    resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+    engines: {node: '>= 4'}
+
+  immutable@4.3.7:
+    resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
+
+  import-fresh@3.3.0:
+    resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
+    engines: {node: '>=6'}
+
+  imurmurhash@0.1.4:
+    resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+    engines: {node: '>=0.8.19'}
+
+  is-binary-path@2.1.0:
+    resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+    engines: {node: '>=8'}
+
+  is-extglob@2.1.1:
+    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+    engines: {node: '>=0.10.0'}
+
+  is-glob@4.0.3:
+    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+    engines: {node: '>=0.10.0'}
+
+  is-number@7.0.0:
+    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+    engines: {node: '>=0.12.0'}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  js-tokens@9.0.0:
+    resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==}
+
+  js-yaml@4.1.0:
+    resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+    hasBin: true
+
+  json-buffer@3.0.1:
+    resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+  json-parse-even-better-errors@3.0.2:
+    resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
+  json-schema-traverse@0.4.1:
+    resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+  json-stable-stringify-without-jsonify@1.0.1:
+    resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+  keyv@4.5.4:
+    resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+  kolorist@1.8.0:
+    resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
+
+  levn@0.4.1:
+    resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+    engines: {node: '>= 0.8.0'}
+
+  local-pkg@0.5.0:
+    resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==}
+    engines: {node: '>=14'}
+
+  locate-path@6.0.0:
+    resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+    engines: {node: '>=10'}
+
+  lodash-es@4.17.21:
+    resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
+
+  lodash-unified@1.0.3:
+    resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
+    peerDependencies:
+      '@types/lodash-es': '*'
+      lodash: '*'
+      lodash-es: '*'
+
+  lodash.merge@4.6.2:
+    resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+  lodash@4.17.21:
+    resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+
+  magic-string@0.30.12:
+    resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==}
+
+  memoize-one@6.0.0:
+    resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
+
+  memorystream@0.3.1:
+    resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
+    engines: {node: '>= 0.10.0'}
+
+  merge2@1.4.1:
+    resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+    engines: {node: '>= 8'}
+
+  micromatch@4.0.8:
+    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+    engines: {node: '>=8.6'}
+
+  mime-db@1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+
+  minimatch@3.1.2:
+    resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+  minimatch@9.0.5:
+    resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  mlly@1.7.2:
+    resolution: {integrity: sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  muggle-string@0.4.1:
+    resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+  nanoid@3.3.7:
+    resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  natural-compare@1.4.0:
+    resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+  normalize-path@3.0.0:
+    resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+    engines: {node: '>=0.10.0'}
+
+  normalize-wheel-es@1.2.0:
+    resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
+
+  npm-normalize-package-bin@3.0.1:
+    resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
+  npm-run-all2@6.2.3:
+    resolution: {integrity: sha512-5RsxC7jEc/RjxOYBVdEfrJf5FsJ0pHA7jr2/OxrThXknajETCTYjigOCG3iaGjdYIKEQlDuCG0ir0T1HTva8pg==}
+    engines: {node: ^14.18.0 || ^16.13.0 || >=18.0.0, npm: '>= 8'}
+    hasBin: true
+
+  nth-check@2.1.1:
+    resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+
+  optionator@0.9.4:
+    resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+    engines: {node: '>= 0.8.0'}
+
+  p-limit@3.1.0:
+    resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+    engines: {node: '>=10'}
+
+  p-locate@5.0.0:
+    resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+    engines: {node: '>=10'}
+
+  package-manager-detector@0.2.2:
+    resolution: {integrity: sha512-VgXbyrSNsml4eHWIvxxG/nTL4wgybMTXCV2Un/+yEc3aDKKU6nQBZjbeP3Pl3qm9Qg92X/1ng4ffvCeD/zwHgg==}
+
+  parent-module@1.0.1:
+    resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+    engines: {node: '>=6'}
+
+  path-browserify@1.0.1:
+    resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+  path-exists@4.0.0:
+    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+    engines: {node: '>=8'}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  pathe@1.1.2:
+    resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+
+  picocolors@1.1.0:
+    resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
+
+  picomatch@2.3.1:
+    resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+    engines: {node: '>=8.6'}
+
+  pidtree@0.6.0:
+    resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
+    engines: {node: '>=0.10'}
+    hasBin: true
+
+  pinia@2.2.4:
+    resolution: {integrity: sha512-K7ZhpMY9iJ9ShTC0cR2+PnxdQRuwVIsXDO/WIEV/RnMC/vmSoKDTKW/exNQYPI+4ij10UjXqdNiEHwn47McANQ==}
+    peerDependencies:
+      '@vue/composition-api': ^1.4.0
+      typescript: '>=4.4.4'
+      vue: ^2.6.14 || ^3.3.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+      typescript:
+        optional: true
+
+  pkg-types@1.2.1:
+    resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==}
+
+  postcss-selector-parser@6.1.2:
+    resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
+    engines: {node: '>=4'}
+
+  postcss@8.4.47:
+    resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==}
+    engines: {node: ^10 || ^12 || >=14}
+
+  prelude-ls@1.2.1:
+    resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+    engines: {node: '>= 0.8.0'}
+
+  prettier-linter-helpers@1.0.0:
+    resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
+    engines: {node: '>=6.0.0'}
+
+  prettier@3.3.3:
+    resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==}
+    engines: {node: '>=14'}
+    hasBin: true
+
+  proxy-from-env@1.1.0:
+    resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
+  punycode@2.3.1:
+    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+    engines: {node: '>=6'}
+
+  queue-microtask@1.2.3:
+    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+  read-package-json-fast@3.0.2:
+    resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==}
+    engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
+  readdirp@3.6.0:
+    resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+    engines: {node: '>=8.10.0'}
+
+  readdirp@4.0.2:
+    resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==}
+    engines: {node: '>= 14.16.0'}
+
+  resolve-from@4.0.0:
+    resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+    engines: {node: '>=4'}
+
+  reusify@1.0.4:
+    resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
+    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+  rollup@4.24.0:
+    resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  run-parallel@1.2.0:
+    resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+  sass@1.79.4:
+    resolution: {integrity: sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==}
+    engines: {node: '>=14.0.0'}
+    hasBin: true
+
+  scule@1.3.0:
+    resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
+
+  semver@7.6.3:
+    resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  shell-quote@1.8.1:
+    resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==}
+
+  source-map-js@1.2.1:
+    resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+    engines: {node: '>=0.10.0'}
+
+  strip-json-comments@3.1.1:
+    resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+    engines: {node: '>=8'}
+
+  strip-literal@2.1.0:
+    resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==}
+
+  supports-color@7.2.0:
+    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+    engines: {node: '>=8'}
+
+  synckit@0.9.2:
+    resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+
+  text-table@0.2.0:
+    resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
+
+  tinyexec@0.3.0:
+    resolution: {integrity: sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==}
+
+  to-fast-properties@2.0.0:
+    resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
+    engines: {node: '>=4'}
+
+  to-regex-range@5.0.1:
+    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+    engines: {node: '>=8.0'}
+
+  ts-api-utils@1.3.0:
+    resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
+    engines: {node: '>=16'}
+    peerDependencies:
+      typescript: '>=4.2.0'
+
+  tslib@2.7.0:
+    resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
+
+  type-check@0.4.0:
+    resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+    engines: {node: '>= 0.8.0'}
+
+  type-fest@0.20.2:
+    resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
+    engines: {node: '>=10'}
+
+  typescript-eslint@8.8.1:
+    resolution: {integrity: sha512-R0dsXFt6t4SAFjUSKFjMh4pXDtq04SsFKCVGDP3ZOzNP7itF0jBcZYU4fMsZr4y7O7V7Nc751dDeESbe4PbQMQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  typescript@5.5.4:
+    resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  ufo@1.5.4:
+    resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
+
+  undici-types@6.19.8:
+    resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
+
+  unimport@3.13.1:
+    resolution: {integrity: sha512-nNrVzcs93yrZQOW77qnyOVHtb68LegvhYFwxFMfuuWScmwQmyVCG/NBuN8tYsaGzgQUVYv34E/af+Cc9u4og4A==}
+
+  unplugin-auto-import@0.18.3:
+    resolution: {integrity: sha512-q3FUtGQjYA2e+kb1WumyiQMjHM27MrTQ05QfVwtLRVhyYe+KF6TblBYaEX9L6Z0EibsqaXAiW+RFfkcQpfaXzg==}
+    engines: {node: '>=14'}
+    peerDependencies:
+      '@nuxt/kit': ^3.2.2
+      '@vueuse/core': '*'
+    peerDependenciesMeta:
+      '@nuxt/kit':
+        optional: true
+      '@vueuse/core':
+        optional: true
+
+  unplugin-icons@0.19.3:
+    resolution: {integrity: sha512-EUegRmsAI6+rrYr0vXjFlIP+lg4fSC4zb62zAZKx8FGXlWAGgEGBCa3JDe27aRAXhistObLPbBPhwa/0jYLFkQ==}
+    peerDependencies:
+      '@svgr/core': '>=7.0.0'
+      '@svgx/core': ^1.0.1
+      '@vue/compiler-sfc': ^3.0.2 || ^2.7.0
+      vue-template-compiler: ^2.6.12
+      vue-template-es2015-compiler: ^1.9.0
+    peerDependenciesMeta:
+      '@svgr/core':
+        optional: true
+      '@svgx/core':
+        optional: true
+      '@vue/compiler-sfc':
+        optional: true
+      vue-template-compiler:
+        optional: true
+      vue-template-es2015-compiler:
+        optional: true
+
+  unplugin-vue-components@0.27.4:
+    resolution: {integrity: sha512-1XVl5iXG7P1UrOMnaj2ogYa5YTq8aoh5jwDPQhemwO/OrXW+lPQKDXd1hMz15qxQPxgb/XXlbgo3HQ2rLEbmXQ==}
+    engines: {node: '>=14'}
+    peerDependencies:
+      '@babel/parser': ^7.15.8
+      '@nuxt/kit': ^3.2.2
+      vue: 2 || 3
+    peerDependenciesMeta:
+      '@babel/parser':
+        optional: true
+      '@nuxt/kit':
+        optional: true
+
+  unplugin@1.14.1:
+    resolution: {integrity: sha512-lBlHbfSFPToDYp9pjXlUEFVxYLaue9f9T1HC+4OHlmj+HnMDdz9oZY+erXfoCe/5V/7gKUSY2jpXPb9S7f0f/w==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      webpack-sources: ^3
+    peerDependenciesMeta:
+      webpack-sources:
+        optional: true
+
+  uri-js@4.4.1:
+    resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+  vite@5.4.8:
+    resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    hasBin: true
+    peerDependencies:
+      '@types/node': ^18.0.0 || >=20.0.0
+      less: '*'
+      lightningcss: ^1.21.0
+      sass: '*'
+      sass-embedded: '*'
+      stylus: '*'
+      sugarss: '*'
+      terser: ^5.4.0
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+      less:
+        optional: true
+      lightningcss:
+        optional: true
+      sass:
+        optional: true
+      sass-embedded:
+        optional: true
+      stylus:
+        optional: true
+      sugarss:
+        optional: true
+      terser:
+        optional: true
+
+  vscode-uri@3.0.8:
+    resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
+
+  vue-demi@0.14.10:
+    resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
+    engines: {node: '>=12'}
+    hasBin: true
+    peerDependencies:
+      '@vue/composition-api': ^1.0.0-rc.1
+      vue: ^3.0.0-0 || ^2.6.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
+  vue-eslint-parser@9.4.3:
+    resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
+    engines: {node: ^14.17.0 || >=16.0.0}
+    peerDependencies:
+      eslint: '>=6.0.0'
+
+  vue-router@4.4.5:
+    resolution: {integrity: sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==}
+    peerDependencies:
+      vue: ^3.2.0
+
+  vue-tsc@2.1.6:
+    resolution: {integrity: sha512-f98dyZp5FOukcYmbFpuSCJ4Z0vHSOSmxGttZJCsFeX0M4w/Rsq0s4uKXjcSRsZqsRgQa6z7SfuO+y0HVICE57Q==}
+    hasBin: true
+    peerDependencies:
+      typescript: '>=5.0.0'
+
+  vue3-virtual-scroll-list@0.2.1:
+    resolution: {integrity: sha512-G4KxITUOy9D4ro15zOp40D6ogmMefzjIyMsBKqN3xGbV1P6dlKYMx+BBXCKm3Nr/6iipcUKM272Sh2AJRyWMyQ==}
+    peerDependencies:
+      vue: '>=3.0.0'
+
+  vue@3.5.12:
+    resolution: {integrity: sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==}
+    peerDependencies:
+      typescript: '*'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  webpack-sources@3.2.3:
+    resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==}
+    engines: {node: '>=10.13.0'}
+
+  webpack-virtual-modules@0.6.2:
+    resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+
+  which@2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  word-wrap@1.2.5:
+    resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+    engines: {node: '>=0.10.0'}
+
+  xml-name-validator@4.0.0:
+    resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
+    engines: {node: '>=12'}
+
+  yocto-queue@0.1.0:
+    resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+    engines: {node: '>=10'}
+
+snapshots:
+
+  '@antfu/install-pkg@0.4.1':
+    dependencies:
+      package-manager-detector: 0.2.2
+      tinyexec: 0.3.0
+
+  '@antfu/utils@0.7.10': {}
+
+  '@babel/helper-string-parser@7.25.7': {}
+
+  '@babel/helper-validator-identifier@7.25.7': {}
+
+  '@babel/parser@7.25.8':
+    dependencies:
+      '@babel/types': 7.25.8
+
+  '@babel/types@7.25.8':
+    dependencies:
+      '@babel/helper-string-parser': 7.25.7
+      '@babel/helper-validator-identifier': 7.25.7
+      to-fast-properties: 2.0.0
+
+  '@ctrl/tinycolor@3.6.1': {}
+
+  '@element-plus/icons-svg@2.3.1': {}
+
+  '@element-plus/icons-vue@2.3.1(vue@3.5.12(typescript@5.5.4))':
+    dependencies:
+      vue: 3.5.12(typescript@5.5.4)
+
+  '@esbuild/aix-ppc64@0.21.5':
+    optional: true
+
+  '@esbuild/android-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/android-arm@0.21.5':
+    optional: true
+
+  '@esbuild/android-x64@0.21.5':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/darwin-x64@0.21.5':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-arm@0.21.5':
+    optional: true
+
+  '@esbuild/linux-ia32@0.21.5':
+    optional: true
+
+  '@esbuild/linux-loong64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.21.5':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.21.5':
+    optional: true
+
+  '@esbuild/linux-s390x@0.21.5':
+    optional: true
+
+  '@esbuild/linux-x64@0.21.5':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.21.5':
+    optional: true
+
+  '@esbuild/sunos-x64@0.21.5':
+    optional: true
+
+  '@esbuild/win32-arm64@0.21.5':
+    optional: true
+
+  '@esbuild/win32-ia32@0.21.5':
+    optional: true
+
+  '@esbuild/win32-x64@0.21.5':
+    optional: true
+
+  '@eslint-community/eslint-utils@4.4.0(eslint@9.12.0)':
+    dependencies:
+      eslint: 9.12.0
+      eslint-visitor-keys: 3.4.3
+
+  '@eslint-community/regexpp@4.11.1': {}
+
+  '@eslint/compat@1.2.0(eslint@9.12.0)':
+    optionalDependencies:
+      eslint: 9.12.0
+
+  '@eslint/config-array@0.18.0':
+    dependencies:
+      '@eslint/object-schema': 2.1.4
+      debug: 4.3.7
+      minimatch: 3.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@eslint/core@0.6.0': {}
+
+  '@eslint/eslintrc@3.1.0':
+    dependencies:
+      ajv: 6.12.6
+      debug: 4.3.7
+      espree: 10.2.0
+      globals: 14.0.0
+      ignore: 5.3.2
+      import-fresh: 3.3.0
+      js-yaml: 4.1.0
+      minimatch: 3.1.2
+      strip-json-comments: 3.1.1
+    transitivePeerDependencies:
+      - supports-color
+
+  '@eslint/js@9.12.0': {}
+
+  '@eslint/object-schema@2.1.4': {}
+
+  '@eslint/plugin-kit@0.2.0':
+    dependencies:
+      levn: 0.4.1
+
+  '@floating-ui/core@1.6.8':
+    dependencies:
+      '@floating-ui/utils': 0.2.8
+
+  '@floating-ui/dom@1.6.11':
+    dependencies:
+      '@floating-ui/core': 1.6.8
+      '@floating-ui/utils': 0.2.8
+
+  '@floating-ui/utils@0.2.8': {}
+
+  '@humanfs/core@0.19.0': {}
+
+  '@humanfs/node@0.16.5':
+    dependencies:
+      '@humanfs/core': 0.19.0
+      '@humanwhocodes/retry': 0.3.1
+
+  '@humanwhocodes/module-importer@1.0.1': {}
+
+  '@humanwhocodes/retry@0.3.1': {}
+
+  '@iconify/types@2.0.0': {}
+
+  '@iconify/utils@2.1.33':
+    dependencies:
+      '@antfu/install-pkg': 0.4.1
+      '@antfu/utils': 0.7.10
+      '@iconify/types': 2.0.0
+      debug: 4.3.7
+      kolorist: 1.8.0
+      local-pkg: 0.5.0
+      mlly: 1.7.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@jridgewell/sourcemap-codec@1.5.0': {}
+
+  '@nodelib/fs.scandir@2.1.5':
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      run-parallel: 1.2.0
+
+  '@nodelib/fs.stat@2.0.5': {}
+
+  '@nodelib/fs.walk@1.2.8':
+    dependencies:
+      '@nodelib/fs.scandir': 2.1.5
+      fastq: 1.17.1
+
+  '@pkgr/core@0.1.1': {}
+
+  '@rollup/pluginutils@5.1.2(rollup@4.24.0)':
+    dependencies:
+      '@types/estree': 1.0.6
+      estree-walker: 2.0.2
+      picomatch: 2.3.1
+    optionalDependencies:
+      rollup: 4.24.0
+
+  '@rollup/rollup-android-arm-eabi@4.24.0':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.24.0':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.24.0':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.24.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.24.0':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.24.0':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.24.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.24.0':
+    optional: true
+
+  '@sxzz/popperjs-es@2.11.7': {}
+
+  '@tsconfig/node20@20.1.4': {}
+
+  '@types/estree@1.0.6': {}
+
+  '@types/json-schema@7.0.15': {}
+
+  '@types/lodash-es@4.17.12':
+    dependencies:
+      '@types/lodash': 4.17.10
+
+  '@types/lodash@4.17.10': {}
+
+  '@types/node@20.16.11':
+    dependencies:
+      undici-types: 6.19.8
+
+  '@types/web-bluetooth@0.0.16': {}
+
+  '@types/web-bluetooth@0.0.20': {}
+
+  '@typescript-eslint/eslint-plugin@8.8.1(@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.5.4))(eslint@9.12.0)(typescript@5.5.4)':
+    dependencies:
+      '@eslint-community/regexpp': 4.11.1
+      '@typescript-eslint/parser': 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+      '@typescript-eslint/scope-manager': 8.8.1
+      '@typescript-eslint/type-utils': 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+      '@typescript-eslint/utils': 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+      '@typescript-eslint/visitor-keys': 8.8.1
+      eslint: 9.12.0
+      graphemer: 1.4.0
+      ignore: 5.3.2
+      natural-compare: 1.4.0
+      ts-api-utils: 1.3.0(typescript@5.5.4)
+    optionalDependencies:
+      typescript: 5.5.4
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.5.4)':
+    dependencies:
+      '@typescript-eslint/scope-manager': 8.8.1
+      '@typescript-eslint/types': 8.8.1
+      '@typescript-eslint/typescript-estree': 8.8.1(typescript@5.5.4)
+      '@typescript-eslint/visitor-keys': 8.8.1
+      debug: 4.3.7
+      eslint: 9.12.0
+    optionalDependencies:
+      typescript: 5.5.4
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/scope-manager@8.8.1':
+    dependencies:
+      '@typescript-eslint/types': 8.8.1
+      '@typescript-eslint/visitor-keys': 8.8.1
+
+  '@typescript-eslint/type-utils@8.8.1(eslint@9.12.0)(typescript@5.5.4)':
+    dependencies:
+      '@typescript-eslint/typescript-estree': 8.8.1(typescript@5.5.4)
+      '@typescript-eslint/utils': 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+      debug: 4.3.7
+      ts-api-utils: 1.3.0(typescript@5.5.4)
+    optionalDependencies:
+      typescript: 5.5.4
+    transitivePeerDependencies:
+      - eslint
+      - supports-color
+
+  '@typescript-eslint/types@8.8.1': {}
+
+  '@typescript-eslint/typescript-estree@8.8.1(typescript@5.5.4)':
+    dependencies:
+      '@typescript-eslint/types': 8.8.1
+      '@typescript-eslint/visitor-keys': 8.8.1
+      debug: 4.3.7
+      fast-glob: 3.3.2
+      is-glob: 4.0.3
+      minimatch: 9.0.5
+      semver: 7.6.3
+      ts-api-utils: 1.3.0(typescript@5.5.4)
+    optionalDependencies:
+      typescript: 5.5.4
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/utils@8.8.1(eslint@9.12.0)(typescript@5.5.4)':
+    dependencies:
+      '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0)
+      '@typescript-eslint/scope-manager': 8.8.1
+      '@typescript-eslint/types': 8.8.1
+      '@typescript-eslint/typescript-estree': 8.8.1(typescript@5.5.4)
+      eslint: 9.12.0
+    transitivePeerDependencies:
+      - supports-color
+      - typescript
+
+  '@typescript-eslint/visitor-keys@8.8.1':
+    dependencies:
+      '@typescript-eslint/types': 8.8.1
+      eslint-visitor-keys: 3.4.3
+
+  '@vitejs/plugin-vue@5.1.4(vite@5.4.8(@types/node@20.16.11)(sass@1.79.4))(vue@3.5.12(typescript@5.5.4))':
+    dependencies:
+      vite: 5.4.8(@types/node@20.16.11)(sass@1.79.4)
+      vue: 3.5.12(typescript@5.5.4)
+
+  '@volar/language-core@2.4.6':
+    dependencies:
+      '@volar/source-map': 2.4.6
+
+  '@volar/source-map@2.4.6': {}
+
+  '@volar/typescript@2.4.6':
+    dependencies:
+      '@volar/language-core': 2.4.6
+      path-browserify: 1.0.1
+      vscode-uri: 3.0.8
+
+  '@vue/compiler-core@3.5.12':
+    dependencies:
+      '@babel/parser': 7.25.8
+      '@vue/shared': 3.5.12
+      entities: 4.5.0
+      estree-walker: 2.0.2
+      source-map-js: 1.2.1
+
+  '@vue/compiler-dom@3.5.12':
+    dependencies:
+      '@vue/compiler-core': 3.5.12
+      '@vue/shared': 3.5.12
+
+  '@vue/compiler-sfc@3.5.12':
+    dependencies:
+      '@babel/parser': 7.25.8
+      '@vue/compiler-core': 3.5.12
+      '@vue/compiler-dom': 3.5.12
+      '@vue/compiler-ssr': 3.5.12
+      '@vue/shared': 3.5.12
+      estree-walker: 2.0.2
+      magic-string: 0.30.12
+      postcss: 8.4.47
+      source-map-js: 1.2.1
+
+  '@vue/compiler-ssr@3.5.12':
+    dependencies:
+      '@vue/compiler-dom': 3.5.12
+      '@vue/shared': 3.5.12
+
+  '@vue/compiler-vue2@2.7.16':
+    dependencies:
+      de-indent: 1.0.2
+      he: 1.2.0
+
+  '@vue/devtools-api@6.6.4': {}
+
+  '@vue/eslint-config-prettier@10.0.0(eslint@9.12.0)(prettier@3.3.3)':
+    dependencies:
+      eslint: 9.12.0
+      eslint-config-prettier: 9.1.0(eslint@9.12.0)
+      eslint-plugin-prettier: 5.2.1(eslint-config-prettier@9.1.0(eslint@9.12.0))(eslint@9.12.0)(prettier@3.3.3)
+      prettier: 3.3.3
+    transitivePeerDependencies:
+      - '@types/eslint'
+
+  '@vue/eslint-config-typescript@14.0.1(eslint-plugin-vue@9.29.0(eslint@9.12.0))(eslint@9.12.0)(typescript@5.5.4)':
+    dependencies:
+      '@typescript-eslint/eslint-plugin': 8.8.1(@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.5.4))(eslint@9.12.0)(typescript@5.5.4)
+      '@typescript-eslint/parser': 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+      eslint: 9.12.0
+      eslint-plugin-vue: 9.29.0(eslint@9.12.0)
+      typescript-eslint: 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+      vue-eslint-parser: 9.4.3(eslint@9.12.0)
+    optionalDependencies:
+      typescript: 5.5.4
+    transitivePeerDependencies:
+      - supports-color
+
+  '@vue/language-core@2.1.6(typescript@5.5.4)':
+    dependencies:
+      '@volar/language-core': 2.4.6
+      '@vue/compiler-dom': 3.5.12
+      '@vue/compiler-vue2': 2.7.16
+      '@vue/shared': 3.5.12
+      computeds: 0.0.1
+      minimatch: 9.0.5
+      muggle-string: 0.4.1
+      path-browserify: 1.0.1
+    optionalDependencies:
+      typescript: 5.5.4
+
+  '@vue/reactivity@3.5.12':
+    dependencies:
+      '@vue/shared': 3.5.12
+
+  '@vue/runtime-core@3.5.12':
+    dependencies:
+      '@vue/reactivity': 3.5.12
+      '@vue/shared': 3.5.12
+
+  '@vue/runtime-dom@3.5.12':
+    dependencies:
+      '@vue/reactivity': 3.5.12
+      '@vue/runtime-core': 3.5.12
+      '@vue/shared': 3.5.12
+      csstype: 3.1.3
+
+  '@vue/server-renderer@3.5.12(vue@3.5.12(typescript@5.5.4))':
+    dependencies:
+      '@vue/compiler-ssr': 3.5.12
+      '@vue/shared': 3.5.12
+      vue: 3.5.12(typescript@5.5.4)
+
+  '@vue/shared@3.5.12': {}
+
+  '@vue/tsconfig@0.5.1': {}
+
+  '@vueuse/core@11.1.0(vue@3.5.12(typescript@5.5.4))':
+    dependencies:
+      '@types/web-bluetooth': 0.0.20
+      '@vueuse/metadata': 11.1.0
+      '@vueuse/shared': 11.1.0(vue@3.5.12(typescript@5.5.4))
+      vue-demi: 0.14.10(vue@3.5.12(typescript@5.5.4))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
+  '@vueuse/core@9.13.0(vue@3.5.12(typescript@5.5.4))':
+    dependencies:
+      '@types/web-bluetooth': 0.0.16
+      '@vueuse/metadata': 9.13.0
+      '@vueuse/shared': 9.13.0(vue@3.5.12(typescript@5.5.4))
+      vue-demi: 0.14.10(vue@3.5.12(typescript@5.5.4))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
+  '@vueuse/metadata@11.1.0': {}
+
+  '@vueuse/metadata@9.13.0': {}
+
+  '@vueuse/shared@11.1.0(vue@3.5.12(typescript@5.5.4))':
+    dependencies:
+      vue-demi: 0.14.10(vue@3.5.12(typescript@5.5.4))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
+  '@vueuse/shared@9.13.0(vue@3.5.12(typescript@5.5.4))':
+    dependencies:
+      vue-demi: 0.14.10(vue@3.5.12(typescript@5.5.4))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
+  acorn-jsx@5.3.2(acorn@8.12.1):
+    dependencies:
+      acorn: 8.12.1
+
+  acorn@8.12.1: {}
+
+  ajv@6.12.6:
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-json-stable-stringify: 2.1.0
+      json-schema-traverse: 0.4.1
+      uri-js: 4.4.1
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  ansi-styles@6.2.1: {}
+
+  anymatch@3.1.3:
+    dependencies:
+      normalize-path: 3.0.0
+      picomatch: 2.3.1
+
+  argparse@2.0.1: {}
+
+  async-validator@4.2.5: {}
+
+  asynckit@0.4.0: {}
+
+  axios@1.7.7:
+    dependencies:
+      follow-redirects: 1.15.9
+      form-data: 4.0.1
+      proxy-from-env: 1.1.0
+    transitivePeerDependencies:
+      - debug
+
+  balanced-match@1.0.2: {}
+
+  binary-extensions@2.3.0: {}
+
+  boolbase@1.0.0: {}
+
+  brace-expansion@1.1.11:
+    dependencies:
+      balanced-match: 1.0.2
+      concat-map: 0.0.1
+
+  brace-expansion@2.0.1:
+    dependencies:
+      balanced-match: 1.0.2
+
+  braces@3.0.3:
+    dependencies:
+      fill-range: 7.1.1
+
+  callsites@3.1.0: {}
+
+  chalk@4.1.2:
+    dependencies:
+      ansi-styles: 4.3.0
+      supports-color: 7.2.0
+
+  chokidar@3.6.0:
+    dependencies:
+      anymatch: 3.1.3
+      braces: 3.0.3
+      glob-parent: 5.1.2
+      is-binary-path: 2.1.0
+      is-glob: 4.0.3
+      normalize-path: 3.0.0
+      readdirp: 3.6.0
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  chokidar@4.0.1:
+    dependencies:
+      readdirp: 4.0.2
+    optional: true
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  combined-stream@1.0.8:
+    dependencies:
+      delayed-stream: 1.0.0
+
+  computeds@0.0.1: {}
+
+  concat-map@0.0.1: {}
+
+  confbox@0.1.8: {}
+
+  cross-spawn@7.0.3:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+
+  cssesc@3.0.0: {}
+
+  csstype@3.1.3: {}
+
+  dayjs@1.11.13: {}
+
+  de-indent@1.0.2: {}
+
+  debug@4.3.7:
+    dependencies:
+      ms: 2.1.3
+
+  deep-is@0.1.4: {}
+
+  delayed-stream@1.0.0: {}
+
+  element-plus@2.8.5(vue@3.5.12(typescript@5.5.4)):
+    dependencies:
+      '@ctrl/tinycolor': 3.6.1
+      '@element-plus/icons-vue': 2.3.1(vue@3.5.12(typescript@5.5.4))
+      '@floating-ui/dom': 1.6.11
+      '@popperjs/core': '@sxzz/popperjs-es@2.11.7'
+      '@types/lodash': 4.17.10
+      '@types/lodash-es': 4.17.12
+      '@vueuse/core': 9.13.0(vue@3.5.12(typescript@5.5.4))
+      async-validator: 4.2.5
+      dayjs: 1.11.13
+      escape-html: 1.0.3
+      lodash: 4.17.21
+      lodash-es: 4.17.21
+      lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21)
+      memoize-one: 6.0.0
+      normalize-wheel-es: 1.2.0
+      vue: 3.5.12(typescript@5.5.4)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+
+  entities@4.5.0: {}
+
+  esbuild@0.21.5:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.21.5
+      '@esbuild/android-arm': 0.21.5
+      '@esbuild/android-arm64': 0.21.5
+      '@esbuild/android-x64': 0.21.5
+      '@esbuild/darwin-arm64': 0.21.5
+      '@esbuild/darwin-x64': 0.21.5
+      '@esbuild/freebsd-arm64': 0.21.5
+      '@esbuild/freebsd-x64': 0.21.5
+      '@esbuild/linux-arm': 0.21.5
+      '@esbuild/linux-arm64': 0.21.5
+      '@esbuild/linux-ia32': 0.21.5
+      '@esbuild/linux-loong64': 0.21.5
+      '@esbuild/linux-mips64el': 0.21.5
+      '@esbuild/linux-ppc64': 0.21.5
+      '@esbuild/linux-riscv64': 0.21.5
+      '@esbuild/linux-s390x': 0.21.5
+      '@esbuild/linux-x64': 0.21.5
+      '@esbuild/netbsd-x64': 0.21.5
+      '@esbuild/openbsd-x64': 0.21.5
+      '@esbuild/sunos-x64': 0.21.5
+      '@esbuild/win32-arm64': 0.21.5
+      '@esbuild/win32-ia32': 0.21.5
+      '@esbuild/win32-x64': 0.21.5
+
+  escape-html@1.0.3: {}
+
+  escape-string-regexp@4.0.0: {}
+
+  escape-string-regexp@5.0.0: {}
+
+  eslint-config-prettier@9.1.0(eslint@9.12.0):
+    dependencies:
+      eslint: 9.12.0
+
+  eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@9.12.0))(eslint@9.12.0)(prettier@3.3.3):
+    dependencies:
+      eslint: 9.12.0
+      prettier: 3.3.3
+      prettier-linter-helpers: 1.0.0
+      synckit: 0.9.2
+    optionalDependencies:
+      eslint-config-prettier: 9.1.0(eslint@9.12.0)
+
+  eslint-plugin-vue@9.29.0(eslint@9.12.0):
+    dependencies:
+      '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0)
+      eslint: 9.12.0
+      globals: 13.24.0
+      natural-compare: 1.4.0
+      nth-check: 2.1.1
+      postcss-selector-parser: 6.1.2
+      semver: 7.6.3
+      vue-eslint-parser: 9.4.3(eslint@9.12.0)
+      xml-name-validator: 4.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  eslint-scope@7.2.2:
+    dependencies:
+      esrecurse: 4.3.0
+      estraverse: 5.3.0
+
+  eslint-scope@8.1.0:
+    dependencies:
+      esrecurse: 4.3.0
+      estraverse: 5.3.0
+
+  eslint-visitor-keys@3.4.3: {}
+
+  eslint-visitor-keys@4.1.0: {}
+
+  eslint@9.12.0:
+    dependencies:
+      '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0)
+      '@eslint-community/regexpp': 4.11.1
+      '@eslint/config-array': 0.18.0
+      '@eslint/core': 0.6.0
+      '@eslint/eslintrc': 3.1.0
+      '@eslint/js': 9.12.0
+      '@eslint/plugin-kit': 0.2.0
+      '@humanfs/node': 0.16.5
+      '@humanwhocodes/module-importer': 1.0.1
+      '@humanwhocodes/retry': 0.3.1
+      '@types/estree': 1.0.6
+      '@types/json-schema': 7.0.15
+      ajv: 6.12.6
+      chalk: 4.1.2
+      cross-spawn: 7.0.3
+      debug: 4.3.7
+      escape-string-regexp: 4.0.0
+      eslint-scope: 8.1.0
+      eslint-visitor-keys: 4.1.0
+      espree: 10.2.0
+      esquery: 1.6.0
+      esutils: 2.0.3
+      fast-deep-equal: 3.1.3
+      file-entry-cache: 8.0.0
+      find-up: 5.0.0
+      glob-parent: 6.0.2
+      ignore: 5.3.2
+      imurmurhash: 0.1.4
+      is-glob: 4.0.3
+      json-stable-stringify-without-jsonify: 1.0.1
+      lodash.merge: 4.6.2
+      minimatch: 3.1.2
+      natural-compare: 1.4.0
+      optionator: 0.9.4
+      text-table: 0.2.0
+    transitivePeerDependencies:
+      - supports-color
+
+  espree@10.2.0:
+    dependencies:
+      acorn: 8.12.1
+      acorn-jsx: 5.3.2(acorn@8.12.1)
+      eslint-visitor-keys: 4.1.0
+
+  espree@9.6.1:
+    dependencies:
+      acorn: 8.12.1
+      acorn-jsx: 5.3.2(acorn@8.12.1)
+      eslint-visitor-keys: 3.4.3
+
+  esquery@1.6.0:
+    dependencies:
+      estraverse: 5.3.0
+
+  esrecurse@4.3.0:
+    dependencies:
+      estraverse: 5.3.0
+
+  estraverse@5.3.0: {}
+
+  estree-walker@2.0.2: {}
+
+  estree-walker@3.0.3:
+    dependencies:
+      '@types/estree': 1.0.6
+
+  esutils@2.0.3: {}
+
+  fast-deep-equal@3.1.3: {}
+
+  fast-diff@1.3.0: {}
+
+  fast-glob@3.3.2:
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      '@nodelib/fs.walk': 1.2.8
+      glob-parent: 5.1.2
+      merge2: 1.4.1
+      micromatch: 4.0.8
+
+  fast-json-stable-stringify@2.1.0: {}
+
+  fast-levenshtein@2.0.6: {}
+
+  fastq@1.17.1:
+    dependencies:
+      reusify: 1.0.4
+
+  file-entry-cache@8.0.0:
+    dependencies:
+      flat-cache: 4.0.1
+
+  fill-range@7.1.1:
+    dependencies:
+      to-regex-range: 5.0.1
+
+  find-up@5.0.0:
+    dependencies:
+      locate-path: 6.0.0
+      path-exists: 4.0.0
+
+  flat-cache@4.0.1:
+    dependencies:
+      flatted: 3.3.1
+      keyv: 4.5.4
+
+  flatted@3.3.1: {}
+
+  follow-redirects@1.15.9: {}
+
+  form-data@4.0.1:
+    dependencies:
+      asynckit: 0.4.0
+      combined-stream: 1.0.8
+      mime-types: 2.1.35
+
+  fsevents@2.3.3:
+    optional: true
+
+  glob-parent@5.1.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob-parent@6.0.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  globals@13.24.0:
+    dependencies:
+      type-fest: 0.20.2
+
+  globals@14.0.0: {}
+
+  graphemer@1.4.0: {}
+
+  has-flag@4.0.0: {}
+
+  he@1.2.0: {}
+
+  hotkeys-js@3.13.7: {}
+
+  ignore@5.3.2: {}
+
+  immutable@4.3.7:
+    optional: true
+
+  import-fresh@3.3.0:
+    dependencies:
+      parent-module: 1.0.1
+      resolve-from: 4.0.0
+
+  imurmurhash@0.1.4: {}
+
+  is-binary-path@2.1.0:
+    dependencies:
+      binary-extensions: 2.3.0
+
+  is-extglob@2.1.1: {}
+
+  is-glob@4.0.3:
+    dependencies:
+      is-extglob: 2.1.1
+
+  is-number@7.0.0: {}
+
+  isexe@2.0.0: {}
+
+  js-tokens@9.0.0: {}
+
+  js-yaml@4.1.0:
+    dependencies:
+      argparse: 2.0.1
+
+  json-buffer@3.0.1: {}
+
+  json-parse-even-better-errors@3.0.2: {}
+
+  json-schema-traverse@0.4.1: {}
+
+  json-stable-stringify-without-jsonify@1.0.1: {}
+
+  keyv@4.5.4:
+    dependencies:
+      json-buffer: 3.0.1
+
+  kolorist@1.8.0: {}
+
+  levn@0.4.1:
+    dependencies:
+      prelude-ls: 1.2.1
+      type-check: 0.4.0
+
+  local-pkg@0.5.0:
+    dependencies:
+      mlly: 1.7.2
+      pkg-types: 1.2.1
+
+  locate-path@6.0.0:
+    dependencies:
+      p-locate: 5.0.0
+
+  lodash-es@4.17.21: {}
+
+  lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
+    dependencies:
+      '@types/lodash-es': 4.17.12
+      lodash: 4.17.21
+      lodash-es: 4.17.21
+
+  lodash.merge@4.6.2: {}
+
+  lodash@4.17.21: {}
+
+  magic-string@0.30.12:
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.0
+
+  memoize-one@6.0.0: {}
+
+  memorystream@0.3.1: {}
+
+  merge2@1.4.1: {}
+
+  micromatch@4.0.8:
+    dependencies:
+      braces: 3.0.3
+      picomatch: 2.3.1
+
+  mime-db@1.52.0: {}
+
+  mime-types@2.1.35:
+    dependencies:
+      mime-db: 1.52.0
+
+  minimatch@3.1.2:
+    dependencies:
+      brace-expansion: 1.1.11
+
+  minimatch@9.0.5:
+    dependencies:
+      brace-expansion: 2.0.1
+
+  mlly@1.7.2:
+    dependencies:
+      acorn: 8.12.1
+      pathe: 1.1.2
+      pkg-types: 1.2.1
+      ufo: 1.5.4
+
+  ms@2.1.3: {}
+
+  muggle-string@0.4.1: {}
+
+  nanoid@3.3.7: {}
+
+  natural-compare@1.4.0: {}
+
+  normalize-path@3.0.0: {}
+
+  normalize-wheel-es@1.2.0: {}
+
+  npm-normalize-package-bin@3.0.1: {}
+
+  npm-run-all2@6.2.3:
+    dependencies:
+      ansi-styles: 6.2.1
+      cross-spawn: 7.0.3
+      memorystream: 0.3.1
+      minimatch: 9.0.5
+      pidtree: 0.6.0
+      read-package-json-fast: 3.0.2
+      shell-quote: 1.8.1
+
+  nth-check@2.1.1:
+    dependencies:
+      boolbase: 1.0.0
+
+  optionator@0.9.4:
+    dependencies:
+      deep-is: 0.1.4
+      fast-levenshtein: 2.0.6
+      levn: 0.4.1
+      prelude-ls: 1.2.1
+      type-check: 0.4.0
+      word-wrap: 1.2.5
+
+  p-limit@3.1.0:
+    dependencies:
+      yocto-queue: 0.1.0
+
+  p-locate@5.0.0:
+    dependencies:
+      p-limit: 3.1.0
+
+  package-manager-detector@0.2.2: {}
+
+  parent-module@1.0.1:
+    dependencies:
+      callsites: 3.1.0
+
+  path-browserify@1.0.1: {}
+
+  path-exists@4.0.0: {}
+
+  path-key@3.1.1: {}
+
+  pathe@1.1.2: {}
+
+  picocolors@1.1.0: {}
+
+  picomatch@2.3.1: {}
+
+  pidtree@0.6.0: {}
+
+  pinia@2.2.4(typescript@5.5.4)(vue@3.5.12(typescript@5.5.4)):
+    dependencies:
+      '@vue/devtools-api': 6.6.4
+      vue: 3.5.12(typescript@5.5.4)
+      vue-demi: 0.14.10(vue@3.5.12(typescript@5.5.4))
+    optionalDependencies:
+      typescript: 5.5.4
+
+  pkg-types@1.2.1:
+    dependencies:
+      confbox: 0.1.8
+      mlly: 1.7.2
+      pathe: 1.1.2
+
+  postcss-selector-parser@6.1.2:
+    dependencies:
+      cssesc: 3.0.0
+      util-deprecate: 1.0.2
+
+  postcss@8.4.47:
+    dependencies:
+      nanoid: 3.3.7
+      picocolors: 1.1.0
+      source-map-js: 1.2.1
+
+  prelude-ls@1.2.1: {}
+
+  prettier-linter-helpers@1.0.0:
+    dependencies:
+      fast-diff: 1.3.0
+
+  prettier@3.3.3: {}
+
+  proxy-from-env@1.1.0: {}
+
+  punycode@2.3.1: {}
+
+  queue-microtask@1.2.3: {}
+
+  read-package-json-fast@3.0.2:
+    dependencies:
+      json-parse-even-better-errors: 3.0.2
+      npm-normalize-package-bin: 3.0.1
+
+  readdirp@3.6.0:
+    dependencies:
+      picomatch: 2.3.1
+
+  readdirp@4.0.2:
+    optional: true
+
+  resolve-from@4.0.0: {}
+
+  reusify@1.0.4: {}
+
+  rollup@4.24.0:
+    dependencies:
+      '@types/estree': 1.0.6
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.24.0
+      '@rollup/rollup-android-arm64': 4.24.0
+      '@rollup/rollup-darwin-arm64': 4.24.0
+      '@rollup/rollup-darwin-x64': 4.24.0
+      '@rollup/rollup-linux-arm-gnueabihf': 4.24.0
+      '@rollup/rollup-linux-arm-musleabihf': 4.24.0
+      '@rollup/rollup-linux-arm64-gnu': 4.24.0
+      '@rollup/rollup-linux-arm64-musl': 4.24.0
+      '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0
+      '@rollup/rollup-linux-riscv64-gnu': 4.24.0
+      '@rollup/rollup-linux-s390x-gnu': 4.24.0
+      '@rollup/rollup-linux-x64-gnu': 4.24.0
+      '@rollup/rollup-linux-x64-musl': 4.24.0
+      '@rollup/rollup-win32-arm64-msvc': 4.24.0
+      '@rollup/rollup-win32-ia32-msvc': 4.24.0
+      '@rollup/rollup-win32-x64-msvc': 4.24.0
+      fsevents: 2.3.3
+
+  run-parallel@1.2.0:
+    dependencies:
+      queue-microtask: 1.2.3
+
+  sass@1.79.4:
+    dependencies:
+      chokidar: 4.0.1
+      immutable: 4.3.7
+      source-map-js: 1.2.1
+    optional: true
+
+  scule@1.3.0: {}
+
+  semver@7.6.3: {}
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@3.0.0: {}
+
+  shell-quote@1.8.1: {}
+
+  source-map-js@1.2.1: {}
+
+  strip-json-comments@3.1.1: {}
+
+  strip-literal@2.1.0:
+    dependencies:
+      js-tokens: 9.0.0
+
+  supports-color@7.2.0:
+    dependencies:
+      has-flag: 4.0.0
+
+  synckit@0.9.2:
+    dependencies:
+      '@pkgr/core': 0.1.1
+      tslib: 2.7.0
+
+  text-table@0.2.0: {}
+
+  tinyexec@0.3.0: {}
+
+  to-fast-properties@2.0.0: {}
+
+  to-regex-range@5.0.1:
+    dependencies:
+      is-number: 7.0.0
+
+  ts-api-utils@1.3.0(typescript@5.5.4):
+    dependencies:
+      typescript: 5.5.4
+
+  tslib@2.7.0: {}
+
+  type-check@0.4.0:
+    dependencies:
+      prelude-ls: 1.2.1
+
+  type-fest@0.20.2: {}
+
+  typescript-eslint@8.8.1(eslint@9.12.0)(typescript@5.5.4):
+    dependencies:
+      '@typescript-eslint/eslint-plugin': 8.8.1(@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.5.4))(eslint@9.12.0)(typescript@5.5.4)
+      '@typescript-eslint/parser': 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+      '@typescript-eslint/utils': 8.8.1(eslint@9.12.0)(typescript@5.5.4)
+    optionalDependencies:
+      typescript: 5.5.4
+    transitivePeerDependencies:
+      - eslint
+      - supports-color
+
+  typescript@5.5.4: {}
+
+  ufo@1.5.4: {}
+
+  undici-types@6.19.8: {}
+
+  unimport@3.13.1(rollup@4.24.0)(webpack-sources@3.2.3):
+    dependencies:
+      '@rollup/pluginutils': 5.1.2(rollup@4.24.0)
+      acorn: 8.12.1
+      escape-string-regexp: 5.0.0
+      estree-walker: 3.0.3
+      fast-glob: 3.3.2
+      local-pkg: 0.5.0
+      magic-string: 0.30.12
+      mlly: 1.7.2
+      pathe: 1.1.2
+      pkg-types: 1.2.1
+      scule: 1.3.0
+      strip-literal: 2.1.0
+      unplugin: 1.14.1(webpack-sources@3.2.3)
+    transitivePeerDependencies:
+      - rollup
+      - webpack-sources
+
+  unplugin-auto-import@0.18.3(@vueuse/core@11.1.0(vue@3.5.12(typescript@5.5.4)))(rollup@4.24.0)(webpack-sources@3.2.3):
+    dependencies:
+      '@antfu/utils': 0.7.10
+      '@rollup/pluginutils': 5.1.2(rollup@4.24.0)
+      fast-glob: 3.3.2
+      local-pkg: 0.5.0
+      magic-string: 0.30.12
+      minimatch: 9.0.5
+      unimport: 3.13.1(rollup@4.24.0)(webpack-sources@3.2.3)
+      unplugin: 1.14.1(webpack-sources@3.2.3)
+    optionalDependencies:
+      '@vueuse/core': 11.1.0(vue@3.5.12(typescript@5.5.4))
+    transitivePeerDependencies:
+      - rollup
+      - webpack-sources
+
+  unplugin-icons@0.19.3(@vue/compiler-sfc@3.5.12)(webpack-sources@3.2.3):
+    dependencies:
+      '@antfu/install-pkg': 0.4.1
+      '@antfu/utils': 0.7.10
+      '@iconify/utils': 2.1.33
+      debug: 4.3.7
+      kolorist: 1.8.0
+      local-pkg: 0.5.0
+      unplugin: 1.14.1(webpack-sources@3.2.3)
+    optionalDependencies:
+      '@vue/compiler-sfc': 3.5.12
+    transitivePeerDependencies:
+      - supports-color
+      - webpack-sources
+
+  unplugin-vue-components@0.27.4(@babel/parser@7.25.8)(rollup@4.24.0)(vue@3.5.12(typescript@5.5.4))(webpack-sources@3.2.3):
+    dependencies:
+      '@antfu/utils': 0.7.10
+      '@rollup/pluginutils': 5.1.2(rollup@4.24.0)
+      chokidar: 3.6.0
+      debug: 4.3.7
+      fast-glob: 3.3.2
+      local-pkg: 0.5.0
+      magic-string: 0.30.12
+      minimatch: 9.0.5
+      mlly: 1.7.2
+      unplugin: 1.14.1(webpack-sources@3.2.3)
+      vue: 3.5.12(typescript@5.5.4)
+    optionalDependencies:
+      '@babel/parser': 7.25.8
+    transitivePeerDependencies:
+      - rollup
+      - supports-color
+      - webpack-sources
+
+  unplugin@1.14.1(webpack-sources@3.2.3):
+    dependencies:
+      acorn: 8.12.1
+      webpack-virtual-modules: 0.6.2
+    optionalDependencies:
+      webpack-sources: 3.2.3
+
+  uri-js@4.4.1:
+    dependencies:
+      punycode: 2.3.1
+
+  util-deprecate@1.0.2: {}
+
+  vite@5.4.8(@types/node@20.16.11)(sass@1.79.4):
+    dependencies:
+      esbuild: 0.21.5
+      postcss: 8.4.47
+      rollup: 4.24.0
+    optionalDependencies:
+      '@types/node': 20.16.11
+      fsevents: 2.3.3
+      sass: 1.79.4
+
+  vscode-uri@3.0.8: {}
+
+  vue-demi@0.14.10(vue@3.5.12(typescript@5.5.4)):
+    dependencies:
+      vue: 3.5.12(typescript@5.5.4)
+
+  vue-eslint-parser@9.4.3(eslint@9.12.0):
+    dependencies:
+      debug: 4.3.7
+      eslint: 9.12.0
+      eslint-scope: 7.2.2
+      eslint-visitor-keys: 3.4.3
+      espree: 9.6.1
+      esquery: 1.6.0
+      lodash: 4.17.21
+      semver: 7.6.3
+    transitivePeerDependencies:
+      - supports-color
+
+  vue-router@4.4.5(vue@3.5.12(typescript@5.5.4)):
+    dependencies:
+      '@vue/devtools-api': 6.6.4
+      vue: 3.5.12(typescript@5.5.4)
+
+  vue-tsc@2.1.6(typescript@5.5.4):
+    dependencies:
+      '@volar/typescript': 2.4.6
+      '@vue/language-core': 2.1.6(typescript@5.5.4)
+      semver: 7.6.3
+      typescript: 5.5.4
+
+  vue3-virtual-scroll-list@0.2.1(vue@3.5.12(typescript@5.5.4)):
+    dependencies:
+      vue: 3.5.12(typescript@5.5.4)
+
+  vue@3.5.12(typescript@5.5.4):
+    dependencies:
+      '@vue/compiler-dom': 3.5.12
+      '@vue/compiler-sfc': 3.5.12
+      '@vue/runtime-dom': 3.5.12
+      '@vue/server-renderer': 3.5.12(vue@3.5.12(typescript@5.5.4))
+      '@vue/shared': 3.5.12
+    optionalDependencies:
+      typescript: 5.5.4
+
+  webpack-sources@3.2.3:
+    optional: true
+
+  webpack-virtual-modules@0.6.2: {}
+
+  which@2.0.2:
+    dependencies:
+      isexe: 2.0.0
+
+  word-wrap@1.2.5: {}
+
+  xml-name-validator@4.0.0: {}
+
+  yocto-queue@0.1.0: {}

+ 219 - 0
modules/web/src/api/api.ts

@@ -0,0 +1,219 @@
+/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/api */
+/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/web */
+
+import type { webReadConfig } from '@/web'
+import ajax from './axios'
+import type {
+  BaseBook,
+  Book,
+  BookChapter,
+  BookProgress,
+  SeachBook,
+} from '@/book'
+import type { Source } from '@/source'
+
+export type LeagdoApiResponse<T> = {
+  isSuccess: boolean
+  errorMsg: string
+  data: T
+}
+
+export let legado_http_entry_point = ''
+export let legado_webSocket_entry_point = ''
+
+let wsOnError: typeof WebSocket.prototype.onerror = () => {}
+export const setWebsocketOnError = (
+  callback: typeof WebSocket.prototype.onerror,
+) => {
+  //WebSocket.prototype.onerror = callback
+  wsOnError = callback
+}
+
+export const setApiEntryPoint = (
+  http_entry_point: string,
+  webSocket_entry_point: string,
+) => {
+  legado_http_entry_point = http_entry_point
+  legado_webSocket_entry_point = webSocket_entry_point
+  ajax.defaults.baseURL = http_entry_point.toString()
+}
+
+// 书架API
+// Http
+const getReadConfig = async (http_url = legado_http_entry_point) => {
+  const { data } = await ajax.get<LeagdoApiResponse<string>>('/getReadConfig', {
+    baseURL: http_url.toString(),
+    timeout: 3000,
+  })
+  if (data.isSuccess) {
+    try {
+      return JSON.parse(data.data) as webReadConfig
+    } catch {}
+  }
+}
+const saveReadConfig = (config: webReadConfig) =>
+  ajax.post<LeagdoApiResponse<string>>('/saveReadConfig', config)
+
+const saveBookProgress = (bookProgress: BookProgress) =>
+  ajax.post('/saveBookProgress', bookProgress)
+
+const saveBookProgressWithBeacon = (bookProgress: BookProgress) => {
+  if (!bookProgress) return
+  // 常规请求可能会被取消 使用Fetch keep-alive 或者 navigator.sendBeacon
+  navigator.sendBeacon(
+    new URL('/saveBookProgress', legado_http_entry_point),
+    JSON.stringify(bookProgress),
+  )
+}
+
+const getBookShelf = () => ajax.get<LeagdoApiResponse<Book[]>>('/getBookshelf')
+
+const getChapterList = (/** @type {string} */ bookUrl: string) =>
+  ajax.get<LeagdoApiResponse<BookChapter[]>>(
+    '/getChapterList?url=' + encodeURIComponent(bookUrl),
+  )
+
+const getBookContent = (
+  /** @type {string} */ bookUrl: string,
+  /** @type {number} */ chapterIndex: number,
+) =>
+  ajax.get<LeagdoApiResponse<string>>(
+    '/getBookContent?url=' +
+      encodeURIComponent(bookUrl) +
+      '&index=' +
+      chapterIndex,
+  )
+
+// webSocket
+const search = (
+  searchKey: string,
+  onReceive: (data: SeachBook[]) => void,
+  onFinish: () => void,
+) => {
+  const socket = new WebSocket(
+    new URL('/searchBook', legado_webSocket_entry_point),
+  )
+  socket.onerror = wsOnError
+
+  socket.onopen = () => {
+    socket.send(`{"key":"${searchKey}"}`)
+  }
+  socket.onmessage = ({ data }) => {
+    try {
+      onReceive(JSON.parse(data))
+    } catch {
+      onFinish()
+    }
+  }
+
+  socket.onclose = () => {
+    onFinish()
+  }
+}
+
+const saveBook = (book: BaseBook) =>
+  ajax.post<LeagdoApiResponse<string>>('/saveBook', book)
+const deleteBook = (book: BaseBook) =>
+  ajax.post<LeagdoApiResponse<string>>('/deleteBook', book)
+
+const isBookSource = /bookSource/i.test(location.href)
+
+// 源编辑API
+// Http
+const getSources = () =>
+  isBookSource ? ajax.get('/getBookSources') : ajax.get('/getRssSources')
+
+const saveSource = (data: Source) =>
+  isBookSource
+    ? ajax.post<LeagdoApiResponse<string>>('/saveBookSource', data)
+    : ajax.post<LeagdoApiResponse<string>>('/saveRssSource', data)
+
+const saveSources = (data: Source[]) =>
+  isBookSource
+    ? ajax.post<LeagdoApiResponse<Source[]>>('/saveBookSources', data)
+    : ajax.post<LeagdoApiResponse<Source[]>>('/saveRssSources', data)
+
+const deleteSource = (data: Source[]) =>
+  isBookSource
+    ? ajax.post<LeagdoApiResponse<string>>('/deleteBookSources', data)
+    : ajax.post<LeagdoApiResponse<string>>('/deleteRssSources', data)
+
+// webSocket
+const debug = (
+  /** @type {string} */ sourceUrl: string,
+  /** @type {string} */ searchKey: string,
+  /** @type {(data: string) => void} */ onReceive: (data: string) => void,
+  /** @type {() => void} */ onFinish: () => void,
+) => {
+  const url = new URL(
+    `/${isBookSource ? 'bookSource' : 'rssSource'}Debug`,
+    legado_webSocket_entry_point,
+  )
+
+  const socket = new WebSocket(url)
+  socket.onerror = wsOnError
+  socket.onopen = () => {
+    socket.send(JSON.stringify({ tag: sourceUrl, key: searchKey }))
+  }
+  socket.onmessage = ({ data }) => onReceive(data)
+
+  socket.onclose = () => {
+    onFinish()
+  }
+}
+
+/**
+ * 从阅读获取需要特定处理的书籍封面
+ * @param {string} coverUrl
+ */
+const getProxyCoverUrl = (coverUrl: string) => {
+  if (coverUrl.startsWith(legado_http_entry_point)) return coverUrl
+  return new URL(
+    '/cover?path=' + encodeURIComponent(coverUrl),
+    legado_http_entry_point,
+  ).toString()
+}
+/**
+ * 从阅读获取需要特定处理的图片
+ * @param {string} bookUrl
+ * @param {string} src
+ * @param {number|`${number}`} width
+ */
+const getProxyImageUrl = (
+  bookUrl: string,
+  src: string,
+  width: number | `${number}`,
+) => {
+  if (src.startsWith(legado_http_entry_point)) return src
+  return new URL(
+    '/image?path=' +
+      encodeURIComponent(src) +
+      '&url=' +
+      encodeURIComponent(bookUrl) +
+      '&width=' +
+      width,
+    legado_http_entry_point,
+  ).toString()
+}
+
+export default {
+  getReadConfig,
+  saveReadConfig,
+  saveBookProgress,
+  saveBookProgressWithBeacon,
+  getBookShelf,
+  getChapterList,
+  getBookContent,
+  search,
+  saveBook,
+  deleteBook,
+
+  getSources,
+  saveSources,
+  saveSource,
+  deleteSource,
+  debug,
+
+  getProxyCoverUrl,
+  getProxyImageUrl,
+}

+ 5 - 5
modules/web/src/api/axios.js → modules/web/src/api/axios.ts

@@ -1,8 +1,8 @@
-import axios from "axios";
+import axios from 'axios'
 
 /** @type {string} localStorage保存自定义阅读http服务接口的键值 */
-export const baseURL_localStorage_key = "remoteUrl"
-const SECOND = 1000;
+export const baseURL_localStorage_key = 'remoteUrl'
+const SECOND = 1000
 
 const ajax = axios.create({
   baseURL:
@@ -10,6 +10,6 @@ const ajax = axios.create({
     localStorage.getItem(baseURL_localStorage_key) ||
     location.origin,
   timeout: 120 * SECOND,
-});
+})
 
-export default ajax;
+export default ajax

+ 0 - 266
modules/web/src/api/index.js

@@ -1,266 +0,0 @@
-import ajax from "./axios";
-import { ElMessage } from "element-plus/es";
-
-/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/api */
-/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/web */
-
-/**@type string */
-export let legado_http_entry_point = "";
-/**@type string */
-export let legado_webSocket_entry_point = "";
-
-/**
- * @param  {string|URL} http_url
- * @returns {URL}
- * @throws {Error}
- */
-export const validatorHttpUrl = (http_url) => {
-  try {
-    const url = new URL(http_url);
-    if (url.toString() === legado_http_entry_point)
-      throw new Error("Please input different url: " + legado_http_entry_point);
-    const { protocol } = url;
-    if (!protocol.startsWith("http"))
-      throw new Error("Expect http:/https: protocol but " + protocol);
-    return url;
-  } catch (e) {
-    if (localStorage.getItem("remoteUrl") == http_url) {
-      localStorage.removeItem("remoteUrl");
-      console.warn("Remove remoteUrl from localStorage");
-    }
-    throw new Error("Fail to parse Leagdo remoteUrl " + http_url, { cause: e });
-  }
-};
-/**
- * @param  {string|URL} http_url
- * @returns
- */
-export const setLeagdoHttpUrl = (http_url) => {
-  let url = new URL(location.origin); //默认当前网址的origin部分
-  try {
-    url = validatorHttpUrl(http_url);
-  } catch (e) {
-    console.warn(e);
-    console.info(
-      "setLeagdoHttpUrl: FallBack to location.origin: " + location.origin,
-    );
-  }
-  const { protocol, port } = url;
-  // websocket服务端口 为http服务端口 + 1
-  let legado_webSocket_port, legado_webSocket_protocol;
-  if (port !== "") {
-    legado_webSocket_port = String(Number(port) + 1);
-  } else {
-    legado_webSocket_port = protocol.startsWith("https:") ? "444" : "81";
-  }
-  // websocket协议是否为加密版本
-  legado_webSocket_protocol = protocol.startsWith("https:")
-    ? "wss://"
-    : "ws://";
-
-  ajax.defaults.baseURL = url.toString();
-  legado_http_entry_point = url.toString();
-
-  url.protocol = legado_webSocket_protocol;
-  url.port = legado_webSocket_port;
-  legado_webSocket_entry_point = url.toString();
-
-  console.info("legado_api_config:");
-  console.table({
-    "http API入口": legado_http_entry_point,
-    "webSocket API入口": legado_webSocket_entry_point,
-  });
-};
-
-// 手动初始化 阅读web服务地址
-setLeagdoHttpUrl(ajax.defaults.baseURL);
-/**
- * @param  {string|URL|undefined} http_url 不传为当前阅读HTTP服务接口
- * @returns
- */
-const testLeagdoHttpUrlConnection = async (http_url = legado_http_entry_point) => {
-  const { data = {} } = await ajax.get("/getReadConfig", {
-    baseURL: http_url.toString(),
-    timeout: 3000,
-  });
-  // 返回结果应该是JSON 并有键值isSuccess
-  try {
-    if ("isSuccess" in data) return data.data;
-    throw new Error("ReadConfig后端返回格式错误");
-  } catch {
-    throw new Error("ReadConfig后端返回格式错误");
-  }
-};
-
-const isSourecEditor = /source/i.test(location.href);
-const APIExceptionHandler = (error) => {
-  if (isSourecEditor) {
-    ElMessage({
-      message: "后端错误,检查网络或者阅读app",
-      type: "error",
-    });
-  }
-  throw error;
-};
-
-ajax.interceptors.response.use((response) => response, APIExceptionHandler);
-
-// 书架API
-// Http
-/** @returns {Promise<import("axios").AxiosResponse<{isSuccess: boolean, data: string, errorMsg:string}>>} */
-const getReadConfig = () => ajax.get("/getReadConfig", { timeout: 3000 });
-const saveReadConfig = (config) => ajax.post("/saveReadConfig", config);
-
-const saveBookProgress = (bookProgress) =>
-  ajax.post("/saveBookProgress", bookProgress);
-
-const saveBookProgressWithBeacon = (bookProgress) => {
-  if (!bookProgress) return;
-  // 常规请求可能会被取消 使用Fetch keep-alive 或者 navigator.sendBeacon
-  navigator.sendBeacon(
-    new URL("/saveBookProgress", legado_http_entry_point),
-    JSON.stringify(bookProgress),
-  );
-};
-
-const getBookShelf = () => ajax.get("/getBookshelf");
-
-const getChapterList = (/** @type {string} */ bookUrl) =>
-  ajax.get("/getChapterList?url=" + encodeURIComponent(bookUrl));
-
-const getBookContent = (
-  /** @type {string} */ bookUrl,
-  /** @type {number} */ chapterIndex,
-) =>
-  ajax.get(
-    "/getBookContent?url=" +
-      encodeURIComponent(bookUrl) +
-      "&index=" +
-      chapterIndex,
-  );
-
-// webSocket
-const search = (
-  /** @type {string} */ searchKey,
-  /** @type {(data: string) => void} */ onReceive,
-  /** @type {() => void} */ onFinish,
-) => {
-  const socket = new WebSocket(
-    new URL("/searchBook", legado_webSocket_entry_point),
-  );
-
-  socket.onopen = () => {
-    socket.send(`{"key":"${searchKey}"}`);
-  };
-  socket.onmessage = ({ data }) => onReceive(data);
-
-  socket.onclose = () => {
-    onFinish();
-  };
-};
-
-const saveBook = (book) => ajax.post("/saveBook", book);
-const deleteBook = (book) => ajax.post("/deleteBook", book);
-
-const isBookSource = /bookSource/i.test(location.href);
-
-// 源编辑API
-// Http
-const getSources = () =>
-  isBookSource ? ajax.get("/getBookSources") : ajax.get("/getRssSources");
-
-const saveSource = (data) =>
-  isBookSource
-    ? ajax.post("/saveBookSource", data)
-    : ajax.post("/saveRssSource", data);
-
-const saveSources = (data) =>
-  isBookSource
-    ? ajax.post("/saveBookSources", data)
-    : ajax.post("/saveRssSources", data);
-
-const deleteSource = (data) =>
-  isBookSource
-    ? ajax.post("/deleteBookSources", data)
-    : ajax.post("/deleteRssSources", data);
-
-// webSocket
-const debug = (
-  /** @type {string} */ sourceUrl,
-  /** @type {string} */ searchKey,
-  /** @type {(data: string) => void} */ onReceive,
-  /** @type {() => void} */ onFinish,
-) => {
-  const url = new URL(
-    `/${isBookSource ? "bookSource" : "rssSource"}Debug`,
-    legado_webSocket_entry_point,
-  );
-
-  const socket = new WebSocket(url);
-
-  socket.onopen = () => {
-    socket.send(JSON.stringify({ tag: sourceUrl, key: searchKey }));
-  };
-  socket.onmessage = ({ data }) => onReceive(data);
-
-  socket.onclose = () => {
-    ElMessage({
-      message: "调试已关闭!",
-      type: "info",
-    });
-    onFinish();
-  };
-};
-
-/**
- * 从阅读获取需要特定处理的书籍封面
- * @param {string} coverUrl
- */
-const getProxyCoverUrl = (coverUrl) => {
-  if (coverUrl.startsWith(legado_http_entry_point)) return coverUrl;
-  return new URL(
-    "/cover?path=" + encodeURIComponent(coverUrl),
-    legado_http_entry_point,
-  ).toString();
-};
-/**
- * 从阅读获取需要特定处理的图片
- * @param {string} src
- * @param {number|`${number}`} width
- */
-const getProxyImageUrl = (src, width) => {
-  if (src.startsWith(legado_http_entry_point)) return src;
-  return new URL(
-    "/image?path=" +
-      encodeURIComponent(src) +
-      "&url=" +
-      encodeURIComponent(sessionStorage.getItem("bookUrl")) +
-      "&width=" +
-      width,
-    legado_http_entry_point,
-  ).toString();
-};
-
-export default {
-  getReadConfig,
-  saveReadConfig,
-  saveBookProgress,
-  saveBookProgressWithBeacon,
-  getBookShelf,
-  getChapterList,
-  getBookContent,
-  search,
-  saveBook,
-  deleteBook,
-
-  getSources,
-  saveSources,
-  saveSource,
-  deleteSource,
-  debug,
-
-  getProxyCoverUrl,
-  getProxyImageUrl,
-
-  testLeagdoHttpUrlConnection,
-};

+ 96 - 0
modules/web/src/api/index.ts

@@ -0,0 +1,96 @@
+import type { AxiosResponse } from 'axios'
+import type { LeagdoApiResponse } from './api'
+import API, {
+  setWebsocketOnError,
+  legado_http_entry_point,
+  legado_webSocket_entry_point,
+  setApiEntryPoint,
+} from './api'
+import ajax from './axios'
+import { validatorHttpUrl } from '@/utils/utils'
+
+const LeagdoApiResponseKeys: string[] = Array.of('isSuccess', 'errorMsg')
+
+/** Axios.Interceptor: check if resp is LeagaoLeagdoApiResponse*/
+const responseCheckInterceptor = (resp: AxiosResponse) => {
+  let isLeagdoApiResponse = true
+  try {
+    const data = resp.data
+
+    for (const key of LeagdoApiResponseKeys) {
+      if (!(key in data)) {
+        isLeagdoApiResponse = false
+        LeagdoApiResponseKeys.length = 0
+      }
+    }
+    if ((data as LeagdoApiResponse<unknown>).isSuccess === true) {
+      if (!('data' in data)) {
+        isLeagdoApiResponse = false
+        LeagdoApiResponseKeys.length = 0
+      }
+    }
+  } catch {
+    isLeagdoApiResponse = false
+  }
+  if (isLeagdoApiResponse === false) {
+    ElNotification.warning('后端返回内容格式错误')
+    throw new Error()
+  }
+  return resp
+}
+
+const axiosErrorInterceptor = (err: unknown) => {
+  ElNotification.error('后端连接失败,请检查阅读WEB服务或者设置其它可用链接')
+  throw err
+}
+// http全局
+ajax.interceptors.response.use(responseCheckInterceptor, axiosErrorInterceptor)
+// websocket
+setWebsocketOnError(axiosErrorInterceptor)
+
+/**
+ * 按照阅读的默认规则 解析阅读HTTP WebSocket API入口地址
+ * @returns [http_url, webSocekt_url]
+ */
+export const parseLeagdoHttpUrlWithDefault = (
+  http_url: string | URL,
+): [string, string] => {
+  let url = new URL(location.origin) //默认当前网址的origin部分
+  if (validatorHttpUrl(http_url)) {
+    url = new URL(http_url)
+  }
+  const { protocol, port } = url
+  // websocket服务端口 为http服务端口 + 1
+  let legado_webSocket_port
+  if (port !== '') {
+    legado_webSocket_port = String(Number(port) + 1)
+  } else {
+    legado_webSocket_port = protocol.startsWith('https:') ? '444' : '81'
+  }
+  // websocket协议是否为加密版本
+  const legado_webSocket_protocol = protocol.startsWith('https:')
+    ? 'wss://'
+    : 'ws://'
+
+  const http_entry_point = url.toString()
+
+  url.protocol = legado_webSocket_protocol
+  url.port = legado_webSocket_port
+  const webSocket_entry_point = url.toString()
+
+  console.info('legado_api_config:')
+  console.table({
+    'http API入口': legado_http_entry_point,
+    'webSocket API入口': legado_webSocket_entry_point,
+  })
+  return [http_entry_point, webSocket_entry_point]
+}
+
+//export const useLeagdoRemoteUrlDialog = () => { }
+
+setApiEntryPoint(
+  ...parseLeagdoHttpUrlWithDefault(ajax.defaults.baseURL as string),
+)
+
+export default API
+export * from './api'

+ 10 - 10
modules/web/src/assets/bookshelf.css

@@ -1,14 +1,14 @@
 body {
-    padding: 0;
-    margin: 0;
-    height: 100vh;
+  padding: 0;
+  margin: 0;
+  height: 100vh;
 }
 
 #app {
-    font-family: "Avenir", Helvetica, Arial, sans-serif;
-    -webkit-font-smoothing: antialiased;
-    -moz-osx-font-smoothing: grayscale;
-    color: #2c3e50;
-    margin: 0;
-    height: 100%;
-}
+  font-family: 'Avenir', Helvetica, Arial, sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  color: #2c3e50;
+  margin: 0;
+  height: 100%;
+}

+ 9 - 7
modules/web/src/assets/code.css

@@ -1,7 +1,9 @@
-code {
-  border-radius: 4px;
-  padding: .15rem .5rem;
-  background-color: var(--el-fill-color-light);
-  transition: color .25s, background-color .5s;
-  font-size: 14px;
-}
+code {
+  border-radius: 4px;
+  padding: 0.15rem 0.5rem;
+  background-color: var(--el-fill-color-light);
+  transition:
+    color 0.25s,
+    background-color 0.5s;
+  font-size: 14px;
+}

+ 2 - 2
modules/web/src/assets/fonts/iconfont.css

@@ -1,5 +1,5 @@
 @charset "UTF-8";
 @font-face {
-  font-family: "iconfont";
-  src: url("./iconfont.woff") format("woff");
+  font-family: 'iconfont';
+  src: url('./iconfont.woff') format('woff');
 }

+ 2 - 2
modules/web/src/assets/fonts/popfont.css

@@ -1,7 +1,7 @@
 @charset "UTF-8";
 @font-face {
-  font-family: "FZZCYSK";
-  src: local("☺"), url("./popfont.ttf");
+  font-family: 'FZZCYSK';
+  src: local('☺'), url('./popfont.ttf');
   font-style: normal;
   font-weight: normal;
 }

+ 2 - 2
modules/web/src/assets/fonts/shelffont.css

@@ -1,7 +1,7 @@
 @charset "UTF-8";
 @font-face {
-  font-family: "FZZCYSK";
-  src: local("☺"), url("./shelffont.ttf");
+  font-family: 'FZZCYSK';
+  src: local('☺'), url('./shelffont.ttf');
   font-style: normal;
   font-weight: normal;
 }

+ 12 - 9
modules/web/src/assets/kbd.css

@@ -1,9 +1,12 @@
-kbd {
-  align-items: center;
-  background: rgba(125, 125, 125, .1);
-  border-radius: 3px;
-  border: 0;
-  padding: 4px 5px;
-  font-weight: bold;
-  box-shadow: inset 0 -2px 0 0 #cdcde6, inset 0 0 1px 1px #fff, 0 1px 2px 1px rgba(30, 35, 90, .4);
-}
+kbd {
+  align-items: center;
+  background: rgba(125, 125, 125, 0.1);
+  border-radius: 3px;
+  border: 0;
+  padding: 4px 5px;
+  font-weight: bold;
+  box-shadow:
+    inset 0 -2px 0 0 #cdcde6,
+    inset 0 0 1px 1px #fff,
+    0 1px 2px 1px rgba(30, 35, 90, 0.4);
+}

+ 13 - 13
modules/web/src/assets/sourceeditor.css

@@ -1,13 +1,13 @@
-@import './kbd.css';
-@import './code.css';
-
-body {
-    padding: 0;
-    margin: 0;
-}
-
-.el-tabs__header {
-    position: sticky;
-    top: 0px;
-    z-index: 2;
-}
+@import './kbd.css';
+@import './code.css';
+
+body {
+  padding: 0;
+  margin: 0;
+}
+
+.el-tabs__header {
+  position: sticky;
+  top: 0px;
+  z-index: 2;
+}

+ 95 - 79
modules/web/src/auto-imports.d.ts

@@ -6,88 +6,104 @@
 // biome-ignore lint: disable
 export {}
 declare global {
-  const EffectScope: typeof import('vue')['EffectScope']
-  const ElMessage: typeof import('element-plus/es')['ElMessage']
-  const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
-  const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
-  const computed: typeof import('vue')['computed']
-  const createApp: typeof import('vue')['createApp']
-  const createPinia: typeof import('pinia')['createPinia']
-  const customRef: typeof import('vue')['customRef']
-  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
-  const defineComponent: typeof import('vue')['defineComponent']
-  const defineStore: typeof import('pinia')['defineStore']
-  const effectScope: typeof import('vue')['effectScope']
-  const getActivePinia: typeof import('pinia')['getActivePinia']
-  const getCurrentInstance: typeof import('vue')['getCurrentInstance']
-  const getCurrentScope: typeof import('vue')['getCurrentScope']
-  const h: typeof import('vue')['h']
-  const inject: typeof import('vue')['inject']
-  const isProxy: typeof import('vue')['isProxy']
-  const isReactive: typeof import('vue')['isReactive']
-  const isReadonly: typeof import('vue')['isReadonly']
-  const isRef: typeof import('vue')['isRef']
-  const mapActions: typeof import('pinia')['mapActions']
-  const mapGetters: typeof import('pinia')['mapGetters']
-  const mapState: typeof import('pinia')['mapState']
-  const mapStores: typeof import('pinia')['mapStores']
-  const mapWritableState: typeof import('pinia')['mapWritableState']
-  const markRaw: typeof import('vue')['markRaw']
-  const nextTick: typeof import('vue')['nextTick']
-  const onActivated: typeof import('vue')['onActivated']
-  const onBeforeMount: typeof import('vue')['onBeforeMount']
-  const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
-  const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
-  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
-  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
-  const onDeactivated: typeof import('vue')['onDeactivated']
-  const onErrorCaptured: typeof import('vue')['onErrorCaptured']
-  const onMounted: typeof import('vue')['onMounted']
-  const onRenderTracked: typeof import('vue')['onRenderTracked']
-  const onRenderTriggered: typeof import('vue')['onRenderTriggered']
-  const onScopeDispose: typeof import('vue')['onScopeDispose']
-  const onServerPrefetch: typeof import('vue')['onServerPrefetch']
-  const onUnmounted: typeof import('vue')['onUnmounted']
-  const onUpdated: typeof import('vue')['onUpdated']
-  const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
-  const provide: typeof import('vue')['provide']
-  const reactive: typeof import('vue')['reactive']
-  const readonly: typeof import('vue')['readonly']
-  const ref: typeof import('vue')['ref']
-  const resolveComponent: typeof import('vue')['resolveComponent']
-  const setActivePinia: typeof import('pinia')['setActivePinia']
-  const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
-  const shallowReactive: typeof import('vue')['shallowReactive']
-  const shallowReadonly: typeof import('vue')['shallowReadonly']
-  const shallowRef: typeof import('vue')['shallowRef']
-  const store: typeof import('./store/index.js')['default']
-  const storeToRefs: typeof import('pinia')['storeToRefs']
-  const toRaw: typeof import('vue')['toRaw']
-  const toRef: typeof import('vue')['toRef']
-  const toRefs: typeof import('vue')['toRefs']
-  const toValue: typeof import('vue')['toValue']
-  const triggerRef: typeof import('vue')['triggerRef']
-  const unref: typeof import('vue')['unref']
-  const useAttrs: typeof import('vue')['useAttrs']
-  const useBookStore: typeof import('./store/bookStore.js')['useBookStore']
-  const useCssModule: typeof import('vue')['useCssModule']
-  const useCssVars: typeof import('vue')['useCssVars']
-  const useId: typeof import('vue')['useId']
-  const useLink: typeof import('vue-router')['useLink']
-  const useModel: typeof import('vue')['useModel']
-  const useRoute: typeof import('vue-router')['useRoute']
-  const useRouter: typeof import('vue-router')['useRouter']
-  const useSlots: typeof import('vue')['useSlots']
-  const useSourceStore: typeof import('./store/sourceStore.js')['useSourceStore']
-  const useTemplateRef: typeof import('vue')['useTemplateRef']
-  const watch: typeof import('vue')['watch']
-  const watchEffect: typeof import('vue')['watchEffect']
-  const watchPostEffect: typeof import('vue')['watchPostEffect']
-  const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+  const EffectScope: (typeof import('vue'))['EffectScope']
+  const ElMessage: (typeof import('element-plus/es'))['ElMessage']
+  const ElMessageBox: (typeof import('element-plus/es'))['ElMessageBox']
+  const ElNotification: (typeof import('element-plus/es'))['ElNotification']
+  const acceptHMRUpdate: (typeof import('pinia'))['acceptHMRUpdate']
+  const computed: (typeof import('vue'))['computed']
+  const createApp: (typeof import('vue'))['createApp']
+  const createPinia: (typeof import('pinia'))['createPinia']
+  const customRef: (typeof import('vue'))['customRef']
+  const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent']
+  const defineComponent: (typeof import('vue'))['defineComponent']
+  const defineStore: (typeof import('pinia'))['defineStore']
+  const effectScope: (typeof import('vue'))['effectScope']
+  const getActivePinia: (typeof import('pinia'))['getActivePinia']
+  const getCurrentInstance: (typeof import('vue'))['getCurrentInstance']
+  const getCurrentScope: (typeof import('vue'))['getCurrentScope']
+  const h: (typeof import('vue'))['h']
+  const inject: (typeof import('vue'))['inject']
+  const isProxy: (typeof import('vue'))['isProxy']
+  const isReactive: (typeof import('vue'))['isReactive']
+  const isReadonly: (typeof import('vue'))['isReadonly']
+  const isRef: (typeof import('vue'))['isRef']
+  const mapActions: (typeof import('pinia'))['mapActions']
+  const mapGetters: (typeof import('pinia'))['mapGetters']
+  const mapState: (typeof import('pinia'))['mapState']
+  const mapStores: (typeof import('pinia'))['mapStores']
+  const mapWritableState: (typeof import('pinia'))['mapWritableState']
+  const markRaw: (typeof import('vue'))['markRaw']
+  const nextTick: (typeof import('vue'))['nextTick']
+  const onActivated: (typeof import('vue'))['onActivated']
+  const onBeforeMount: (typeof import('vue'))['onBeforeMount']
+  const onBeforeRouteLeave: (typeof import('vue-router'))['onBeforeRouteLeave']
+  const onBeforeRouteUpdate: (typeof import('vue-router'))['onBeforeRouteUpdate']
+  const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount']
+  const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate']
+  const onDeactivated: (typeof import('vue'))['onDeactivated']
+  const onErrorCaptured: (typeof import('vue'))['onErrorCaptured']
+  const onMounted: (typeof import('vue'))['onMounted']
+  const onRenderTracked: (typeof import('vue'))['onRenderTracked']
+  const onRenderTriggered: (typeof import('vue'))['onRenderTriggered']
+  const onScopeDispose: (typeof import('vue'))['onScopeDispose']
+  const onServerPrefetch: (typeof import('vue'))['onServerPrefetch']
+  const onUnmounted: (typeof import('vue'))['onUnmounted']
+  const onUpdated: (typeof import('vue'))['onUpdated']
+  const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup']
+  const provide: (typeof import('vue'))['provide']
+  const reactive: (typeof import('vue'))['reactive']
+  const readonly: (typeof import('vue'))['readonly']
+  const ref: (typeof import('vue'))['ref']
+  const resolveComponent: (typeof import('vue'))['resolveComponent']
+  const setActivePinia: (typeof import('pinia'))['setActivePinia']
+  const setMapStoreSuffix: (typeof import('pinia'))['setMapStoreSuffix']
+  const shallowReactive: (typeof import('vue'))['shallowReactive']
+  const shallowReadonly: (typeof import('vue'))['shallowReadonly']
+  const shallowRef: (typeof import('vue'))['shallowRef']
+  const store: (typeof import('./store/index'))['default']
+  const storeToRefs: (typeof import('pinia'))['storeToRefs']
+  const toRaw: (typeof import('vue'))['toRaw']
+  const toRef: (typeof import('vue'))['toRef']
+  const toRefs: (typeof import('vue'))['toRefs']
+  const toValue: (typeof import('vue'))['toValue']
+  const triggerRef: (typeof import('vue'))['triggerRef']
+  const unref: (typeof import('vue'))['unref']
+  const useAttrs: (typeof import('vue'))['useAttrs']
+  const useBookStore: (typeof import('./store/bookStore'))['useBookStore']
+  const useCssModule: (typeof import('vue'))['useCssModule']
+  const useCssVars: (typeof import('vue'))['useCssVars']
+  const useId: (typeof import('vue'))['useId']
+  const useLink: (typeof import('vue-router'))['useLink']
+  const useModel: (typeof import('vue'))['useModel']
+  const useRoute: (typeof import('vue-router'))['useRoute']
+  const useRouter: (typeof import('vue-router'))['useRouter']
+  const useSlots: (typeof import('vue'))['useSlots']
+  const useSourceStore: (typeof import('./store/sourceStore'))['useSourceStore']
+  const useTemplateRef: (typeof import('vue'))['useTemplateRef']
+  const watch: (typeof import('vue'))['watch']
+  const watchEffect: (typeof import('vue'))['watchEffect']
+  const watchPostEffect: (typeof import('vue'))['watchPostEffect']
+  const watchSyncEffect: (typeof import('vue'))['watchSyncEffect']
 }
 // for type re-export
 declare global {
   // @ts-ignore
-  export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+  export type {
+    Component,
+    ComponentPublicInstance,
+    ComputedRef,
+    DirectiveBinding,
+    ExtractDefaultPropTypes,
+    ExtractPropTypes,
+    ExtractPublicPropTypes,
+    InjectionKey,
+    PropType,
+    Ref,
+    MaybeRef,
+    MaybeRefOrGetter,
+    VNode,
+    WritableComputedRef,
+  } from 'vue'
   import('vue')
 }

+ 109 - 0
modules/web/src/book.d.ts

@@ -0,0 +1,109 @@
+/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/data/entities */
+export type BaseBook = {
+  name: string
+  author: string
+  bookUrl: string
+  kind?: string
+  wordCount?: string
+  variable?: string
+  /** 忽略序列化
+    infoHtml?: string
+    tocHtml?: string
+    */
+}
+export type Book = BaseBook & {
+  // 目录页Url (toc=table of Contents)
+  tocUrl: string
+  // 书源URL(默认BookType.local)
+  origin: string
+  //书源名称 or 本地书籍文件名
+  originName: string
+  // 分类信息(用户修改)
+  customTag?: string
+  // 封面Url(书源获取)
+  coverUrl?: string
+  // 封面Url(用户修改)
+  customCoverUrl?: string
+  // 简介内容(书源获取)
+  intro?: string
+  // 简介内容(用户修改)
+  customnumberro?: string
+  // 自定义字符集名称(仅适用于本地书籍)
+  charset?: string
+  // 类型详见BookType
+  type: number
+  // 自定义分组索引号
+  group: number
+  // 最新章节标题
+  latestChapterTitle?: string
+  // 最新章节标题更新时间
+  latestChapterTime: number
+  // 最近一次更新书籍信息的时间
+  lastCheckTime: number
+  // 最近一次发现新章节的数量
+  lastCheckCount: number
+  // 书籍目录总数
+  totalChapterNum: number
+  // 当前章节名称
+  durChapterTitle?: string
+  // 当前章节索引
+  durChapterIndex: number
+  // 当前阅读的进度(首行字符的索引位置)
+  durChapterPos: number
+  // 最近一次阅读书籍的时间(打开正文的时间)
+  durChapterTime: number
+  // 刷新书架时更新书籍信息
+  canUpdate: boolean
+  // 手动排序
+  order: number
+  //书源排序
+  originOrder: number
+  //阅读设置
+  readConfig?: ReadConfig
+  //同步时间
+  syncTime: number
+}
+export type SeachBook = BaseBook & {
+  /** 书源 */
+  origin: string
+  originName: string
+  /** BookType */
+  type: number
+  coverUrl?: string
+  intro?: string
+  latestChapterTitle?: string
+  /** 目录页Url (toc=table of Contents) */
+  tocUrl: string
+  time: number
+  originOrder: number
+  chapterWordCountText?: string
+  chapterWordCount: number0
+  respondTime: number
+}
+export type BookProgress = Pick<
+  Book,
+  | 'name'
+  | 'author'
+  | 'durChapterIndex'
+  | 'durChapterPos'
+  | 'durChapterTime'
+  | 'durChapterTitle'
+>
+
+export type BookChapter = {
+  url: string // 章节地址
+  title: string // 章节标题
+  isVolume: boolean // 是否是卷名
+  baseUrl: string // 用来拼接相对url
+  bookUrl: string // 书籍地址
+  index: number // 章节序号
+  isVip: boolean // 是否VIP
+  isPay: boolean // 是否已购买
+  resourceUrl?: string // 音频真实URL
+  tag?: string // 更新时间或其他章节附加信息
+  start?: number // 章节起始位置
+  end?: number // 章节终止位置
+  startFragmentId?: string //EPUB书籍当前章节的fragmentId
+  endFragmentId?: string //EPUB书籍下一章节的fragmentId
+  variable?: string //变量
+}

+ 30 - 21
modules/web/src/components/BookItems.vue

@@ -10,7 +10,7 @@
         <div class="cover-img">
           <img
             class="cover"
-            :src="getCover(book.coverUrl)"
+            :src="getCover(book)"
             :key="book.coverUrl"
             @error.once="proxyImage"
             alt=""
@@ -33,15 +33,17 @@
             </div>
             <div class="update-info" v-if="!isSearch">
               <div class="dot">•</div>
-              <div class="size">共{{ book.totalChapterNum }}章</div>
+              <div class="size">共{{ (book as Book).totalChapterNum }}章</div>
               <div class="dot">•</div>
-              <div class="date">{{ dateFormat(book.lastCheckTime) }}</div>
+              <div class="date">
+                {{ dateFormat((book as Book).lastCheckTime) }}
+              </div>
             </div>
           </div>
           <div class="intro" v-if="isSearch">{{ book.intro }}</div>
 
           <div class="dur-chapter" v-if="!isSearch">
-            已读:{{ book.durChapterTitle }}
+            已读:{{ (book as Book).durChapterTitle }}
           </div>
           <div class="last-chapter">最新:{{ book.latestChapterTitle }}</div>
         </div>
@@ -49,26 +51,32 @@
     </div>
   </div>
 </template>
-<script setup>
-import { dateFormat, isLegadoUrl } from "../utils/utils";
-import API from "@api";
-
-const props = defineProps(["books", "isSearch"]);
-const emit = defineEmits(["bookClick"]);
-const handleClick = (book) => emit("bookClick", book);
-const getCover = (coverUrl) => {
-  return isLegadoUrl(coverUrl) ? API.getProxyCoverUrl(coverUrl) : coverUrl;
-};
-const proxyImage = (event) => {
-  event.target.src = API.getProxyCoverUrl(event.target.src);
-};
+<script setup lang="ts">
+import type { Book, SeachBook } from '@/book'
+import { dateFormat, isLegadoUrl } from '../utils/utils'
+import API from '@api'
+const props = defineProps<{
+  books: Array<Book | SeachBook>
+  isSearch: boolean
+}>()
+
+const emit = defineEmits(['bookClick'])
+const handleClick = (book: Book | SeachBook) => emit('bookClick', book)
+const getCover = ({ bookUrl, coverUrl }: Book | SeachBook) => {
+  if (coverUrl === undefined) return API.getProxyCoverUrl(bookUrl)
+  return isLegadoUrl(coverUrl) ? API.getProxyCoverUrl(coverUrl) : coverUrl
+}
+const proxyImage = (evt: Event) => {
+  const target = evt.target as HTMLImageElement
+  target.src = API.getProxyCoverUrl(target.src)
+}
 
 const subJustify = computed(() =>
-  props.isSearch ? "space-between" : "flex-start",
-);
+  props.isSearch ? 'space-between' : 'flex-start',
+)
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 .books-wrapper {
   overflow: auto;
 
@@ -119,7 +127,7 @@ const subJustify = computed(() =>
           display: flex;
           flex-direction: row;
           align-items: baseline;
-          justify-content: v-bind("subJustify");
+          justify-content: v-bind('subJustify');
           font-size: 12px;
           font-weight: 600;
           color: #6b6b6b;
@@ -149,6 +157,7 @@ const subJustify = computed(() =>
           display: -webkit-box;
           -webkit-box-orient: vertical;
           -webkit-line-clamp: 1;
+          line-clamp: 1;
           text-align: left;
         }
       }

+ 17 - 13
modules/web/src/components/CatalogItem.vue

@@ -11,25 +11,29 @@
     </div>
   </div>
 </template>
-<script setup>
-const props = defineProps([
-  "index",
-  "source",
-  "gotoChapter",
-  "currentChapterIndex",
-]);
+<script setup lang="ts">
+import type { BookChapter } from '@/book'
 
-const isSelected = (idx) => {
-  return idx == props.currentChapterIndex;
-};
+const props = defineProps<{
+  index: number
+  source: BookChapter | { index: number; catas: BookChapter[] }
+  gotoChapter: (chapter: BookChapter) => void
+  currentChapterIndex: number
+}>()
+
+const isSelected = (idx: number) => {
+  return idx == props.currentChapterIndex
+}
 
 // PC端 一个虚拟列表中有两个章节
 const catas = computed(() => {
-  return props.source?.catas ?? [props.source];
-});
+  const source = props.source
+  if ('catas' in source) return source.catas
+  return [props.source as BookChapter]
+})
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 .selected {
   color: #eb4259;
 }

+ 80 - 73
modules/web/src/components/ChapterContent.vue

@@ -17,116 +17,123 @@
   </div>
 </template>
 
-<script setup>
-import { isLegadoUrl } from "@/utils/utils";
-import API from "@api";
-import jump from "@/plugins/jump";
+<script setup lang="ts">
+import { isLegadoUrl } from '@/utils/utils'
+import API from '@api'
+import jump from '@/plugins/jump'
+import type { webReadConfig } from '@/web'
 
-const props = defineProps({
-  chapterIndex: { type: Number, required: true },
-  contents: { type: Array, required: true },
-  title: { type: String, required: true },
-  spacing: { type: Object, required: true },
-  fontFamily: { type: String, required: true },
-  fontSize: { type: String, required: true },
-});
+const store = useBookStore()
+const readWidth = computed(() => store.config.readWidth)
+const bookUrl = computed(() => store.readingBook.bookUrl)
+const props = defineProps<{
+  chapterIndex: number
+  contents: Array<string>
+  title: string
+  spacing: webReadConfig['spacing']
+  fontFamily: string
+  fontSize: string
+}>()
 
-const getImageSrc = (content) => {
-  const imgPattern = /<img[^>]*src="([^"]*(?:"[^>]+\})?)"[^>]*>/;
-  const src = content.match(imgPattern)[1];
+const getImageSrc = (content: string) => {
+  const imgPattern = /<img[^>]*src="([^"]*(?:"[^>]+\})?)"[^>]*>/
+  const src = content.match(imgPattern)![1] //reg tested in template
   if (isLegadoUrl(src))
-    return API.getProxyImageUrl(src, useBookStore().config.readWidth);
-  return src;
-};
-const proxyImage = (event) => {
-  event.target.src = API.getProxyImageUrl(
-    event.target.src,
-    useBookStore().config.readWidth,
-  );
-};
+    return API.getProxyImageUrl(
+      bookUrl.value,
+      src,
+      useBookStore().config.readWidth,
+    )
+  return src
+}
+const proxyImage = (event: Event) => {
+  ;(event.target as HTMLImageElement).src = API.getProxyImageUrl(
+    bookUrl.value,
+    (event.target as HTMLImageElement).src,
+    readWidth.value,
+  )
+}
 
-const calculateWordCount = (paragraph) => {
-  const imgPattern = /<img[^>]*src="[^"]*(?:"[^>]+\})?"[^>]*>/g;
+const calculateWordCount = (paragraph: string) => {
+  const imgPattern = /<img[^>]*src="[^"]*(?:"[^>]+\})?"[^>]*>/g
   //内嵌图片文字为1
-  const imagePlaceHolder = " ";
-  return paragraph.replaceAll(imgPattern, imagePlaceHolder).length;
-};
+  const imagePlaceHolder = ' '
+  return paragraph.replaceAll(imgPattern, imagePlaceHolder).length
+}
 const chapterPos = computed(() => {
-  let pos = -1;
-  return Array.from(props.contents, (content) => {
-    pos += calculateWordCount(content) + 1; //计算上一段的换行符
-    return pos;
-  });
-});
+  let pos = -1
+  return Array.from(props.contents, content => {
+    pos += calculateWordCount(content) + 1 //计算上一段的换行符
+    return pos
+  })
+})
 
-const titleRef = ref();
-const paragraphRef = ref();
-const scrollToReadedLength = (length) => {
-  if (length === 0) return;
-  let paragraphIndex = chapterPos.value.findIndex(
-    (wordCount) => wordCount >= length,
-  );
-  if (paragraphIndex === -1) return;
+const titleRef = ref<HTMLElement>()
+const paragraphRef = ref<HTMLParagraphElement[]>()
+const scrollToReadedLength = (length: number) => {
+  if (length === 0) return
+  const paragraphIndex = chapterPos.value.findIndex(
+    wordCount => wordCount >= length,
+  )
+  if (paragraphIndex === -1) return
   nextTick(() => {
-    jump(paragraphRef.value[paragraphIndex], {
+    jump(paragraphRef.value![paragraphIndex], {
       duration: 0,
-    });
-  });
-};
+    })
+  })
+}
 defineExpose({
   scrollToReadedLength,
-});
-let intersectionObserver = null;
-const emit = defineEmits(["readedLengthChange"]);
+})
+let intersectionObserver: IntersectionObserver | null = null
+const emit = defineEmits(['readedLengthChange'])
 onMounted(() => {
   intersectionObserver = new IntersectionObserver(
-    (entries) => {
-      for (let { target, isIntersecting } of entries) {
+    entries => {
+      for (const { target, isIntersecting } of entries) {
         if (isIntersecting) {
           emit(
-            "readedLengthChange",
+            'readedLengthChange',
             props.chapterIndex,
-            // @ts-ignore
-            parseInt(target.dataset.chapterpos),
-          );
+            parseInt((target as HTMLElement).dataset.chapterpos as string),
+          )
         }
       }
     },
     {
       rootMargin: `0px 0px -${window.innerHeight - 24}px 0px`,
     },
-  );
-  intersectionObserver.observe(titleRef.value);
-  paragraphRef.value.forEach((element) => {
-    intersectionObserver.observe(element);
-  });
-});
+  )
+  intersectionObserver.observe(titleRef.value!)
+  paragraphRef.value!.forEach(element => {
+    intersectionObserver!.observe(element)
+  })
+})
 
 onUnmounted(() => {
-  intersectionObserver?.disconnect();
-  intersectionObserver = null;
-});
+  intersectionObserver?.disconnect()
+  intersectionObserver = null
+})
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 .title {
   margin-bottom: 57px;
   font:
     24px / 32px PingFangSC-Regular,
     HelveticaNeue-Light,
-    "Helvetica Neue Light",
-    "Microsoft YaHei",
+    'Helvetica Neue Light',
+    'Microsoft YaHei',
     sans-serif;
 }
 
 p {
   display: block;
   word-wrap: break-word;
-  // word-break: break-all;
-
-  letter-spacing: calc(v-bind("props.spacing.letter") * 1em);
-  line-height: calc(1 + v-bind("props.spacing.line"));
-  margin: calc(v-bind("props.spacing.paragraph") * 1em) 0;
+  /*   word-break: break-all; */
+  letter-spacing: calc(v-bind('props.spacing.letter') * 1em);
+  line-height: calc(1 + v-bind('props.spacing.line'));
+  margin: calc(v-bind('props.spacing.paragraph') * 1em) 0;
 
   :deep(img) {
     height: 1em;

+ 49 - 45
modules/web/src/components/PopCatalog.vue

@@ -19,81 +19,85 @@
   </div>
 </template>
 
-<script setup>
-import VirtualList from "vue3-virtual-scroll-list";
-import settings from "../config/themeConfig";
-import "../assets/fonts/popfont.css";
-import CatalogItem from "./CatalogItem.vue";
+<script setup lang="ts">
+import VirtualList from 'vue3-virtual-scroll-list'
+import settings from '../config/themeConfig'
+import '../assets/fonts/popfont.css'
+import CatalogItem from './CatalogItem.vue'
+import type { BookChapter } from '@/book'
 
-const store = useBookStore();
+const store = useBookStore()
 
-const { catalog, popCataVisible, miniInterface } = storeToRefs(store);
+const { catalog, popCataVisible, miniInterface } = storeToRefs(store)
 
 //主题
-const isNight = computed(() => store.theme);
-const theme = computed(() => store.theme);
+const isNight = computed(() => store.theme)
+const theme = computed(() => store.theme)
 const popupTheme = computed(() => {
   return {
     background: settings.themes[theme.value].popup,
-  };
-});
+  }
+})
 
 //虚拟列表 数据源
 const virtualListdata = computed(() => {
-  let catalogValue = catalog.value;
-  if (miniInterface.value) return catalogValue;
+  const catalogValue = catalog.value
+  if (miniInterface.value) return catalogValue
 
   // pc端 virtualListIitem有2个章节
-  let length = Math.ceil(catalogValue.length / 2);
-  let virtualListDataSource = new Array(length);
+  const length = Math.ceil(catalogValue.length / 2)
+  const virtualListDataSource = new Array<{
+    index: number
+    catas: BookChapter[]
+  }>(length)
 
-  let i = 0;
+  let i = 0
   while (i < length) {
     virtualListDataSource[i] = {
       index: i,
       catas: catalogValue.slice(2 * i, 2 * i + 2),
-    };
-    i++;
+    }
+    i++
   }
-  return virtualListDataSource;
-});
+  return virtualListDataSource
+})
 
 //打开目录 计算当前章节对应的虚拟列表位置
-const virtualListRef = ref();
+const virtualListRef = ref()
 const currentChapterIndex = computed({
-  get: () => store.readingBook.index,
-  set: (value) => (store.readingBook.index = value),
-});
+  get: () => store.readingBook.chapterIndex,
+  set: value => (store.readingBook.chapterIndex = value),
+})
 const virtualListIndex = computed(() => {
-  let index = currentChapterIndex.value;
-  if (miniInterface.value) return index;
+  const index = currentChapterIndex.value
+  if (miniInterface.value) return index
   // pc端 virtualListIitem有2个章节
-  return Math.floor(index / 2);
-});
+  return Math.floor(index / 2)
+})
 onUpdated(() => {
   // dom更新触发ResizeObserver,更新虚拟列表内部的sizes Map
-  if (!popCataVisible.value) return;
-  virtualListRef.value.scrollToIndex(virtualListIndex.value);
-});
+  if (!popCataVisible.value) return
+  virtualListRef.value.scrollToIndex(virtualListIndex.value)
+})
 
 // 点击加载对应章节内容
-const emit = defineEmits(["getContent"]);
-const gotoChapter = (note) => {
-  const chapterIndex = catalog.value.indexOf(note);
-  currentChapterIndex.value = chapterIndex;
-  store.setPopCataVisible(false);
-  store.setContentLoading(true);
-  store.saveBookProgress();
-  emit("getContent", chapterIndex);
-};
+const emit = defineEmits(['getContent'])
+const gotoChapter = (chapter: BookChapter) => {
+  const chapterIndex = catalog.value.indexOf(chapter)
+  currentChapterIndex.value = chapterIndex
+  store.setPopCataVisible(false)
+  store.setContentLoading(true)
+  store.saveBookProgress()
+  emit('getContent', chapterIndex)
+}
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 .cata-wrapper {
   margin: -16px;
   padding: 18px 0 24px 25px;
 
-  // background: #ede7da url('../assets/imgs/themes/popup_1.png') repeat;
+  /* background: #ede7da url('../assets/imgs/themes/popup_1.png') repeat; */
   .title {
     font-size: 18px;
     font-weight: 400;
@@ -105,14 +109,14 @@ const gotoChapter = (note) => {
   }
   :deep(.data-wrapper) {
     .cata {
-      //width: 50%;
+      /*width: 50%;*/
       height: 40px;
       cursor: pointer;
       font:
         16px / 40px PingFangSC-Regular,
         HelveticaNeue-Light,
-        "Helvetica Neue Light",
-        "Microsoft YaHei",
+        'Helvetica Neue Light',
+        'Microsoft YaHei',
         sans-serif;
     }
   }

+ 117 - 131
modules/web/src/components/ReadSettings.vue

@@ -62,19 +62,10 @@
               <el-button
                 type="primary"
                 size="small"
-                @click="
-                  setCustomFont();
-                  customFontSavePopVisible = false;
-                "
+                @click="setCustomFont(), (customFontSavePopVisible = false)"
                 >确定</el-button
               >
-              <el-button
-                type="primary"
-                size="small"
-                @click="
-                  loadFontFromURL();
-                  customFontSavePopVisible = false;
-                "
+              <el-button type="primary" size="small" @click="loadFontFromURL()"
                 >网络下载</el-button
               >
             </div>
@@ -183,197 +174,192 @@
   </div>
 </template>
 
-<script setup>
-import "../assets/fonts/popfont.css";
-import "../assets/fonts/iconfont.css";
-import settings from "../config/themeConfig";
-import API from "@api";
-
-const store = useBookStore();
-
+<script setup lang="ts">
+import '../assets/fonts/popfont.css'
+import '../assets/fonts/iconfont.css'
+import settings from '../config/themeConfig'
+import API from '@api'
+import { useDebounceFn } from '@vueuse/shared'
+
+const store = useBookStore()
+const saveConfigDebounce = useDebounceFn(
+  () => API.saveReadConfig(store.config),
+  500,
+)
 //阅读界面设置改变时保存同步配置
-let configChanged = false;
 watch(
   () => store.config,
-  (newValue) => {
-    localStorage.setItem("config", JSON.stringify(newValue));
-    configChanged = true;
+  () => {
+    saveConfigDebounce()
   },
   {
     deep: 2, //深度为2
   },
-);
-// 设置页面关闭时同步设置到阅读APP
-watch(
-  () => store.readSettingsVisible,
-  (visbile) => {
-    if (!visbile && configChanged)
-      API.saveReadConfig(store.config).then(() => (configChanged = false));
-  },
-);
+)
 
 //主题颜色
-const theme = computed(() => store.theme);
-const isNight = computed(() => store.isNight);
-const moonIcon = computed(() => (theme.value == 6 ? "" : ""));
+const theme = computed(() => store.theme)
+const isNight = computed(() => store.isNight)
+const moonIcon = computed(() => (theme.value == 6 ? '' : ''))
 const themeColors = [
   {
-    background: "rgba(250, 245, 235, 0.8)",
+    background: 'rgba(250, 245, 235, 0.8)',
   },
   {
-    background: "rgba(245, 234, 204, 0.8)",
+    background: 'rgba(245, 234, 204, 0.8)',
   },
   {
-    background: "rgba(230, 242, 230, 0.8)",
+    background: 'rgba(230, 242, 230, 0.8)',
   },
   {
-    background: "rgba(228, 241, 245, 0.8)",
+    background: 'rgba(228, 241, 245, 0.8)',
   },
   {
-    background: "rgba(245, 228, 228, 0.8)",
+    background: 'rgba(245, 228, 228, 0.8)',
   },
   {
-    background: "rgba(224, 224, 224, 0.8)",
+    background: 'rgba(224, 224, 224, 0.8)',
   },
   {
-    background: "rgba(0, 0, 0, 0.5)",
+    background: 'rgba(0, 0, 0, 0.5)',
   },
-];
+]
 const popupTheme = computed(() => {
   return {
     background: settings.themes[theme.value].popup,
-  };
-});
-const setTheme = (theme) => {
-  store.config.theme = theme;
-};
+  }
+})
+const setTheme = (theme: number) => {
+  store.config.theme = theme
+}
 
 //预置字体
-const fonts = ref(["雅黑", "宋体", "楷书"]);
-const setFont = (font) => {
-  store.config.font = font;
-};
+const fonts = ref(['雅黑', '宋体', '楷书'])
+const setFont = (font: number) => {
+  store.config.font = font
+}
 const selectedFont = computed(() => {
-  return store.config.font;
-});
+  return store.config.font
+})
 //自定义字体
-const customFontName = ref(store.config.customFontName);
-const customFontSavePopVisible = ref(false);
+const customFontName = ref(store.config.customFontName)
+const customFontSavePopVisible = ref(false)
 const setCustomFont = () => {
-  store.config.font = -1;
-  store.config.customFontName = customFontName.value;
-};
+  customFontSavePopVisible.value = false
+  store.config.font = -1
+  store.config.customFontName = customFontName.value
+}
 // 加载网络字体
 const loadFontFromURL = () => {
-  ElMessageBox.prompt("请输入 字体网络链接", "提示", {
-    confirmButtonText: "确定",
-    cancelButtonText: "取消",
+  customFontSavePopVisible.value = false
+  ElMessageBox.prompt('请输入 字体网络链接', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
     inputPattern: /^https?:.+$/,
-    inputErrorMessage: "url 形式不正确",
+    inputErrorMessage: 'url 形式不正确',
     beforeClose: (action, instance, done) => {
-      if (action === "confirm") {
-        instance.confirmButtonLoading = true;
-        instance.confirmButtonText = "下载中……";
+      if (action === 'confirm') {
+        instance.confirmButtonLoading = true
+        instance.confirmButtonText = '下载中……'
         // instance.inputValue
-        const url = instance.inputValue;
-        if (typeof FontFace !== "function") {
-          ElMessage.error("浏览器不支持FontFace");
-          return done();
+        const url = instance.inputValue
+        if (typeof FontFace !== 'function') {
+          ElMessage.error('浏览器不支持FontFace')
+          return done()
         }
-        const fontface = new FontFace(customFontName.value, `url("${url}")`);
-        //@ts-ignore
-        document.fonts.add(fontface);
+        const fontface = new FontFace(customFontName.value, `url("${url}")`)
+        document.fonts.add(fontface)
         fontface
           .load()
           //API.getBookShelf()
           .then(function () {
-            instance.confirmButtonLoading = false;
-            ElMessage.info("字体加载成功!");
-            setCustomFont();
-            done();
+            instance.confirmButtonLoading = false
+            ElMessage.info('字体加载成功!')
+            setCustomFont()
+            done()
           })
           .catch(function (error) {
-            instance.confirmButtonLoading = false;
-            instance.confirmButtonText = "确定";
-            ElMessage.error("下载失败,请检查您输入的 url");
-            throw error;
-          });
+            instance.confirmButtonLoading = false
+            instance.confirmButtonText = '确定'
+            ElMessage.error('下载失败,请检查您输入的 url')
+            throw error
+          })
       } else {
-        done();
+        done()
       }
     },
-  });
-};
+  })
+}
 
 //字体大小
 const fontSize = computed(() => {
-  return store.config.fontSize;
-});
+  return store.config.fontSize
+})
 const moreFontSize = () => {
-  if (store.config.fontSize < 48) store.config.fontSize += 2;
-};
+  if (store.config.fontSize < 48) store.config.fontSize += 2
+}
 const lessFontSize = () => {
-  if (store.config.fontSize > 12) store.config.fontSize -= 2;
-};
+  if (store.config.fontSize > 12) store.config.fontSize -= 2
+}
 
 //字 行 段落间距
 const spacing = computed(() => {
-  return store.config.spacing;
-});
+  return store.config.spacing
+})
 const lessLetterSpacing = () => {
-  store.config.spacing.letter -= 0.01;
-};
+  store.config.spacing.letter -= 0.01
+}
 const moreLetterSpacing = () => {
-  store.config.spacing.letter += 0.01;
-};
+  store.config.spacing.letter += 0.01
+}
 const lessLineSpacing = () => {
-  store.config.spacing.line -= 0.1;
-};
+  store.config.spacing.line -= 0.1
+}
 const moreLineSpacing = () => {
-  store.config.spacing.line += 0.1;
-};
+  store.config.spacing.line += 0.1
+}
 const lessParagraphSpacing = () => {
-  store.config.spacing.paragraph -= 0.1;
-};
+  store.config.spacing.paragraph -= 0.1
+}
 const moreParagraphSpacing = () => {
-  store.config.spacing.paragraph += 0.1;
-};
+  store.config.spacing.paragraph += 0.1
+}
 
 //页面宽度
 const readWidth = computed(() => {
-  return store.config.readWidth;
-});
+  return store.config.readWidth
+})
 const moreReadWidth = () => {
   // 此时会截断页面
-  if (store.config.readWidth + 160 + 2 * 68 > window.innerWidth) return;
-  store.config.readWidth += 160;
-};
+  if (store.config.readWidth + 160 + 2 * 68 > window.innerWidth) return
+  store.config.readWidth += 160
+}
 const lessReadWidth = () => {
-  if (store.config.readWidth > 640) store.config.readWidth -= 160;
-};
+  if (store.config.readWidth > 640) store.config.readWidth -= 160
+}
 
 //翻页速度
 const jumpDuration = computed(() => {
-  return store.config.jumpDuration;
-});
+  return store.config.jumpDuration
+})
 const moreJumpDuration = () => {
-  store.config.jumpDuration += 100;
-};
+  store.config.jumpDuration += 100
+}
 const lessJumpDuration = () => {
-  if (store.config.jumpDuration === 0) return;
-  store.config.jumpDuration -= 100;
-};
+  if (store.config.jumpDuration === 0) return
+  store.config.jumpDuration -= 100
+}
 
 //无限加载
 const infiniteLoading = computed(() => {
-  return store.config.infiniteLoading;
-});
-const setInfiniteLoading = (loading) => {
-  store.config.infiniteLoading = loading;
-};
+  return store.config.infiniteLoading
+})
+const setInfiniteLoading = (loading: boolean) => {
+  store.config.infiniteLoading = loading
+}
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 :deep(.iconfont) {
   font-family: iconfont;
   font-style: normal;
@@ -387,11 +373,11 @@ const setInfiniteLoading = (loading) => {
 .settings-wrapper {
   user-select: none;
   margin: -13px;
-  // width: 478px;
-  // height: 350px;
+  /*   width: 478px;
+  height: 350px; */
   text-align: left;
   padding: 40px 0 40px 24px;
-  background: #ede7da url("../assets/imgs/themes/popup_1.png") repeat;
+  background: #ede7da url('../assets/imgs/themes/popup_1.png') repeat;
 
   .settings-title {
     font-size: 18px;
@@ -416,7 +402,7 @@ const setInfiniteLoading = (loading) => {
         i {
           font:
             12px / 16px PingFangSC-Regular,
-            "-apple-system",
+            '-apple-system',
             Simsun;
           display: inline-block;
           min-width: 48px;
@@ -468,8 +454,8 @@ const setInfiniteLoading = (loading) => {
           font:
             14px / 34px PingFangSC-Regular,
             HelveticaNeue-Light,
-            "Helvetica Neue Light",
-            "Microsoft YaHei",
+            'Helvetica Neue Light',
+            'Microsoft YaHei',
             sans-serif;
         }
         .font-item-input {

+ 22 - 22
modules/web/src/components/SourceDebug.vue

@@ -18,49 +18,49 @@
   />
 </template>
 
-<script setup>
-import API from "@api";
-import { Search } from "@element-plus/icons-vue";
+<script setup lang="ts">
+import API from '@api'
+import { Search } from '@element-plus/icons-vue'
 
-const store = useSourceStore();
+const store = useSourceStore()
 
-const printDebug = ref("");
-const searchKey = ref("");
+const printDebug = ref('')
+const searchKey = ref('')
 
 watch(
   () => store.isDebuging,
   () => {
-    if (store.isDebuging) startDebug();
+    if (store.isDebuging) startDebug()
   },
-);
+)
 
-const appendDebugMsg = (msg) => {
-  let debugDom = document.querySelector("#debug-text");
-  debugDom.scrollTop = debugDom.scrollHeight;
-  printDebug.value += msg + "\n";
-};
+const appendDebugMsg = (msg: string) => {
+  const debugDom = document.querySelector('#debug-text')
+  debugDom!.scrollTop = debugDom!.scrollHeight
+  printDebug.value += msg + '\n'
+}
 const startDebug = async () => {
-  printDebug.value = "";
+  printDebug.value = ''
   try {
-    await API.saveSource(store.currentSource);
+    await API.saveSource(store.currentSource)
   } catch (e) {
-    store.debugFinish();
-    throw e;
+    store.debugFinish()
+    throw e
   }
   API.debug(
     store.currentSourceUrl,
     searchKey.value || store.searchKey,
     appendDebugMsg,
     store.debugFinish,
-  );
-};
+  )
+}
 
 const isBookSource = computed(() => {
-  return /bookSource/i.test(window.location.href);
-});
+  return /bookSource/i.test(window.location.href)
+})
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 :deep(#debug-text) {
   height: calc(100vh - 45px - 36px - 5px);
 }

+ 3 - 3
modules/web/src/components/SourceHelp.vue

@@ -1,5 +1,5 @@
-<script setup>
-import { Link } from "@element-plus/icons-vue";
+<script setup lang="ts">
+import { Link } from '@element-plus/icons-vue'
 </script>
 <template>
   <el-link :icon="Link" href="/help/#appHelp" target="_blank"
@@ -53,7 +53,7 @@ import { Link } from "@element-plus/icons-vue";
   </div>
 </template>
 
-<style lang="scss" scoped>
+<style scoped>
 .el-link {
   padding: 4px;
 }

+ 19 - 16
modules/web/src/components/SourceItem.vue

@@ -8,32 +8,35 @@
       edit: sourceUrl == currentSourceUrl,
     }"
   >
-    {{ source.bookSourceName || source.sourceName }}
+    {{ getSourceName(source) }}
     <el-button text :icon="Edit" @click="handleSourceClick(source)" />
   </el-checkbox>
 </template>
 
-<script setup>
-import { Edit } from "@element-plus/icons-vue";
-import { getSourceUniqueKey } from "@/utils/souce";
+<script setup lang="ts">
+import { Edit } from '@element-plus/icons-vue'
+import { getSourceUniqueKey, getSourceName } from '@/utils/souce'
+import type { Source } from '@/source'
 
-const props = defineProps(["source"]);
+const props = defineProps<{
+  source: Source
+}>()
 
-const store = useSourceStore();
+const store = useSourceStore()
 
-const currentSourceUrl = computed(() => store.currentSourceUrl);
-const sourceUrl = computed(() => getSourceUniqueKey(props.source));
+const currentSourceUrl = computed(() => store.currentSourceUrl)
+const sourceUrl = computed(() => getSourceUniqueKey(props.source))
 
-const handleSourceClick = (source) => {
-  store.changeCurrentSource(source);
-};
+const handleSourceClick = (source: Source) => {
+  store.changeCurrentSource(source)
+}
 const isSaveError = computed(() => {
-  const map = store.savedSourcesMap;
-  if (map.size == 0) return false;
-  return !map.has(sourceUrl.value);
-});
+  const map = store.savedSourcesMap
+  if (map.size == 0) return false
+  return !map.has(sourceUrl.value)
+})
 </script>
-<style lang="scss" scoped>
+<style scoped>
 :deep(.el-checkbox__label) {
   flex: 1;
   display: flex;

+ 14 - 14
modules/web/src/components/SourceJson.vue

@@ -9,30 +9,30 @@
     style="margin-bottom: 4px"
   ></el-input>
 </template>
-<script setup>
-import { useSourceStore } from "@/store";
+<script setup lang="ts">
+import { useSourceStore } from '@/store'
 
-const store = useSourceStore();
-const sourceString = ref("");
-const update = async (string) => {
+const store = useSourceStore()
+const sourceString = ref('')
+const update = async (string: string) => {
   try {
-    store.changeEditTabSource(JSON.parse(string));
+    store.changeEditTabSource(JSON.parse(string))
   } catch {
     ElMessage({
-      message: "粘贴的源格式错误",
-      type: "error",
-    });
+      message: '粘贴的源格式错误',
+      type: 'error',
+    })
   }
-};
+}
 
 watchEffect(async () => {
-  let source = store.editTabSource;
+  const source = store.editTabSource
   if (Object.keys(source).length > 0) {
-    sourceString.value = JSON.stringify(source, null, 4);
+    sourceString.value = JSON.stringify(source, null, 4)
   } else {
-    sourceString.value = "";
+    sourceString.value = ''
   }
-});
+})
 </script>
 <style scoped>
 :deep(.el-input) {

+ 75 - 81
modules/web/src/components/SourceList.vue

@@ -32,7 +32,7 @@
   <el-checkbox-group id="source-list" v-model="sourceUrlSelect">
     <virtual-list
       style="height: 100%; overflow-y: auto; overflow-x: hidden"
-      :data-key="(source) => source.bookSourceUrl || source.sourceUrl"
+      :data-key="(source: Source) => getSourceName(source)"
       :data-sources="sourcesFiltered"
       :data-component="SourceItem"
       :estimate-size="45"
@@ -40,116 +40,110 @@
   </el-checkbox-group>
 </template>
 
-<script setup>
-import API from "@api";
-import { Folder, Delete, Download, Search } from "@element-plus/icons-vue";
+<script setup lang="ts">
+import API from '@api'
+import { Folder, Delete, Download, Search } from '@element-plus/icons-vue'
 import {
   isSourceMatches,
   getSourceUniqueKey,
+  getSourceName,
   convertSourcesToMap,
-} from "@utils/souce";
-import VirtualList from "vue3-virtual-scroll-list";
-import SourceItem from "./SourceItem.vue";
+} from '@utils/souce'
+import VirtualList from 'vue3-virtual-scroll-list'
+import SourceItem from './SourceItem.vue'
+import type { Source } from '@/source'
 
-const store = useSourceStore();
-const sourceUrlSelect = ref([]);
-const searchKey = ref("");
-const sources = computed(() => store.sources);
+const store = useSourceStore()
+const sourceUrlSelect = ref<string[]>([])
+const searchKey = ref('')
+const sources = computed(() => store.sources)
 
-// 筛选源
-/** @type Ref<import('@/source').Source[]> */
-const sourcesFiltered = computed(() => {
-  const key = searchKey.value;
-  if (key === "") return sources.value;
-  return (
-    sources.value
-      // @ts-ignore
-      .filter((source) => isSourceMatches(source, key))
-  );
-});
+/* 筛选源 */
+const sourcesFiltered = computed<Source[]>(() => {
+  const key = searchKey.value
+  if (key === '') return sources.value
+  return sources.value.filter(source => isSourceMatches(source, key))
+})
 // 计算当前筛选关键词下的选中源
-/** @type Ref<import('@/source').Source[]> */
-const sourceSelect = computed(() => {
-  const urls = sourceUrlSelect.value;
-  if (urls.length == 0) return [];
+const sourceSelect = computed<Source[]>(() => {
+  const urls = sourceUrlSelect.value
+  if (urls.length == 0) return []
   const sourcesFilteredMap =
-    searchKey.value == ""
+    searchKey.value == ''
       ? store.sourcesMap
-      : convertSourcesToMap(sourcesFiltered.value);
+      : convertSourcesToMap(sourcesFiltered.value)
   return urls.reduce((sources, sourceUrl) => {
-    const source = sourcesFilteredMap.get(sourceUrl);
-    if (source) sources.push(source);
-    return sources;
-  }, []);
-});
+    const source = sourcesFilteredMap.get(sourceUrl)
+    if (source) sources.push(source)
+    return sources
+  }, [] as Source[])
+})
 
 const deleteSelectSources = () => {
-  const sourceSelectValue = sourceSelect.value;
+  const sourceSelectValue = sourceSelect.value
   API.deleteSource(sourceSelectValue).then(({ data }) => {
-    if (!data.isSuccess) return ElMessage.error(data.errorMsg);
-    store.deleteSources(sourceSelectValue);
-    const sourceUrlSelectRawValue = toRaw(sourceUrlSelect.value);
-    sourceSelectValue.forEach((source) => {
-      const index = sourceUrlSelectRawValue.indexOf(getSourceUniqueKey(source));
-      if (index > -1) sourceUrlSelectRawValue.splice(index, 1);
-    });
-    sourceUrlSelect.value = sourceUrlSelectRawValue;
-  });
-};
+    if (!data.isSuccess) return ElMessage.error(data.errorMsg)
+    store.deleteSources(sourceSelectValue)
+    const sourceUrlSelectRawValue = toRaw(sourceUrlSelect.value)
+    sourceSelectValue.forEach(source => {
+      const index = sourceUrlSelectRawValue.indexOf(getSourceUniqueKey(source))
+      if (index > -1) sourceUrlSelectRawValue.splice(index, 1)
+    })
+    sourceUrlSelect.value = sourceUrlSelectRawValue
+  })
+}
 const clearAllSources = () => {
-  store.clearAllSource();
-  sourceUrlSelect.value = [];
-};
+  store.clearAllSource()
+  sourceUrlSelect.value = []
+}
 
 //导入本地文件
 const importSourceFile = () => {
-  const input = document.createElement("input");
-  input.type = "file";
-  input.accept = ".json,.txt";
-  input.addEventListener("change", (e) => {
-    // @ts-ignore
-    const file = e.target.files[0];
-    const reader = new FileReader();
-    reader.readAsText(file);
+  const input = document.createElement('input')
+  input.type = 'file'
+  input.accept = '.json,.txt'
+  input.addEventListener('change', () => {
+    const files = input.files
+    if (files === null) {
+      return ElMessage.info('未选择文件')
+    }
+    const reader = new FileReader()
+    reader.readAsText(files[0])
     reader.onload = () => {
       try {
-        // @ts-ignore
-        const jsonData = JSON.parse(reader.result);
-        store.saveSources(jsonData);
-      } catch {
-        ElMessage({
-          message: "上传的源格式错误",
-          type: "error",
-        });
+        const jsonData = JSON.parse(reader.result as string)
+        store.saveSources(jsonData)
+      } catch (e: unknown) {
+        ElMessage.error('上传的源格式错误: ' + (e as Error).message)
       }
-    };
-  });
-  input.click();
-};
+    }
+  })
+  input.click()
+}
 
-const isBookSource = /bookSource/i.test(window.location.href);
+const isBookSource = /bookSource/i.test(window.location.href)
 const outExport = () => {
-  const exportFile = document.createElement("a");
-  let sources =
+  const exportFile = document.createElement('a')
+  const sources =
       sourceUrlSelect.value.length === 0
         ? sourcesFiltered.value
         : sourceSelect.value,
-    sourceType = isBookSource ? "BookSource" : "RssSource";
+    sourceType = isBookSource ? 'BookSource' : 'RssSource'
 
   exportFile.download = `${sourceType}_${Date()
-    .replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/, "$2$1$3")
-    .replace(/:/g, "")}.json`;
+    .replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/, '$2$1$3')
+    .replace(/:/g, '')}.json`
 
-  let myBlob = new Blob([JSON.stringify(sources, null, 4)], {
-    type: "application/json",
-  });
-  exportFile.href = window.URL.createObjectURL(myBlob);
-  exportFile.click();
-  window.URL.revokeObjectURL(exportFile.href); //avoid memory leak
-};
+  const myBlob = new Blob([JSON.stringify(sources, null, 4)], {
+    type: 'application/json',
+  })
+  exportFile.href = window.URL.createObjectURL(myBlob)
+  exportFile.click()
+  window.URL.revokeObjectURL(exportFile.href) //avoid memory leak
+}
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 .tool {
   display: flex;
   margin: 4px 0;

+ 19 - 11
modules/web/src/components/SourceTabForm.vue

@@ -14,7 +14,7 @@
             id,
             array,
             hint,
-            required,
+            required = false,
           } in children"
           :label="title"
           :key="title"
@@ -35,20 +35,26 @@
             autosize
           />
 
-          <el-switch v-if="type == 'Boolean'" v-model="currentSource[id]" />
+          <el-switch
+            v-if="(type as string) === 'Boolean'"
+            v-model="currentSource[id]"
+          />
 
           <el-input-number
-            v-if="type == 'Number'"
+            v-if="(type as string) === 'Number'"
             v-model="currentSource[id]"
             :min="0"
           />
 
-          <el-select v-if="type == 'Array'" v-model="currentSource[id]">
+          <el-select
+            v-if="(type as string) === 'Array'"
+            v-model="currentSource[id]"
+          >
             <el-option
-              v-for="(name, index) in array"
+              v-for="(optionName, index) in array"
               :value="index"
-              :key="name"
-              :label="name"
+              :key="optionName"
+              :label="optionName"
             />
           </el-select>
         </el-form-item>
@@ -57,11 +63,13 @@
   </el-tabs>
 </template>
 
-<script setup>
-const store = useSourceStore();
-defineProps(["config"]);
+<script setup lang="ts">
+import type { SourceConfig } from '@/config/sourceConfig'
+
+const store = useSourceStore()
+defineProps<{ config: SourceConfig }>()
 
-const currentSource = computed(() => store.currentSource);
+const currentSource = computed(() => store.currentSource)
 /* 
 修改currentSource的属性 没有直接修改本身
 const { currentSource } = storeToRefs(store);

+ 10 - 10
modules/web/src/components/SourceTabTools.vue

@@ -14,22 +14,22 @@
   </el-tabs>
 </template>
 
-<script setup>
-import { useSourceStore } from "@/store";
+<script setup lang="ts">
+import { useSourceStore } from '@/store'
 
-const store = useSourceStore();
+const store = useSourceStore()
 
 const current_tab = computed({
   get: () => store.currentTab,
-  set: (val) => (store.currentTab = val),
-});
+  set: val => (store.currentTab = val),
+})
 
 const tabData = ref([
-  ["editTab", "编辑源"],
-  ["editDebug", "调试源"],
-  ["editList", "源列表"],
-  ["editHelp", "帮助信息"],
-]);
+  ['editTab', '编辑源'],
+  ['editDebug', '调试源'],
+  ['editList', '源列表'],
+  ['editHelp', '帮助信息'],
+])
 </script>
 
 <style lang="scss" scoped>

+ 142 - 145
modules/web/src/components/ToolBar.vue

@@ -36,7 +36,7 @@
 
     <div class="hotkeys-settings flex-column-center">
       <div
-        v-for="(button, index) in buttons"
+        v-for="(button, buttonIndex) in buttons"
         :key="button.name"
         class="hotkeys-item flex-space-between"
       >
@@ -44,9 +44,9 @@
           ><el-text>{{ button.name }}</el-text></span
         >
         <div class="hotkeys-item__content">
-          <div v-for="(key, index) in button.hotKeys" :key="key">
+          <div v-for="(key, hotKeysIndex) in button.hotKeys" :key="key">
             <kbd>{{ key }}</kbd>
-            <span v-if="index + 1 < button.hotKeys.length">
+            <span v-if="hotKeysIndex + 1 < button.hotKeys.length">
               <el-text>+</el-text>
             </span>
           </div>
@@ -56,7 +56,7 @@
           :disabled="recordKeyDowning"
           text
           :icon="Edit"
-          @click="recordKeyDown(index)"
+          @click="recordKeyDown(buttonIndex)"
           >编辑</el-button
         >
       </div>
@@ -64,59 +64,59 @@
   </el-dialog>
 </template>
 
-<script setup>
-import API from "@api";
-import { CircleCheckFilled, Edit } from "@element-plus/icons-vue";
-import hotkeys from "hotkeys-js";
-import { isInvaildSource } from "../utils/souce";
+<script setup lang="ts">
+import API from '@api'
+import { CircleCheckFilled, Edit } from '@element-plus/icons-vue'
+import hotkeys from 'hotkeys-js'
+import { getSourceName, isInvaildSource } from '../utils/souce'
 
-const store = useSourceStore();
+const store = useSourceStore()
 const pull = () => {
   const loadingMsg = ElMessage({
-    message: "加载中……",
+    message: '加载中……',
     showClose: true,
     duration: 0,
-  });
+  })
   API.getSources()
     .then(({ data }) => {
       if (data.isSuccess) {
-        store.changeTabName("editList");
-        store.saveSources(data.data);
+        store.changeTabName('editList')
+        store.saveSources(data.data)
         ElMessage({
           message: `成功拉取${data.data.length}条源`,
-          type: "success",
-        });
+          type: 'success',
+        })
       } else {
         ElMessage({
-          message: data.errorMsg ?? "后端错误",
-          type: "error",
-        });
+          message: data.errorMsg ?? '后端错误',
+          type: 'error',
+        })
       }
     })
-    .finally(() => loadingMsg.close());
-};
+    .finally(() => loadingMsg.close())
+}
 
 const push = () => {
-  let sources = store.sources;
-  store.changeTabName("editList");
+  const sources = store.sources
+  store.changeTabName('editList')
   if (sources.length === 0) {
     return ElMessage({
-      message: "空空如也",
-      type: "info",
-    });
+      message: '空空如也',
+      type: 'info',
+    })
   }
   ElMessage({
-    message: "正在推送中",
-    type: "info",
-  });
+    message: '正在推送中',
+    type: 'info',
+  })
   API.saveSources(sources).then(({ data }) => {
     if (data.isSuccess) {
-      let okData = data.data;
+      const okData = data.data
       if (Array.isArray(okData)) {
-        let failMsg = ``;
+        let failMsg = ``
         if (sources.length > okData.length) {
-          failMsg = "\n推送失败的源将用红色字体标注!";
-          store.setPushReturnSources(okData);
+          failMsg = '\n推送失败的源将用红色字体标注!'
+          store.setPushReturnSources(okData)
         }
         ElMessage({
           message: `批量推送源到「阅读3.0APP」\n共计: ${
@@ -124,168 +124,163 @@ const push = () => {
           } 条\n成功: ${okData.length} 条\n失败: ${
             sources.length - okData.length
           } 条${failMsg}`,
-          type: "success",
-        });
+          type: 'success',
+        })
       }
     } else {
       ElMessage({
         message: `批量推送源失败!\nErrorMsg: ${data.errorMsg}`,
-        type: "error",
-      });
+        type: 'error',
+      })
     }
-  });
-};
+  })
+}
 
 const conver2Tab = () => {
-  store.changeTabName("editTab");
-  store.changeEditTabSource(store.currentSource);
-};
+  store.changeTabName('editTab')
+  store.changeEditTabSource(store.currentSource)
+}
 const conver2Source = () => {
-  store.changeCurrentSource(store.editTabSource);
-};
+  store.changeCurrentSource(store.editTabSource)
+}
 
 const undo = () => {
-  store.editHistoryUndo();
-};
+  store.editHistoryUndo()
+}
 
 const clearEdit = () => {
-  store.clearEdit();
+  store.clearEdit()
   ElMessage({
-    message: "已清除",
-    type: "success",
-  });
-};
+    message: '已清除',
+    type: 'success',
+  })
+}
 
 const redo = () => {
-  store.clearEdit();
-  store.clearAllHistory();
+  store.clearEdit()
+  store.clearAllHistory()
   ElMessage({
-    message: "已清除所有历史记录",
-    type: "success",
-  });
-};
+    message: '已清除所有历史记录',
+    type: 'success',
+  })
+}
 
 const saveSource = () => {
-  let isBookSource = /bookSource/i.test(location.href),
-    /** @type {import("@/source.js").Source} */
-    source = store.currentSource;
+  const source = store.currentSource
   if (isInvaildSource(source)) {
     API.saveSource(source).then(({ data }) => {
+      const sourceName = getSourceName(source)
       if (data.isSuccess) {
         ElMessage({
-          message: `源《${
-            isBookSource ? source.bookSourceName : source.sourceName
-          }》已成功保存到「阅读3.0APP」`,
-          type: "success",
-        });
+          message: `源《${sourceName}》已成功保存到「阅读3.0APP」`,
+          type: 'success',
+        })
         //save to store
-        store.saveCurrentSource();
+        store.saveCurrentSource()
       } else {
         ElMessage({
-          message: `源《${
-            isBookSource ? source.bookSourceName : source.sourceName
-          }》保存失败!\nErrorMsg: ${data.errorMsg}`,
-          type: "error",
-        });
+          message: `源《${sourceName}》保存失败!\nErrorMsg: ${data.errorMsg}`,
+          type: 'error',
+        })
       }
-    });
+    })
   } else {
     ElMessage({
       message: `请检查<必填>项是否全部填写`,
-      type: "error",
-    });
+      type: 'error',
+    })
   }
-};
+}
 
 const debug = () => {
-  store.startDebug();
-};
+  store.startDebug()
+}
 
-const buttons = ref(
+const buttons = ref<{ name: string; hotKeys: string[]; action: () => void }[]>(
   Array.of(
-    { name: "⇈推送源", hotKeys: [], action: push },
-    { name: "⇊拉取源", hotKeys: [], action: pull },
-    { name: "⋙生成源", hotKeys: [], action: conver2Tab },
-    { name: "⋘编辑源", hotKeys: [], action: conver2Source },
-    { name: "✗清空表单", hotKeys: [], action: clearEdit },
-    { name: "↶撤销操作", hotKeys: [], action: undo },
-    { name: "↷重做操作", hotKeys: [], action: redo },
-    { name: "⇏调试源", hotKeys: [], action: debug },
-    { name: "✓保存源", hotKeys: [], action: saveSource },
+    { name: '⇈推送源', hotKeys: [], action: push },
+    { name: '⇊拉取源', hotKeys: [], action: pull },
+    { name: '⋙生成源', hotKeys: [], action: conver2Tab },
+    { name: '⋘编辑源', hotKeys: [], action: conver2Source },
+    { name: '✗清空表单', hotKeys: [], action: clearEdit },
+    { name: '↶撤销操作', hotKeys: [], action: undo },
+    { name: '↷重做操作', hotKeys: [], action: redo },
+    { name: '⇏调试源', hotKeys: [], action: debug },
+    { name: '✓保存源', hotKeys: [], action: saveSource },
   ),
-);
-const hotkeysDialogVisible = ref(true);
+)
+const hotkeysDialogVisible = ref(true)
 
-const recordKeyDowning = ref(false);
+const recordKeyDowning = ref(false)
 
-const recordKeyDownIndex = ref(-1);
+const recordKeyDownIndex = ref(-1)
 
 const stopRecordKeyDown = () => {
   if (!recordKeyDowning.value) {
-    hotkeysDialogVisible.value = false;
+    hotkeysDialogVisible.value = false
   }
-  recordKeyDowning.value = false;
-};
+  recordKeyDowning.value = false
+}
 
 watch(
   hotkeysDialogVisible,
-  (visibale) => {
+  visibale => {
     if (!visibale) {
-      hotkeys.unbind("*");
-      readHotkeysConfig();
-      bindHotKeys();
-      return;
+      hotkeys.unbind('*')
+      readHotkeysConfig()
+      bindHotKeys()
+      return
     }
-    readHotkeysConfig();
-    hotkeys.unbind();
+    readHotkeysConfig()
+    hotkeys.unbind()
     /**监听按键 */
-    hotkeys("*", (event) => {
-      event.preventDefault();
-      let pressedKeys = hotkeys.getPressedKeyString();
-      if (pressedKeys.length == 1 && pressedKeys[0] == "esc") {
+    hotkeys('*', event => {
+      event.preventDefault()
+      const pressedKeys = hotkeys.getPressedKeyString()
+      if (pressedKeys.length == 1 && pressedKeys[0] == 'esc') {
         //单独按下esc 不录入
-        return;
+        return
       }
       if (recordKeyDowning.value && recordKeyDownIndex.value > -1)
-        buttons.value[recordKeyDownIndex.value].hotKeys = pressedKeys;
-    });
+        buttons.value[recordKeyDownIndex.value].hotKeys = pressedKeys
+    })
   },
   { immediate: true },
-);
+)
 
-const recordKeyDown = (index) => {
-  recordKeyDowning.value = true;
+const recordKeyDown = (index: number) => {
+  recordKeyDowning.value = true
   ElMessage({
-    message: "按ESC键或者点击空白处结束录入",
-    type: "info",
-  });
-  buttons.value[index].hotKeys = [];
-  recordKeyDownIndex.value = index;
-};
+    message: '按ESC键或者点击空白处结束录入',
+    type: 'info',
+  })
+  buttons.value[index].hotKeys = []
+  recordKeyDownIndex.value = index
+}
 
 const saveHotKeys = () => {
-  const hotKeysConfig = [];
+  const hotKeysConfig: string[][] = []
   buttons.value.forEach(({ hotKeys }) => {
-    hotKeysConfig.push(hotKeys);
-  });
-  saveHotkeysConfig(hotKeysConfig);
-  hotkeysDialogVisible.value = false;
-};
+    hotKeysConfig.push(hotKeys)
+  })
+  saveHotkeysConfig(hotKeysConfig)
+  hotkeysDialogVisible.value = false
+}
 
 const bindHotKeys = () => {
   // hotkeys默认过滤INPUT SELECT TEXTAREA
-  hotkeys.filter = () => true;
+  hotkeys.filter = () => true
   buttons.value.forEach(({ hotKeys, action }) => {
-    if (hotKeys.length == 0) return;
-    hotkeys(hotKeys.join("+"), (event) => {
-      event.preventDefault();
-      action.call(null);
-    });
-  });
-};
-const saveHotkeysConfig = (config) => {
-  localStorage.setItem("legado_web_hotkeys", JSON.stringify(config));
-};
+    if (hotKeys.length == 0) return
+    hotkeys(hotKeys.join('+'), event => {
+      event.preventDefault()
+      action.call(null)
+    })
+  })
+}
+const saveHotkeysConfig = (config: string[][]) => {
+  localStorage.setItem('legado_web_hotkeys', JSON.stringify(config))
+}
 
 /**
  * 读取快捷键配置
@@ -293,26 +288,28 @@ const saveHotkeysConfig = (config) => {
  */
 function readHotkeysConfig() {
   try {
-    const config = JSON.parse(localStorage.getItem("legado_web_hotkeys"));
-    if (!Array.isArray(config) || config.length == 0) return false;
-    buttons.value.forEach((button, index) => (button.hotKeys = config[index]));
-    return true;
+    const localStorageConfig = localStorage.getItem('legado_web_hotkeys')
+    if (localStorageConfig === null) return false
+    const config = JSON.parse(localStorageConfig)
+    if (!Array.isArray(config) || config.length == 0) return false
+    buttons.value.forEach((button, index) => (button.hotKeys = config[index]))
+    return true
   } catch {
-    ElMessage({ message: "快捷键配置错误", type: "error" });
-    localStorage.removeItem("legado_web_hotkeys");
+    ElMessage({ message: '快捷键配置错误', type: 'error' })
+    localStorage.removeItem('legado_web_hotkeys')
   }
-  return false;
+  return false
 }
 
 onMounted(() => {
   /**读取热键配置 */
   if (readHotkeysConfig()) {
-    hotkeysDialogVisible.value = false;
+    hotkeysDialogVisible.value = false
   }
-});
+})
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 .flex-space-between {
   display: flex;
   justify-content: space-between;
@@ -337,7 +334,7 @@ onMounted(() => {
     justify-content: flex-end;
     margin-right: 1em;
   }
-  &__content {
+  .hotkeys-item__content {
     display: flex;
     flex-wrap: wrap;
     flex: 1;

+ 0 - 585
modules/web/src/config/bookSourceEditConfig.js

@@ -1,585 +0,0 @@
-export default {
-  base: {
-    name: "基础",
-    children: [
-      {
-        title: "源类型",
-        id: "bookSourceType",
-        type: "Array",
-        array: ["文本", "音频", "图片", "文件"],
-        required: true,
-      },
-      {
-        title: "源域名",
-        id: "bookSourceUrl",
-        type: "String",
-        hint: "通常填写网站主页,例: https://www.qidian.com",
-        required: true,
-      },
-      {
-        title: "源名称",
-        id: "bookSourceName",
-        type: "String",
-        hint: "会显示在源列表",
-        required: true,
-      },
-      {
-        title: "源分组",
-        id: "bookSourceGroup",
-        type: "String",
-        hint: "描述源的特征信息",
-      },
-      {
-        title: "源注释",
-        id: "bookSourceComment",
-        type: "String",
-        hint: "描述源作者和状态",
-      },
-      {
-        title: "登录地址",
-        id: "loginUrl",
-        type: "String",
-        hint: "填写网站登录网址,仅在需要登录的源有用",
-      },
-      {
-        title: "登录界面",
-        id: "loginUi",
-        type: "String",
-        hint: "自定义登录界面",
-      },
-      {
-        title: "登录检测",
-        id: "loginCheckJs",
-        type: "String",
-        hint: "登录检测js",
-      },
-      {
-        title: "封面解密",
-        id: "coverDecodeJs",
-        type: "String",
-        hint: "封面解密js",
-      },
-      {
-        title: "链接验证",
-        id: "bookUrlPattern",
-        type: "String",
-        hint: "书籍URL正则,当详情页URL与源URL的域名不一致时有效,用于添加网址",
-      },
-      {
-        title: "请求头",
-        id: "header",
-        type: "String",
-        hint: "客户端标识",
-      },
-      {
-        title: "变量说明",
-        id: "variableComment",
-        type: "String",
-        hint: "书源变量说明",
-      },
-      {
-        title: "并发率",
-        id: "concurrentRate",
-        type: "String",
-        hint: "并发率,如1000(访问间隔1000ms)或者1/1000(1000ms内访问1次)",
-      },
-      {
-        title: "js库",
-        id: "jsLib",
-        type: "String",
-        hint: "js库, 可填写js或者key-value object获取在线js文件",
-      },
-    ],
-  },
-  search: {
-    name: "搜索",
-    children: [
-      {
-        title: "搜索地址",
-        id: "searchUrl",
-        type: "String",
-        hint: "[域名可省略]/search.php@kw={{key}}",
-      },
-      {
-        title: "校验文字",
-        namespace: "ruleSearch",
-        id: "checkKeyWord",
-        type: "String",
-        hint: "校验关键字,强烈建议填写",
-      },
-      {
-        title: "列表规则",
-        namespace: "ruleSearch",
-        id: "bookList",
-        type: "String",
-        hint: "选择书籍节点 (规则结果为List<Element>)",
-      },
-      {
-        title: "书名规则",
-        namespace: "ruleSearch",
-        id: "name",
-        type: "String",
-        hint: "选择节点书名 (规则结果为String)",
-      },
-      {
-        title: "作者规则",
-        namespace: "ruleSearch",
-        id: "author",
-        type: "String",
-        hint: "选择节点作者 (规则结果为String)",
-      },
-      {
-        title: "分类规则",
-        namespace: "ruleSearch",
-        id: "kind",
-        type: "String",
-        hint: "选择节点分类信息 (规则结果为String)",
-      },
-      {
-        title: "字数规则",
-        namespace: "ruleSearch",
-        id: "wordCount",
-        type: "String",
-        hint: "选择节点字数信息 (规则结果为String)",
-      },
-      {
-        title: "最新章节",
-        namespace: "ruleSearch",
-        id: "lastChapter",
-        type: "String",
-        hint: "选择节点最新章节 (规则结果为String)",
-      },
-      {
-        title: "简介规则",
-        namespace: "ruleSearch",
-        id: "intro",
-        type: "String",
-        hint: "选择节点书籍简介 (规则结果为String)",
-      },
-      {
-        title: "封面规则",
-        namespace: "ruleSearch",
-        id: "coverUrl",
-        type: "String",
-        hint: "选择节点书籍封面 (规则结果为String类型的url)",
-      },
-      {
-        title: "详情地址",
-        namespace: "ruleSearch",
-        id: "bookUrl",
-        type: "String",
-        hint: "选择书籍详情页网址 (规则结果为String类型的url)",
-      },
-    ],
-  },
-  find: {
-    name: "发现",
-    children: [
-      {
-        title: "发现地址",
-        id: "exploreUrl",
-        type: "String",
-        hint: "单个发现格式<name>::<url>或者{url:<url>,title:<name>,style:...};前者用换行符或者&&连接,后者放在数组内;可用js动态生成",
-      },
-      {
-        title: "列表规则",
-        namespace: "ruleExplore",
-        id: "bookList",
-        type: "String",
-        hint: "选择书籍节点 (规则结果为List<Element>)",
-      },
-      {
-        title: "书名规则",
-        namespace: "ruleExplore",
-        id: "name",
-        type: "String",
-        hint: "选择节点书名 (规则结果为String)",
-      },
-      {
-        title: "作者规则",
-        namespace: "ruleExplore",
-        id: "author",
-        type: "String",
-        hint: "选择节点作者 (规则结果为String)",
-      },
-      {
-        title: "分类规则",
-        namespace: "ruleExplore",
-        id: "kind",
-        type: "String",
-        hint: "选择节点分类信息 (规则结果为String)",
-      },
-      {
-        title: "字数规则",
-        namespace: "ruleExplore",
-        id: "wordCount",
-        type: "String",
-        hint: "选择节点字数信息 (规则结果为String)",
-      },
-      {
-        title: "最新章节",
-        namespace: "ruleExplore",
-        id: "lastChapter",
-        type: "String",
-        hint: "选择节点最新章节 (规则结果为String)",
-      },
-      {
-        title: "简介规则",
-        namespace: "ruleExplore",
-        id: "intro",
-        type: "String",
-        hint: "选择节点书籍简介 (规则结果为String)",
-      },
-      {
-        title: "封面规则",
-        namespace: "ruleExplore",
-        id: "coverUrl",
-        type: "String",
-        hint: "选择节点书籍封面 (规则结果为String类型的url)",
-      },
-      {
-        title: "详情地址",
-        namespace: "ruleExplore",
-        id: "bookUrl",
-        type: "String",
-        hint: "选择书籍详情页网址 (规则结果为String类型的url)",
-      },
-    ],
-  },
-  detail: {
-    name: "详情",
-    children: [
-      {
-        title: "预处理",
-        namespace: "ruleBookInfo",
-        id: "init",
-        type: "String",
-        hint: "用于加速详情信息检索,只支持AllInOne规则",
-      },
-      {
-        title: "书名规则",
-        namespace: "ruleBookInfo",
-        id: "name",
-        type: "String",
-        hint: "选择节点书名 (规则结果为String)",
-      },
-      {
-        title: "作者规则",
-        namespace: "ruleBookInfo",
-        id: "author",
-        type: "String",
-        hint: "选择节点作者 (规则结果为String)",
-      },
-      {
-        title: "分类规则",
-        namespace: "ruleBookInfo",
-        id: "kind",
-        type: "String",
-        hint: "选择节点分类信息 (规则结果为String)",
-      },
-      {
-        title: "字数规则",
-        namespace: "ruleBookInfo",
-        id: "wordCount",
-        type: "String",
-        hint: "选择节点字数信息 (规则结果为String)",
-      },
-      {
-        title: "最新章节",
-        namespace: "ruleBookInfo",
-        id: "lastChapter",
-        type: "String",
-        hint: "选择节点最新章节 (规则结果为String)",
-      },
-      {
-        title: "简介规则",
-        namespace: "ruleBookInfo",
-        id: "intro",
-        type: "String",
-        hint: "选择节点书籍简介 (规则结果为String)",
-      },
-      {
-        title: "封面规则",
-        namespace: "ruleBookInfo",
-        id: "coverUrl",
-        type: "String",
-        hint: "选择节点书籍封面 (规则结果为String类型的url)",
-      },
-      {
-        title: "目录地址",
-        namespace: "ruleBookInfo",
-        id: "tocUrl",
-        type: "String",
-        hint: "选择书籍详情页网址 (规则结果为String类型的url, 与详情页相同时可省略)",
-      },
-      {
-        title: "修改书籍",
-        namespace: "ruleBookInfo",
-        id: "canReName",
-        type: "String",
-        hint: "允许修改书名作者(规则结果为String类型, 默认不允许)",
-      },
-      {
-        title: "下载URL",
-        namespace: "ruleBookInfo",
-        id: "downloadUrls",
-        type: "String",
-        hint: "文件类书源下载地址 (规则结果为String类型的url, 多个链接返回数组)",
-      },
-    ],
-  },
-  directory: {
-    name: "目录",
-    children: [
-      {
-        title: "更新前JS",
-        namespace: "ruleToc",
-        id: "preUpdateJs",
-        type: "String",
-        hint: "更新目录前调用JS 动态更新目录链接",
-      },
-      {
-        title: "列表规则",
-        namespace: "ruleToc",
-        id: "chapterList",
-        type: "String",
-        hint: "选择目录列表的章节节点 (规则结果为List<Element>)",
-      },
-      {
-        title: "章节名称",
-        namespace: "ruleToc",
-        id: "chapterName",
-        type: "String",
-        hint: "选择章节名称 (规则结果为String)",
-      },
-      {
-        title: "章节地址",
-        namespace: "ruleToc",
-        id: "chapterUrl",
-        type: "String",
-        hint: "选择章节链接 (规则结果为String类型的Url)",
-      },
-      {
-        title: "标题处理",
-        namespace: "ruleToc",
-        id: "formatJs",
-        type: "String",
-        hint: "遍历去重后的章节列表的回调,提供index(章节序号从1开始)、title(章节标题)变量,额外提供gInt(初始值0),返回值作为新的标题",
-      },
-      {
-        title: "卷名标识",
-        namespace: "ruleToc",
-        id: "isVolume",
-        type: "String",
-        hint: "章节名称是否是卷名 (规则结果为Bool)",
-      },
-      {
-        title: "章节信息",
-        namespace: "ruleToc",
-        id: "updateTime",
-        type: "String",
-        hint: "选择章节信息(如更新时间) (规则结果为String)",
-      },
-      {
-        title: "收费标识",
-        namespace: "ruleToc",
-        id: "isVip",
-        type: "String",
-        hint: "章节是否为VIP章节 (规则结果为Bool)",
-      },
-      {
-        title: "购买标识",
-        namespace: "ruleToc",
-        id: "isPay",
-        type: "String",
-        hint: "章节是否为已购买 (规则结果为Bool)",
-      },
-      {
-        title: "翻页规则",
-        namespace: "ruleToc",
-        id: "nextTocUrl",
-        type: "String",
-        hint: "选择目录下一页链接 (规则结果为List<String>或String)",
-      },
-    ],
-  },
-  content: {
-    name: "正文",
-    children: [
-      {
-        title: "正文规则",
-        namespace: "ruleContent",
-        id: "content",
-        type: "String",
-        hint: "选择正文内容 (规则结果为String)",
-      },
-      {
-        title: "标题规则",
-        namespace: "ruleContent",
-        id: "title",
-        type: "String",
-        hint: "获取结果将会覆盖章节标题 (规则结果为String)",
-      },
-      {
-        title: "翻页规则",
-        namespace: "ruleContent",
-        id: "nextContentUrl",
-        type: "String",
-        hint: "选择下一分页(不是下一章)链接 (规则结果为String类型的Url)",
-      },
-      {
-        title: "脚本注入",
-        namespace: "ruleContent",
-        id: "webJs",
-        type: "String",
-        hint: "注入javascript,用于模拟鼠标点击等,必须有返回值,一般为String类型",
-      },
-      {
-        title: "资源正则",
-        namespace: "ruleContent",
-        id: "sourceRegex",
-        type: "String",
-        hint: "匹配资源的url特征,用于嗅探",
-      },
-      {
-        title: "替换规则",
-        namespace: "ruleContent",
-        id: "replaceRegex",
-        type: "String",
-        hint: "多页内容合并后替换,用于正文净化",
-      },
-      {
-        title: "图片样式",
-        namespace: "ruleContent",
-        id: "imageStyle",
-        type: "String",
-        hint: "FULL:铺满 不填:默认样式",
-      },
-      {
-        title: "图片解密",
-        namespace: "ruleContent",
-        id: "imageDecode",
-        type: "String",
-        hint: "填写JavaScript 返回解密图片的bytes ",
-      },
-      {
-        title: "购买操作",
-        namespace: "ruleContent",
-        id: "payAction",
-        type: "String",
-        hint: "填写JavaScript 返回购买链接或者调用购买接口",
-      },
-    ],
-  },
-  /*
-  review: {
-    name: "段评",
-    children: [
-      {
-        title: "段评URL",
-        namespace: "ruleReview",
-id: "reviewUrl",
-        type: "String",
-        hint: "段评URL",
-      },
-      {
-        title: "发布头像",
-        namespace: "ruleReview",
-id: "avatarRule",
-        type: "String",
-        hint: "段评发布者头像",
-      },
-      {
-        title: "段评内容",
-        namespace: "ruleReview",
-id: "contentRule",
-        type: "String",
-        hint: "段评内容",
-      },
-      {
-        title: "发布时间",
-        namespace: "ruleReview",
-id: "postTimeRule",
-        type: "String",
-        hint: "段评发布时间",
-      },
-      {
-        title: "回复URL",
-        namespace: "ruleReview",
-id: "reviewQuoteUrl",
-        type: "String",
-        hint: "获取段评回复URL",
-      },
-      {
-        title: "点赞URL",
-        namespace: "ruleReview",
-id: "voteUpUrl",
-        type: "String",
-        hint: "点赞URL",
-      },
-      {
-        title: "点踩URL",
-        namespace: "ruleReview",
-id: "voteDownUrl",
-        type: "String",
-        hint: "点踩URL",
-      },
-      {
-        title: "发送回复",
-        namespace: "ruleReview",
-id: "postReviewUrl",
-        type: "String",
-        hint: "发送回复URL",
-      },
-      {
-        title: "回复段评",
-        namespace: "ruleReview",
-id: "postQuoteUrl",
-        type: "String",
-        hint: "发送回复段评URL",
-      },
-      {
-        title: "删除段评",
-        namespace: "ruleReview",
-id: "deleteUrl",
-        type: "String",
-        hint: "删除段评URL",
-      },
-    ],
-  },*/
-  other: {
-    name: "其他",
-    children: [
-      {
-        title: "启用搜索",
-        id: "enabled",
-        type: "Boolean",
-      },
-      {
-        title: "启用发现",
-        id: "enabledExplore",
-        type: "Boolean",
-      },
-      // {
-      //   title: "启用段评",
-      //   id: "enabledReview",
-      //   type: "Boolean",
-      // },
-      {
-        title: "CookieJar",
-        id: "enabledCookieJar",
-        type: "Boolean",
-      },
-      {
-        title: "搜索权重",
-        id: "weight",
-        type: "Number",
-      },
-      {
-        title: "排序编号",
-        id: "customOrder",
-        type: "Number",
-      },
-    ],
-  },
-};

+ 585 - 0
modules/web/src/config/bookSourceEditConfig.ts

@@ -0,0 +1,585 @@
+export default {
+  base: {
+    name: '基础',
+    children: [
+      {
+        title: '源类型',
+        id: 'bookSourceType',
+        type: 'Array',
+        array: ['文本', '音频', '图片', '文件'],
+        required: true,
+      },
+      {
+        title: '源域名',
+        id: 'bookSourceUrl',
+        type: 'String',
+        hint: '通常填写网站主页,例: https://www.qidian.com',
+        required: true,
+      },
+      {
+        title: '源名称',
+        id: 'bookSourceName',
+        type: 'String',
+        hint: '会显示在源列表',
+        required: true,
+      },
+      {
+        title: '源分组',
+        id: 'bookSourceGroup',
+        type: 'String',
+        hint: '描述源的特征信息',
+      },
+      {
+        title: '源注释',
+        id: 'bookSourceComment',
+        type: 'String',
+        hint: '描述源作者和状态',
+      },
+      {
+        title: '登录地址',
+        id: 'loginUrl',
+        type: 'String',
+        hint: '填写网站登录网址,仅在需要登录的源有用',
+      },
+      {
+        title: '登录界面',
+        id: 'loginUi',
+        type: 'String',
+        hint: '自定义登录界面',
+      },
+      {
+        title: '登录检测',
+        id: 'loginCheckJs',
+        type: 'String',
+        hint: '登录检测js',
+      },
+      {
+        title: '封面解密',
+        id: 'coverDecodeJs',
+        type: 'String',
+        hint: '封面解密js',
+      },
+      {
+        title: '链接验证',
+        id: 'bookUrlPattern',
+        type: 'String',
+        hint: '书籍URL正则,当详情页URL与源URL的域名不一致时有效,用于添加网址',
+      },
+      {
+        title: '请求头',
+        id: 'header',
+        type: 'String',
+        hint: '客户端标识',
+      },
+      {
+        title: '变量说明',
+        id: 'variableComment',
+        type: 'String',
+        hint: '书源变量说明',
+      },
+      {
+        title: '并发率',
+        id: 'concurrentRate',
+        type: 'String',
+        hint: '并发率,如1000(访问间隔1000ms)或者1/1000(1000ms内访问1次)',
+      },
+      {
+        title: 'js库',
+        id: 'jsLib',
+        type: 'String',
+        hint: 'js库, 可填写js或者key-value object获取在线js文件',
+      },
+    ],
+  },
+  search: {
+    name: '搜索',
+    children: [
+      {
+        title: '搜索地址',
+        id: 'searchUrl',
+        type: 'String',
+        hint: '[域名可省略]/search.php@kw={{key}}',
+      },
+      {
+        title: '校验文字',
+        namespace: 'ruleSearch',
+        id: 'checkKeyWord',
+        type: 'String',
+        hint: '校验关键字,强烈建议填写',
+      },
+      {
+        title: '列表规则',
+        namespace: 'ruleSearch',
+        id: 'bookList',
+        type: 'String',
+        hint: '选择书籍节点 (规则结果为List<Element>)',
+      },
+      {
+        title: '书名规则',
+        namespace: 'ruleSearch',
+        id: 'name',
+        type: 'String',
+        hint: '选择节点书名 (规则结果为String)',
+      },
+      {
+        title: '作者规则',
+        namespace: 'ruleSearch',
+        id: 'author',
+        type: 'String',
+        hint: '选择节点作者 (规则结果为String)',
+      },
+      {
+        title: '分类规则',
+        namespace: 'ruleSearch',
+        id: 'kind',
+        type: 'String',
+        hint: '选择节点分类信息 (规则结果为String)',
+      },
+      {
+        title: '字数规则',
+        namespace: 'ruleSearch',
+        id: 'wordCount',
+        type: 'String',
+        hint: '选择节点字数信息 (规则结果为String)',
+      },
+      {
+        title: '最新章节',
+        namespace: 'ruleSearch',
+        id: 'lastChapter',
+        type: 'String',
+        hint: '选择节点最新章节 (规则结果为String)',
+      },
+      {
+        title: '简介规则',
+        namespace: 'ruleSearch',
+        id: 'intro',
+        type: 'String',
+        hint: '选择节点书籍简介 (规则结果为String)',
+      },
+      {
+        title: '封面规则',
+        namespace: 'ruleSearch',
+        id: 'coverUrl',
+        type: 'String',
+        hint: '选择节点书籍封面 (规则结果为String类型的url)',
+      },
+      {
+        title: '详情地址',
+        namespace: 'ruleSearch',
+        id: 'bookUrl',
+        type: 'String',
+        hint: '选择书籍详情页网址 (规则结果为String类型的url)',
+      },
+    ],
+  },
+  find: {
+    name: '发现',
+    children: [
+      {
+        title: '发现地址',
+        id: 'exploreUrl',
+        type: 'String',
+        hint: '单个发现格式<name>::<url>或者{url:<url>,title:<name>,style:...};前者用换行符或者&&连接,后者放在数组内;可用js动态生成',
+      },
+      {
+        title: '列表规则',
+        namespace: 'ruleExplore',
+        id: 'bookList',
+        type: 'String',
+        hint: '选择书籍节点 (规则结果为List<Element>)',
+      },
+      {
+        title: '书名规则',
+        namespace: 'ruleExplore',
+        id: 'name',
+        type: 'String',
+        hint: '选择节点书名 (规则结果为String)',
+      },
+      {
+        title: '作者规则',
+        namespace: 'ruleExplore',
+        id: 'author',
+        type: 'String',
+        hint: '选择节点作者 (规则结果为String)',
+      },
+      {
+        title: '分类规则',
+        namespace: 'ruleExplore',
+        id: 'kind',
+        type: 'String',
+        hint: '选择节点分类信息 (规则结果为String)',
+      },
+      {
+        title: '字数规则',
+        namespace: 'ruleExplore',
+        id: 'wordCount',
+        type: 'String',
+        hint: '选择节点字数信息 (规则结果为String)',
+      },
+      {
+        title: '最新章节',
+        namespace: 'ruleExplore',
+        id: 'lastChapter',
+        type: 'String',
+        hint: '选择节点最新章节 (规则结果为String)',
+      },
+      {
+        title: '简介规则',
+        namespace: 'ruleExplore',
+        id: 'intro',
+        type: 'String',
+        hint: '选择节点书籍简介 (规则结果为String)',
+      },
+      {
+        title: '封面规则',
+        namespace: 'ruleExplore',
+        id: 'coverUrl',
+        type: 'String',
+        hint: '选择节点书籍封面 (规则结果为String类型的url)',
+      },
+      {
+        title: '详情地址',
+        namespace: 'ruleExplore',
+        id: 'bookUrl',
+        type: 'String',
+        hint: '选择书籍详情页网址 (规则结果为String类型的url)',
+      },
+    ],
+  },
+  detail: {
+    name: '详情',
+    children: [
+      {
+        title: '预处理',
+        namespace: 'ruleBookInfo',
+        id: 'init',
+        type: 'String',
+        hint: '用于加速详情信息检索,只支持AllInOne规则',
+      },
+      {
+        title: '书名规则',
+        namespace: 'ruleBookInfo',
+        id: 'name',
+        type: 'String',
+        hint: '选择节点书名 (规则结果为String)',
+      },
+      {
+        title: '作者规则',
+        namespace: 'ruleBookInfo',
+        id: 'author',
+        type: 'String',
+        hint: '选择节点作者 (规则结果为String)',
+      },
+      {
+        title: '分类规则',
+        namespace: 'ruleBookInfo',
+        id: 'kind',
+        type: 'String',
+        hint: '选择节点分类信息 (规则结果为String)',
+      },
+      {
+        title: '字数规则',
+        namespace: 'ruleBookInfo',
+        id: 'wordCount',
+        type: 'String',
+        hint: '选择节点字数信息 (规则结果为String)',
+      },
+      {
+        title: '最新章节',
+        namespace: 'ruleBookInfo',
+        id: 'lastChapter',
+        type: 'String',
+        hint: '选择节点最新章节 (规则结果为String)',
+      },
+      {
+        title: '简介规则',
+        namespace: 'ruleBookInfo',
+        id: 'intro',
+        type: 'String',
+        hint: '选择节点书籍简介 (规则结果为String)',
+      },
+      {
+        title: '封面规则',
+        namespace: 'ruleBookInfo',
+        id: 'coverUrl',
+        type: 'String',
+        hint: '选择节点书籍封面 (规则结果为String类型的url)',
+      },
+      {
+        title: '目录地址',
+        namespace: 'ruleBookInfo',
+        id: 'tocUrl',
+        type: 'String',
+        hint: '选择书籍详情页网址 (规则结果为String类型的url, 与详情页相同时可省略)',
+      },
+      {
+        title: '修改书籍',
+        namespace: 'ruleBookInfo',
+        id: 'canReName',
+        type: 'String',
+        hint: '允许修改书名作者(规则结果为String类型, 默认不允许)',
+      },
+      {
+        title: '下载URL',
+        namespace: 'ruleBookInfo',
+        id: 'downloadUrls',
+        type: 'String',
+        hint: '文件类书源下载地址 (规则结果为String类型的url, 多个链接返回数组)',
+      },
+    ],
+  },
+  directory: {
+    name: '目录',
+    children: [
+      {
+        title: '更新前JS',
+        namespace: 'ruleToc',
+        id: 'preUpdateJs',
+        type: 'String',
+        hint: '更新目录前调用JS 动态更新目录链接',
+      },
+      {
+        title: '列表规则',
+        namespace: 'ruleToc',
+        id: 'chapterList',
+        type: 'String',
+        hint: '选择目录列表的章节节点 (规则结果为List<Element>)',
+      },
+      {
+        title: '章节名称',
+        namespace: 'ruleToc',
+        id: 'chapterName',
+        type: 'String',
+        hint: '选择章节名称 (规则结果为String)',
+      },
+      {
+        title: '章节地址',
+        namespace: 'ruleToc',
+        id: 'chapterUrl',
+        type: 'String',
+        hint: '选择章节链接 (规则结果为String类型的Url)',
+      },
+      {
+        title: '标题处理',
+        namespace: 'ruleToc',
+        id: 'formatJs',
+        type: 'String',
+        hint: '遍历去重后的章节列表的回调,提供index(章节序号从1开始)、title(章节标题)变量,额外提供gInt(初始值0),返回值作为新的标题',
+      },
+      {
+        title: '卷名标识',
+        namespace: 'ruleToc',
+        id: 'isVolume',
+        type: 'String',
+        hint: '章节名称是否是卷名 (规则结果为Bool)',
+      },
+      {
+        title: '章节信息',
+        namespace: 'ruleToc',
+        id: 'updateTime',
+        type: 'String',
+        hint: '选择章节信息(如更新时间) (规则结果为String)',
+      },
+      {
+        title: '收费标识',
+        namespace: 'ruleToc',
+        id: 'isVip',
+        type: 'String',
+        hint: '章节是否为VIP章节 (规则结果为Bool)',
+      },
+      {
+        title: '购买标识',
+        namespace: 'ruleToc',
+        id: 'isPay',
+        type: 'String',
+        hint: '章节是否为已购买 (规则结果为Bool)',
+      },
+      {
+        title: '翻页规则',
+        namespace: 'ruleToc',
+        id: 'nextTocUrl',
+        type: 'String',
+        hint: '选择目录下一页链接 (规则结果为List<String>或String)',
+      },
+    ],
+  },
+  content: {
+    name: '正文',
+    children: [
+      {
+        title: '正文规则',
+        namespace: 'ruleContent',
+        id: 'content',
+        type: 'String',
+        hint: '选择正文内容 (规则结果为String)',
+      },
+      {
+        title: '标题规则',
+        namespace: 'ruleContent',
+        id: 'title',
+        type: 'String',
+        hint: '获取结果将会覆盖章节标题 (规则结果为String)',
+      },
+      {
+        title: '翻页规则',
+        namespace: 'ruleContent',
+        id: 'nextContentUrl',
+        type: 'String',
+        hint: '选择下一分页(不是下一章)链接 (规则结果为String类型的Url)',
+      },
+      {
+        title: '脚本注入',
+        namespace: 'ruleContent',
+        id: 'webJs',
+        type: 'String',
+        hint: '注入javascript,用于模拟鼠标点击等,必须有返回值,一般为String类型',
+      },
+      {
+        title: '资源正则',
+        namespace: 'ruleContent',
+        id: 'sourceRegex',
+        type: 'String',
+        hint: '匹配资源的url特征,用于嗅探',
+      },
+      {
+        title: '替换规则',
+        namespace: 'ruleContent',
+        id: 'replaceRegex',
+        type: 'String',
+        hint: '多页内容合并后替换,用于正文净化',
+      },
+      {
+        title: '图片样式',
+        namespace: 'ruleContent',
+        id: 'imageStyle',
+        type: 'String',
+        hint: 'FULL:铺满 不填:默认样式',
+      },
+      {
+        title: '图片解密',
+        namespace: 'ruleContent',
+        id: 'imageDecode',
+        type: 'String',
+        hint: '填写JavaScript 返回解密图片的bytes ',
+      },
+      {
+        title: '购买操作',
+        namespace: 'ruleContent',
+        id: 'payAction',
+        type: 'String',
+        hint: '填写JavaScript 返回购买链接或者调用购买接口',
+      },
+    ],
+  },
+  /*
+  review: {
+    name: "段评",
+    children: [
+      {
+        title: "段评URL",
+        namespace: "ruleReview",
+id: "reviewUrl",
+        type: "String",
+        hint: "段评URL",
+      },
+      {
+        title: "发布头像",
+        namespace: "ruleReview",
+id: "avatarRule",
+        type: "String",
+        hint: "段评发布者头像",
+      },
+      {
+        title: "段评内容",
+        namespace: "ruleReview",
+id: "contentRule",
+        type: "String",
+        hint: "段评内容",
+      },
+      {
+        title: "发布时间",
+        namespace: "ruleReview",
+id: "postTimeRule",
+        type: "String",
+        hint: "段评发布时间",
+      },
+      {
+        title: "回复URL",
+        namespace: "ruleReview",
+id: "reviewQuoteUrl",
+        type: "String",
+        hint: "获取段评回复URL",
+      },
+      {
+        title: "点赞URL",
+        namespace: "ruleReview",
+id: "voteUpUrl",
+        type: "String",
+        hint: "点赞URL",
+      },
+      {
+        title: "点踩URL",
+        namespace: "ruleReview",
+id: "voteDownUrl",
+        type: "String",
+        hint: "点踩URL",
+      },
+      {
+        title: "发送回复",
+        namespace: "ruleReview",
+id: "postReviewUrl",
+        type: "String",
+        hint: "发送回复URL",
+      },
+      {
+        title: "回复段评",
+        namespace: "ruleReview",
+id: "postQuoteUrl",
+        type: "String",
+        hint: "发送回复段评URL",
+      },
+      {
+        title: "删除段评",
+        namespace: "ruleReview",
+id: "deleteUrl",
+        type: "String",
+        hint: "删除段评URL",
+      },
+    ],
+  },*/
+  other: {
+    name: '其他',
+    children: [
+      {
+        title: '启用搜索',
+        id: 'enabled',
+        type: 'Boolean',
+      },
+      {
+        title: '启用发现',
+        id: 'enabledExplore',
+        type: 'Boolean',
+      },
+      // {
+      //   title: "启用段评",
+      //   id: "enabledReview",
+      //   type: "Boolean",
+      // },
+      {
+        title: 'CookieJar',
+        id: 'enabledCookieJar',
+        type: 'Boolean',
+      },
+      {
+        title: '搜索权重',
+        id: 'weight',
+        type: 'Number',
+      },
+      {
+        title: '排序编号',
+        id: 'customOrder',
+        type: 'Number',
+      },
+    ],
+  },
+}

+ 0 - 216
modules/web/src/config/rssSourceEditConfig.js

@@ -1,216 +0,0 @@
-export default {
-  base: {
-    name: "基础",
-    children: [
-      {
-        title: "源域名",
-        id: "sourceUrl",
-        type: "String",
-        hint: "通常填写网站主页,例: https://www.qidian.com",
-        required: true,
-      },
-      {
-        title: "图标",
-        id: "sourceIcon",
-        type: "String",
-        hint: "填写图片网络链接",
-      },
-      {
-        title: "源名称",
-        id: "sourceName",
-        type: "String",
-        hint: "会显示在源列表",
-        required: true,
-      },
-      {
-        title: "源分组",
-        id: "sourceGroup",
-        type: "String",
-        hint: "描述源的特征信息",
-      },
-      {
-        title: "源注释",
-        id: "sourceComment",
-        type: "String",
-        hint: "描述源作者和状态",
-      },
-      {
-        title: "分类地址",
-        id: "sortUrl",
-        type: "String",
-        hint: "名称1::链接1\n名称2::链接2",
-      },
-      {
-        title: "登录地址",
-        id: "loginUrl",
-        type: "String",
-        hint: "填写网站登录网址,仅在需要登录的源有用",
-      },
-      {
-        title: "登录界面",
-        id: "loginUi",
-        type: "String",
-        hint: "自定义登录界面",
-      },
-      {
-        title: "登录检测",
-        id: "loginCheckJs",
-        type: "String",
-        hint: "登录检测js",
-      },
-      {
-        title: "封面解密",
-        id: "coverDecodeJs",
-        type: "String",
-        hint: "封面解密js",
-      },
-      {
-        title: "请求头",
-        id: "header",
-        type: "String",
-        hint: "客户端标识",
-      },
-      {
-        title: "变量说明",
-        id: "variableComment",
-        type: "String",
-        hint: "源变量说明",
-      },
-      {
-        title: "并发率",
-        id: "concurrentRate",
-        type: "String",
-        hint: "并发率",
-      },
-    ],
-  },
-  list: {
-    name: "列表",
-    children: [
-      {
-        title: "列表规则",
-        id: "ruleArticles",
-        type: "String",
-        hint: "规则结果为List<Element>",
-      },
-      {
-        title: "翻页规则",
-        id: "ruleNextPage",
-        type: "String",
-        hint: "下一页链接 规则结果为List<String>或String",
-      },
-      {
-        title: "标题规则",
-        id: "ruleTitle",
-        type: "String",
-        hint: "文章标题 规则结果为String",
-      },
-      {
-        title: "时间规则",
-        id: "rulePubDate",
-        type: "String",
-        hint: "文章发布时间 规则结果为String",
-      },
-      {
-        title: "描述规则",
-        id: "ruleDescription",
-        type: "String",
-        hint: "文章简要描述 规则结果为String",
-      },
-      {
-        title: "图片规则",
-        id: "ruleImage",
-        type: "String",
-        hint: "文章图片链接 规则结果为String",
-      },
-      {
-        title: "链接规则",
-        id: "ruleLink",
-        type: "String",
-        hint: "文章链接 规则结果为String",
-      },
-    ],
-  },
-  webView: {
-    name: "WebView",
-    children: [
-      {
-        title: "内容规则",
-        id: "ruleContent",
-        type: "String",
-        hint: "文章正文",
-      },
-      {
-        title: "样式规则",
-        id: "style",
-        type: "String",
-        hint: "文章正文样式 填写css",
-      },
-      {
-        title: "注入规则",
-        id: "injectJs",
-        type: "String",
-        hint: "注入网页的JavaScript",
-      },
-      {
-        title: "黑名单",
-        id: "contentBlacklist",
-        type: "String",
-        hint: "webView链接加载黑名单,英文逗号隔开",
-      },
-      {
-        title: "白名单",
-        id: "contentWhitelist",
-        type: "String",
-        hint: "webView链接加载白名单,英文逗号隔开",
-      },
-      {
-        title: "链接拦截",
-        id: "shouldOverrideUrlLoading",
-        type: "String",
-        hint: "填写js,变量url为当前资源链接,返回true拦截",
-      },
-    ],
-  },
-  other: {
-    name: "其他",
-    children: [
-      {
-        title: "列表样式",
-        id: "articleStyle",
-        type: "Array",
-        array: ["默认", "大图", "双列"],
-      },
-      {
-        title: "加载地址",
-        id: "loadWithBaseUrl",
-        type: "Boolean",
-      },
-      {
-        title: "启用JS",
-        id: "enableJs",
-        type: "Boolean",
-      },
-      {
-        title: "启用",
-        id: "enabled",
-        type: "Boolean",
-      },
-      {
-        title: "Cookie",
-        id: "enabledCookieJar",
-        type: "Boolean",
-      },
-      {
-        title: "单URL",
-        id: "singleUrl",
-        type: "Boolean",
-      },
-      {
-        title: "排序编号",
-        id: "customOrder",
-        type: "Number",
-      },
-    ],
-  },
-};

+ 216 - 0
modules/web/src/config/rssSourceEditConfig.ts

@@ -0,0 +1,216 @@
+export default {
+  base: {
+    name: '基础',
+    children: [
+      {
+        title: '源域名',
+        id: 'sourceUrl',
+        type: 'String',
+        hint: '通常填写网站主页,例: https://www.qidian.com',
+        required: true,
+      },
+      {
+        title: '图标',
+        id: 'sourceIcon',
+        type: 'String',
+        hint: '填写图片网络链接',
+      },
+      {
+        title: '源名称',
+        id: 'sourceName',
+        type: 'String',
+        hint: '会显示在源列表',
+        required: true,
+      },
+      {
+        title: '源分组',
+        id: 'sourceGroup',
+        type: 'String',
+        hint: '描述源的特征信息',
+      },
+      {
+        title: '源注释',
+        id: 'sourceComment',
+        type: 'String',
+        hint: '描述源作者和状态',
+      },
+      {
+        title: '分类地址',
+        id: 'sortUrl',
+        type: 'String',
+        hint: '名称1::链接1\n名称2::链接2',
+      },
+      {
+        title: '登录地址',
+        id: 'loginUrl',
+        type: 'String',
+        hint: '填写网站登录网址,仅在需要登录的源有用',
+      },
+      {
+        title: '登录界面',
+        id: 'loginUi',
+        type: 'String',
+        hint: '自定义登录界面',
+      },
+      {
+        title: '登录检测',
+        id: 'loginCheckJs',
+        type: 'String',
+        hint: '登录检测js',
+      },
+      {
+        title: '封面解密',
+        id: 'coverDecodeJs',
+        type: 'String',
+        hint: '封面解密js',
+      },
+      {
+        title: '请求头',
+        id: 'header',
+        type: 'String',
+        hint: '客户端标识',
+      },
+      {
+        title: '变量说明',
+        id: 'variableComment',
+        type: 'String',
+        hint: '源变量说明',
+      },
+      {
+        title: '并发率',
+        id: 'concurrentRate',
+        type: 'String',
+        hint: '并发率',
+      },
+    ],
+  },
+  list: {
+    name: '列表',
+    children: [
+      {
+        title: '列表规则',
+        id: 'ruleArticles',
+        type: 'String',
+        hint: '规则结果为List<Element>',
+      },
+      {
+        title: '翻页规则',
+        id: 'ruleNextPage',
+        type: 'String',
+        hint: '下一页链接 规则结果为List<String>或String',
+      },
+      {
+        title: '标题规则',
+        id: 'ruleTitle',
+        type: 'String',
+        hint: '文章标题 规则结果为String',
+      },
+      {
+        title: '时间规则',
+        id: 'rulePubDate',
+        type: 'String',
+        hint: '文章发布时间 规则结果为String',
+      },
+      {
+        title: '描述规则',
+        id: 'ruleDescription',
+        type: 'String',
+        hint: '文章简要描述 规则结果为String',
+      },
+      {
+        title: '图片规则',
+        id: 'ruleImage',
+        type: 'String',
+        hint: '文章图片链接 规则结果为String',
+      },
+      {
+        title: '链接规则',
+        id: 'ruleLink',
+        type: 'String',
+        hint: '文章链接 规则结果为String',
+      },
+    ],
+  },
+  webView: {
+    name: 'WebView',
+    children: [
+      {
+        title: '内容规则',
+        id: 'ruleContent',
+        type: 'String',
+        hint: '文章正文',
+      },
+      {
+        title: '样式规则',
+        id: 'style',
+        type: 'String',
+        hint: '文章正文样式 填写css',
+      },
+      {
+        title: '注入规则',
+        id: 'injectJs',
+        type: 'String',
+        hint: '注入网页的JavaScript',
+      },
+      {
+        title: '黑名单',
+        id: 'contentBlacklist',
+        type: 'String',
+        hint: 'webView链接加载黑名单,英文逗号隔开',
+      },
+      {
+        title: '白名单',
+        id: 'contentWhitelist',
+        type: 'String',
+        hint: 'webView链接加载白名单,英文逗号隔开',
+      },
+      {
+        title: '链接拦截',
+        id: 'shouldOverrideUrlLoading',
+        type: 'String',
+        hint: '填写js,变量url为当前资源链接,返回true拦截',
+      },
+    ],
+  },
+  other: {
+    name: '其他',
+    children: [
+      {
+        title: '列表样式',
+        id: 'articleStyle',
+        type: 'Array',
+        array: ['默认', '大图', '双列'],
+      },
+      {
+        title: '加载地址',
+        id: 'loadWithBaseUrl',
+        type: 'Boolean',
+      },
+      {
+        title: '启用JS',
+        id: 'enableJs',
+        type: 'Boolean',
+      },
+      {
+        title: '启用',
+        id: 'enabled',
+        type: 'Boolean',
+      },
+      {
+        title: 'Cookie',
+        id: 'enabledCookieJar',
+        type: 'Boolean',
+      },
+      {
+        title: '单URL',
+        id: 'singleUrl',
+        type: 'Boolean',
+      },
+      {
+        title: '排序编号',
+        id: 'customOrder',
+        type: 'Number',
+      },
+    ],
+  },
+}

+ 23 - 0
modules/web/src/config/sourceConfig.d.ts

@@ -0,0 +1,23 @@
+import type { Source } from '@/source'
+import bookSourceEditConfig from './bookSourceEditConfig'
+import rssSourceEditConfig from './rssSourceEditConfig'
+
+type PickAnyValueKey<T> = {
+  [K in keyof T]: T[K] extends { [prop: string]: string } ? K : never
+}
+type b = keyof PickAnyValueKey<BookSoure>
+
+type SourceConfigKey =
+  | keyof typeof bookSourceEditConfig
+  | keyof typeof rssSourceEditConfig
+type SourceConfigRecord = {
+  title: string
+  type: string //"array" | "String" | "Boolean"
+  array?: string[]
+  hint?: string
+  required?: boolean
+  namespace?: Partial<keyof Source>
+  id: Partial<keyof Source>
+}
+type SourceConfigValue = { name: string; children: SourceConfigRecord[] }
+export type SourceConfig = Partial<Record<SourceConfigKey, SourceConfigValue>>

+ 0 - 65
modules/web/src/config/themeConfig.js

@@ -1,65 +0,0 @@
-import body_0 from "../assets/imgs/themes/body_0.png";
-import content_0 from "../assets/imgs/themes/content_0.png";
-import popup_0 from "../assets/imgs/themes/popup_0.png";
-import body_1 from "../assets/imgs/themes/body_1.png";
-import content_1 from "../assets/imgs/themes/content_1.png";
-import popup_1 from "../assets/imgs/themes/popup_1.png";
-import body_2 from "../assets/imgs/themes/body_2.png";
-import content_2 from "../assets/imgs/themes/content_2.png";
-import popup_2 from "../assets/imgs/themes/popup_2.png";
-import body_3 from "../assets/imgs/themes/body_3.png";
-import content_3 from "../assets/imgs/themes/content_3.png";
-import popup_3 from "../assets/imgs/themes/popup_3.png";
-import body_5 from "../assets/imgs/themes/body_5.png";
-import content_5 from "../assets/imgs/themes/content_5.png";
-import popup_5 from "../assets/imgs/themes/popup_5.png";
-import body_6 from "../assets/imgs/themes/body_6.png";
-import content_6 from "../assets/imgs/themes/content_6.png";
-import popup_6 from "../assets/imgs/themes/popup_6.png";
-var settings = {
-  themes: [
-    {
-      body: "#ede7da url(" + body_0 + ") repeat",
-      content: "#ede7da url(" + content_0 + ") repeat",
-      popup: "#ede7da url(" + popup_0 + ") repeat",
-    },
-    {
-      body: "#ede7da url(" + body_1 + ") repeat",
-      content: "#ede7da url(" + content_1 + ") repeat",
-      popup: "#ede7da url(" + popup_1 + ") repeat",
-    },
-    {
-      body: "#ede7da url(" + body_2 + ") repeat",
-      content: "#ede7da url(" + content_2 + ") repeat",
-      popup: "#ede7da url(" + popup_2 + ") repeat",
-    },
-    {
-      body: "#ede7da url(" + body_3 + ") repeat",
-      content: "#ede7da url(" + content_3 + ") repeat",
-      popup: "#ede7da url(" + popup_3 + ") repeat",
-    },
-    {
-      body: "#ebcece repeat",
-      content: "#f5e4e4 repeat",
-      popup: "#faeceb repeat",
-    },
-    {
-      body: "#ede7da url(" + body_5 + ") repeat",
-      content: "#ede7da url(" + content_5 + ") repeat",
-      popup: "#ede7da url(" + popup_5 + ") repeat",
-    },
-    {
-      body: "#ede7da url(" + body_6 + ") repeat",
-      content: "#ede7da url(" + content_6 + ") repeat",
-      popup: "#ede7da url(" + popup_6 + ") repeat",
-    },
-  ],
-  fonts: [
-    "Microsoft YaHei, PingFangSC-Regular, HelveticaNeue-Light, Helvetica Neue Light, sans-serif",
-
-    "PingFangSC-Regular, -apple-system, Simsun",
-
-    "Kaiti",
-  ],
-};
-export default settings;

+ 65 - 0
modules/web/src/config/themeConfig.ts

@@ -0,0 +1,65 @@
+import body_0 from '../assets/imgs/themes/body_0.png'
+import content_0 from '../assets/imgs/themes/content_0.png'
+import popup_0 from '../assets/imgs/themes/popup_0.png'
+import body_1 from '../assets/imgs/themes/body_1.png'
+import content_1 from '../assets/imgs/themes/content_1.png'
+import popup_1 from '../assets/imgs/themes/popup_1.png'
+import body_2 from '../assets/imgs/themes/body_2.png'
+import content_2 from '../assets/imgs/themes/content_2.png'
+import popup_2 from '../assets/imgs/themes/popup_2.png'
+import body_3 from '../assets/imgs/themes/body_3.png'
+import content_3 from '../assets/imgs/themes/content_3.png'
+import popup_3 from '../assets/imgs/themes/popup_3.png'
+import body_5 from '../assets/imgs/themes/body_5.png'
+import content_5 from '../assets/imgs/themes/content_5.png'
+import popup_5 from '../assets/imgs/themes/popup_5.png'
+import body_6 from '../assets/imgs/themes/body_6.png'
+import content_6 from '../assets/imgs/themes/content_6.png'
+import popup_6 from '../assets/imgs/themes/popup_6.png'
+const settings = {
+  themes: [
+    {
+      body: '#ede7da url(' + body_0 + ') repeat',
+      content: '#ede7da url(' + content_0 + ') repeat',
+      popup: '#ede7da url(' + popup_0 + ') repeat',
+    },
+    {
+      body: '#ede7da url(' + body_1 + ') repeat',
+      content: '#ede7da url(' + content_1 + ') repeat',
+      popup: '#ede7da url(' + popup_1 + ') repeat',
+    },
+    {
+      body: '#ede7da url(' + body_2 + ') repeat',
+      content: '#ede7da url(' + content_2 + ') repeat',
+      popup: '#ede7da url(' + popup_2 + ') repeat',
+    },
+    {
+      body: '#ede7da url(' + body_3 + ') repeat',
+      content: '#ede7da url(' + content_3 + ') repeat',
+      popup: '#ede7da url(' + popup_3 + ') repeat',
+    },
+    {
+      body: '#ebcece repeat',
+      content: '#f5e4e4 repeat',
+      popup: '#faeceb repeat',
+    },
+    {
+      body: '#ede7da url(' + body_5 + ') repeat',
+      content: '#ede7da url(' + content_5 + ') repeat',
+      popup: '#ede7da url(' + popup_5 + ') repeat',
+    },
+    {
+      body: '#ede7da url(' + body_6 + ') repeat',
+      content: '#ede7da url(' + content_6 + ') repeat',
+      popup: '#ede7da url(' + popup_6 + ') repeat',
+    },
+  ],
+  fonts: [
+    'Microsoft YaHei, PingFangSC-Regular, HelveticaNeue-Light, Helvetica Neue Light, sans-serif',
+
+    'PingFangSC-Regular, -apple-system, Simsun',
+
+    'Kaiti',
+  ],
+}
+export default settings

+ 2 - 2
modules/web/src/hooks/loading.css

@@ -3,6 +3,6 @@
   color: #b5b5b5;
 }
 .el-loading-text {
- font-weight: 500;
- color: #b5b5b5 !important;
+  font-weight: 500;
+  color: #b5b5b5 !important;
 }

+ 0 - 36
modules/web/src/hooks/loading.js

@@ -1,36 +0,0 @@
-import { watch, unref, onUnmounted } from "vue";
-import { ElLoading } from "element-plus";
-import loadingSvg from "@element-plus/icons-svg/loading.svg?raw";
-import "element-plus/theme-chalk/el-loading.css";
-import "./loading.css";
-
-export const useLoading = (target, text, spinner = loadingSvg) => {
-  // loading spinner
-  const isLoading = ref(false);
-  let loadingInstance = null;
-  const closeLoading = () => (isLoading.value = false);
-  const showLoading = () => (isLoading.value = true);
-  watch(isLoading, (loading) => {
-    if (!loading) return loadingInstance?.close();
-    loadingInstance = ElLoading.service({
-      target: unref(target),
-      spinner: spinner,
-      text: text,
-      lock: true,
-      background: "rgba(0, 0, 0, 0)",
-    });
-  });
-
-  const loadingWrapper = (promise) => {
-    if (!(promise instanceof Promise))
-      throw TypeError("loadingWrapper argument must be Promise");
-    showLoading();
-    return promise.finally(closeLoading);
-  };
-
-  onUnmounted(() => {
-    closeLoading();
-  });
-
-  return { isLoading, showLoading, closeLoading, loadingWrapper };
-};

+ 40 - 0
modules/web/src/hooks/loading.ts

@@ -0,0 +1,40 @@
+import { watch, unref, onUnmounted } from 'vue'
+import { ElLoading } from 'element-plus'
+import loadingSvg from '@element-plus/icons-svg/loading.svg?raw'
+import 'element-plus/theme-chalk/el-loading.css'
+import './loading.css'
+
+export const useLoading = (
+  target: MaybeRef<string | HTMLElement | undefined>,
+  text: string,
+  spinner = loadingSvg,
+) => {
+  // loading spinner
+  const isLoading = ref(false)
+  let loadingInstance: ReturnType<typeof ElLoading.service> | null = null
+  const closeLoading = () => (isLoading.value = false)
+  const showLoading = () => (isLoading.value = true)
+  watch(isLoading, loading => {
+    if (!loading) return loadingInstance?.close()
+    loadingInstance = ElLoading.service({
+      target: unref(target),
+      spinner: spinner,
+      text: text,
+      lock: true,
+      background: 'rgba(0, 0, 0, 0)',
+    })
+  })
+
+  const loadingWrapper = (promise: Promise<unknown>) => {
+    if (!(promise instanceof Promise))
+      throw TypeError('loadingWrapper argument must be Promise')
+    showLoading()
+    return promise.finally(closeLoading)
+  }
+
+  onUnmounted(() => {
+    closeLoading()
+  })
+
+  return { isLoading, showLoading, closeLoading, loadingWrapper }
+}

+ 0 - 18
modules/web/src/main.js

@@ -1,18 +0,0 @@
-import { createApp } from "vue";
-import App from "./App.vue";
-import router from "@/router";
-import store from "@/store";
-import "element-plus/theme-chalk/dark/css-vars.css";
-
-createApp(App).use(store).use(router).mount("#app");
-// 书架 同步Element PLUS 夜间模式
-watch(
-  () => useBookStore().isNight,
-  (isNight) => {
-    if (isNight) {
-      document.documentElement.classList.add("dark");
-    } else {
-      document.documentElement.classList.remove("dark");
-    }
-  },
-);

+ 18 - 0
modules/web/src/main.ts

@@ -0,0 +1,18 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from '@/router'
+import store from '@/store'
+import 'element-plus/theme-chalk/dark/css-vars.css'
+
+createApp(App).use(store).use(router).mount('#app')
+// 书架 同步Element PLUS 夜间模式
+watch(
+  () => useBookStore().isNight,
+  isNight => {
+    if (isNight) {
+      document.documentElement.classList.add('dark')
+    } else {
+      document.documentElement.classList.remove('dark')
+    }
+  },
+)

+ 2 - 1
modules/web/src/pages/bookshelf/README.md

@@ -29,4 +29,5 @@ pnpm build
 pnpm lint:fix
 #格式化代码
 ```
- - 调试的时候可以修改.env.development里面的地址连接手机端调试
+
+- 调试的时候可以修改.env.development里面的地址连接手机端调试

+ 10 - 10
modules/web/src/pages/bookshelf/index.html

@@ -1,13 +1,13 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="zh" class="">
-    <head>
-        <meta charset="UTF-8" />
-        <link rel="icon" href="/favicon.ico" />
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
     <meta name="viewport" content="width=device-width,initial-scale=1.0" />
-    </head>
+  </head>
 
-    <body>
-        <div id="app"></div>
-        <script type="module" src="./main.js"></script>
-    </body>
-</html>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="./main.js"></script>
+  </body>
+</html>

+ 10 - 10
modules/web/src/pages/bookshelf/main.js

@@ -1,19 +1,19 @@
-import { createApp } from "vue";
-import App from "@/App.vue";
-import bookRouter from "@/router/bookRouter";
-import store from "@/store";
-import "element-plus/theme-chalk/dark/css-vars.css";
+import { createApp } from 'vue'
+import App from '@/App.vue'
+import bookRouter from '@/router/bookRouter'
+import store from '@/store'
+import 'element-plus/theme-chalk/dark/css-vars.css'
 
-createApp(App).use(store).use(bookRouter).mount("#app");
+createApp(App).use(store).use(bookRouter).mount('#app')
 
 // 同步Element PLUS 夜间模式
 watch(
   () => useBookStore().isNight,
-  (isNight) => {
+  isNight => {
     if (isNight) {
-      document.documentElement.classList.add("dark");
+      document.documentElement.classList.add('dark')
     } else {
-      document.documentElement.classList.remove("dark");
+      document.documentElement.classList.remove('dark')
     }
   },
-);
+)

+ 3 - 2
modules/web/src/pages/source/README.md

@@ -1,6 +1,5 @@
 # legado_web_editor
 
-
 ## 🚧开发注意
 
 如果你想要调试项目 请修改文件`.env.development`里`VITE_API`为阅读web服务ip
@@ -17,17 +16,19 @@ pnpm i
 ```
 
 ### Compiles and hot-reloads for development
+
 ```
 pnpm dev
 ```
 
 ### Compiles and minifies for production
+
 ```
 pnpm build
 ```
 
 ### Lints and fixes files
+
 ```
 pnpm lint:fix
 ```
-

+ 10 - 10
modules/web/src/pages/source/index.html

@@ -1,13 +1,13 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="zh" class="">
-    <head>
-        <meta charset="UTF-8" />
-        <link rel="icon" href="/favicon.ico" />
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
     <meta name="viewport" content="width=device-width,initial-scale=1.0" />
-    </head>
+  </head>
 
-    <body>
-        <div id="app"></div>
-        <script type="module" src="./main.js"></script>
-    </body>
-</html>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="./main.js"></script>
+  </body>
+</html>

+ 6 - 6
modules/web/src/pages/source/main.js

@@ -1,7 +1,7 @@
-import { createApp } from "vue";
-import App from "@/App.vue";
-import sourceRouter from "@/router/sourceRouter";
-import store from "@/store";
-import "element-plus/theme-chalk/dark/css-vars.css";
+import { createApp } from 'vue'
+import App from '@/App.vue'
+import sourceRouter from '@/router/sourceRouter'
+import store from '@/store'
+import 'element-plus/theme-chalk/dark/css-vars.css'
 
-createApp(App).use(store).use(sourceRouter).mount("#app");
+createApp(App).use(store).use(sourceRouter).mount('#app')

+ 18 - 0
modules/web/src/plugins/jump.d.ts

@@ -0,0 +1,18 @@
+export {}
+export type Options = {
+  duration?: number | [(distance: number) => number]
+  offset?: number
+  callback?: () => void // "undefined" is a suitable default, and won't be called
+  easing?: (
+    timeElapsed: number,
+    start: number,
+    distance: number,
+    duration: number,
+  ) => number
+  a11y?: boolean
+  container?: HTMLElement | string
+}
+export default function (
+  target: number | string | HTMLElement,
+  options: Options = {},
+): void

+ 74 - 74
modules/web/src/plugins/jump.js

@@ -1,51 +1,51 @@
 const easeInOutQuad = (t, b, c, d) => {
-  t /= d / 2;
-  if (t < 1) return (c / 2) * t * t + b;
-  t--;
-  return (-c / 2) * (t * (t - 2) - 1) + b;
-};
+  t /= d / 2
+  if (t < 1) return (c / 2) * t * t + b
+  t--
+  return (-c / 2) * (t * (t - 2) - 1) + b
+}
 
 const jumper = () => {
   // private variable cache
   // no variables are created during a jump, preventing memory leaks
 
-  let container; // container element to be scrolled       (node)
-  let element; // element to scroll to                   (node)
+  let container // container element to be scrolled       (node)
+  let element // element to scroll to                   (node)
 
-  let start; // where scroll starts                    (px)
-  let stop; // where scroll stops                     (px)
+  let start // where scroll starts                    (px)
+  let stop // where scroll stops                     (px)
 
-  let offset; // adjustment from the stop position      (px)
-  let easing; // easing function                        (function)
-  let a11y; // accessibility support flag             (boolean)
+  let offset // adjustment from the stop position      (px)
+  let easing // easing function                        (function)
+  let a11y // accessibility support flag             (boolean)
 
-  let distance; // distance of scroll                     (px)
-  let duration; // scroll duration                        (ms)
+  let distance // distance of scroll                     (px)
+  let duration // scroll duration                        (ms)
 
-  let timeStart; // time scroll started                    (ms)
-  let timeElapsed; // time spent scrolling thus far          (ms)
+  let timeStart // time scroll started                    (ms)
+  let timeElapsed // time spent scrolling thus far          (ms)
 
-  let next; // next scroll position                   (px)
+  let next // next scroll position                   (px)
 
-  let callback; // to call when done scrolling            (function)
+  let callback // to call when done scrolling            (function)
 
   // scroll position helper
 
   function location() {
-    let top = container.scrollTop || container.scrollY || container.pageYOffset;
-    top = typeof top === "undefined" ? 0 : top;
-    return top;
+    let top = container.scrollTop || container.scrollY || container.pageYOffset
+    top = typeof top === 'undefined' ? 0 : top
+    return top
   }
 
   // element offset helper
 
   function top(element) {
-    const elementTop = element.getBoundingClientRect().top;
+    const elementTop = element.getBoundingClientRect().top
     const containerTop = container.getBoundingClientRect
       ? container.getBoundingClientRect().top
-      : 0;
+      : 0
 
-    return elementTop - containerTop + start;
+    return elementTop - containerTop + start
   }
 
   // scrollTo helper
@@ -53,7 +53,7 @@ const jumper = () => {
   function scrollTo(top) {
     container.scrollTo
       ? container.scrollTo(0, top) // window
-      : (container.scrollTop = top); // custom container
+      : (container.scrollTop = top) // custom container
   }
 
   // rAF loop helper
@@ -61,126 +61,126 @@ const jumper = () => {
   function loop(timeCurrent) {
     // store time scroll started, if not started already
     if (!timeStart) {
-      timeStart = timeCurrent;
+      timeStart = timeCurrent
     }
 
     // determine time spent scrolling so far
-    timeElapsed = timeCurrent - timeStart;
+    timeElapsed = timeCurrent - timeStart
 
     // calculate next scroll position
-    next = easing(timeElapsed, start, distance, duration);
+    next = easing(timeElapsed, start, distance, duration)
 
     // scroll to it
-    scrollTo(next);
+    scrollTo(next)
 
     // check progress
     timeElapsed < duration
       ? requestAnimationFrame(loop) // continue scroll loop
-      : done(); // scrolling is done
+      : done() // scrolling is done
   }
 
   // scroll finished helper
 
   function done() {
     // account for rAF time rounding inaccuracies
-    scrollTo(start + distance);
+    scrollTo(start + distance)
 
     // if scrolling to an element, and accessibility is enabled
     if (element && a11y) {
       // add tabindex indicating programmatic focus
-      element.setAttribute("tabindex", "-1");
+      element.setAttribute('tabindex', '-1')
 
       // focus the element
-      element.focus();
+      element.focus()
     }
 
     // if it exists, fire the callback
-    if (typeof callback === "function") {
-      callback();
+    if (typeof callback === 'function') {
+      callback()
     }
 
     // reset time for next jump
-    timeStart = false;
+    timeStart = false
   }
 
   // API
 
   function jump(target, options = {}) {
     // resolve options, or use defaults
-    duration = options.duration || 1000;
-    offset = options.offset || 0;
-    callback = options.callback; // "undefined" is a suitable default, and won't be called
-    easing = options.easing || easeInOutQuad;
-    a11y = options.a11y || false;
+    duration = options.duration || 1000
+    offset = options.offset || 0
+    callback = options.callback // "undefined" is a suitable default, and won't be called
+    easing = options.easing || easeInOutQuad
+    a11y = options.a11y || false
 
     // resolve container
     switch (typeof options.container) {
-      case "object":
+      case 'object':
         // we assume container is an HTML element (Node)
-        container = options.container;
-        break;
+        container = options.container
+        break
 
-      case "string":
-        container = document.querySelector(options.container);
-        break;
+      case 'string':
+        container = document.querySelector(options.container)
+        break
 
       default:
-        container = window;
+        container = window
     }
 
     // cache starting position
-    start = location();
+    start = location()
 
     // resolve target
     switch (typeof target) {
       // scroll from current position
-      case "number":
-        element = undefined; // no element to scroll to
-        a11y = false; // make sure accessibility is off
-        stop = start + target;
-        break;
+      case 'number':
+        element = undefined // no element to scroll to
+        a11y = false // make sure accessibility is off
+        stop = start + target
+        break
 
       // scroll to element (node)
       // bounding rect is relative to the viewport
-      case "object":
-        element = target;
-        stop = top(element);
-        break;
+      case 'object':
+        element = target
+        stop = top(element)
+        break
 
       // scroll to element (selector)
       // bounding rect is relative to the viewport
-      case "string":
-        element = document.querySelector(target);
-        stop = top(element);
-        break;
+      case 'string':
+        element = document.querySelector(target)
+        stop = top(element)
+        break
     }
 
     // resolve scroll distance, accounting for offset
-    distance = stop - start + offset;
+    distance = stop - start + offset
 
     // resolve duration
     switch (typeof options.duration) {
       // number in ms
-      case "number":
-        duration = options.duration;
-        break;
+      case 'number':
+        duration = options.duration
+        break
 
       // function passed the distance of the scroll
-      case "function":
-        duration = options.duration(distance);
-        break;
+      case 'function':
+        duration = options.duration(distance)
+        break
     }
 
     // start the loop
-    requestAnimationFrame(loop);
+    requestAnimationFrame(loop)
   }
 
   // expose only the jump method
-  return jump;
-};
+  return jump
+}
 
 // export singleton
 
-const singleton = jumper();
+const singleton = jumper()
 
-export default singleton;
+export default singleton

+ 0 - 23
modules/web/src/router/bookRouter.js

@@ -1,23 +0,0 @@
-import { createWebHashHistory, createRouter } from "vue-router";
-
-export const bookRoutes = [
-  {
-    path: "/",
-    name: "shelf",
-    component: () => import("../views/BookShelf.vue"),
-  },
-  {
-    path: "/chapter",
-    name: "chapter",
-    component: () => import("../views/BookChapter.vue"),
-  },
-];
-
-const router = createRouter({
-  // mode: "history",
-  history: createWebHashHistory(),
-  // @ts-ignore
-  routes: bookRoutes,
-});
-
-export default router;

+ 22 - 0
modules/web/src/router/bookRouter.ts

@@ -0,0 +1,22 @@
+import { createWebHashHistory, createRouter } from 'vue-router'
+
+export const bookRoutes = [
+  {
+    path: '/',
+    name: 'shelf',
+    component: () => import('../views/BookShelf.vue'),
+  },
+  {
+    path: '/chapter',
+    name: 'chapter',
+    component: () => import('../views/BookChapter.vue'),
+  },
+]
+
+const router = createRouter({
+  // mode: "history",
+  history: createWebHashHistory(),
+  routes: bookRoutes,
+})
+
+export default router

+ 0 - 15
modules/web/src/router/index.js

@@ -1,15 +0,0 @@
-import { createWebHashHistory, createRouter } from "vue-router";
-import { bookRoutes } from "./bookRouter";
-import { sourceRoutes } from "./sourceRouter";
-
-const router = createRouter({
-  //   history: createWebHistory(process.env.BASE_URL),
-  history: createWebHashHistory(),
-  // @ts-ignore
-  routes: bookRoutes.concat(sourceRoutes),
-});
-
-router.afterEach((to) => {
-  if (to.name == "shelf") document.title = "书架";
-});
-export default router;

+ 14 - 0
modules/web/src/router/index.ts

@@ -0,0 +1,14 @@
+import { createWebHashHistory, createRouter } from 'vue-router'
+import { bookRoutes } from './bookRouter'
+import { sourceRoutes } from './sourceRouter'
+
+const router = createRouter({
+  //   history: createWebHistory(process.env.BASE_URL),
+  history: createWebHashHistory(),
+  routes: [bookRoutes, sourceRoutes].flat(),
+})
+
+router.afterEach(to => {
+  if (to.name == 'shelf') document.title = '书架'
+})
+export default router

+ 9 - 9
modules/web/src/router/sourceRouter.js → modules/web/src/router/sourceRouter.ts

@@ -1,23 +1,23 @@
-import sourceEditor from "../views/SourceEditor.vue";
-import { createWebHashHistory, createRouter } from "vue-router";
+import sourceEditor from '../views/SourceEditor.vue'
+import { createWebHashHistory, createRouter } from 'vue-router'
 
 export const sourceRoutes = [
   {
-    path: "/bookSource",
-    name: "book-home",
+    path: '/bookSource',
+    name: 'book-home',
     component: sourceEditor,
   },
   {
-    path: "/rssSource",
-    name: "rss-home",
+    path: '/rssSource',
+    name: 'rss-home',
     component: sourceEditor,
   },
-];
+]
 
 const router = createRouter({
   //   history: createWebHistory(process.env.BASE_URL),
   history: createWebHashHistory(),
   routes: sourceRoutes,
-});
+})
 
-export default router;
+export default router

+ 162 - 26
modules/web/src/source.d.ts

@@ -1,29 +1,165 @@
 /** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/data/entities */
-interface BaseSource {
-    lastUpdateTime?: number | undefined
-}
-interface BookSoure extends BaseSource {
-    bookSourceUrl?: string | undefined
-    bookSourceName?: string | undefined
-    bookSourceType?: number | undefined
-    bookSourceGroup?: string | undefined
-    bookSourceComment?: string | undefined
-    ruleSearch?: RuleSearch | undefined
-    ruleBookInfo?: RuleBookInfo | undefined
-    ruleToc?: RuleToc | undefined
-    ruleContent?: RuleContent | undefined
-    ruleReview?: RuleReview | undefined
-    ruleExplore?: ruleExplore | undefined
-}
-interface RuleSearch {
-    checkKeyWord?: string | undefined
-}
-interface RssSource extends BaseSource {
-    sourceUrl?: string | undefined
-    sourceName?: string | undefined
-    sourceGroup?: string | undefined
-    sourceComment?: string | undefined
-}
-type Source = BookSoure & RssSource
+type BaseSource = {
+  /**
+   * 并发率
+   */
+  concurrentRate?: string
+  /**
+   * 登录地址
+   */
+  loginUrl?: string
+
+  /**
+   * 登录UI
+   */
+  loginUi?: string
+
+  /**
+   * 请求头
+   */
+  header?: string
+
+  /**
+   * 启用cookieJar
+   */
+  enabledCookieJar?: boolean
+
+  /**
+   * js库
+   */
+  jsLib?: string
+}
+type BookSoure = BaseSource & {
+  // 地址,包括 http/https
+  bookSourceUrl: string
+  // 名称
+  bookSourceName: string
+  // 分组
+  bookSourceGroup?: string
+  // 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站)
+  bookSourceType: number
+  // 详情页url正则
+  bookUrlPattern?: string
+  // 手动排序编号
+  customOrder: number
+  // 是否启用
+  enabled: boolean
+  // 启用发现
+  enabledExplore: boolean
+  // 登录检测js
+  loginCheckJs?: string
+  // 封面解密js
+  coverDecodeJs?: string
+  // 注释
+  bookSourceComment?: string
+  // 自定义变量说明
+  variableComment?: string
+  // 最后更新时间,用于排序
+  lastUpdateTime: number
+  // 响应时间,用于排序
+  respondTime: number
+  // 智能排序的权重
+  weight: number
+  // 发现url
+  exploreUrl?: string
+  // 发现筛选规则
+  exploreScreen?: string
+  // 发现规则
+  ruleExplore?: ExploreRule
+  // 搜索url
+  searchUrl?: string
+  // 搜索规则
+  ruleSearch?: SearchRule
+  // 书籍信息页规则
+  ruleBookInfo?: BookInfoRule
+  // 目录页规则
+  ruleToc?: TocRule
+  // 正文页规则
+  ruleContent?: ContentRule
+  // 段评规则
+  ruleReview?: ReviewRule
+}
+type RuleSearch = {
+  checkKeyWord?: string
+  [prop: string]: string
+}
+/* type ExploreRule = {
+    [prop:string]: string
+}
+type BookInfoRule = {
+    [prop:string]: string
+}
+type TocRule = {
+    [prop:string]: string
+}
+type ContentRule = {
+    [prop:string]: string
+}
+type ReviewRule = {
+    [prop:string]: string
+} */
+type RssSource = BaseSource & {
+  sourceUrl: string
+  // 名称
+  sourceName: string
+  // 图标
+  sourceIcon: string
+  // 分组
+  sourceGroup?: string
+  // 注释
+  sourceComment?: string
+  // 是否启用
+  enabled: boolean
+  // 自定义变量说明
+  variableComment?: string
+  /**登录检测js**/
+  loginCheckJs?: string
+  /**封面解密js**/
+  coverDecodeJs?: string
+  /**分类Url**/
+  sortUrl?: string
+  /**是否单url源**/
+  singleUrl: boolean
+  /*列表规则*/
+  /**列表样式,0,1,2**/
+  articleStyle: number
+  /**列表规则**/
+  ruleArticles?: string
+  /**下一页规则**/
+  ruleNextPage?: string
+  /**标题规则**/
+  ruleTitle?: string
+  /**发布日期规则**/
+  rulePubDate?: string
+  /*webView规则*/
+  /**描述规则**/
+  ruleDescription?: string
+  /**图片规则**/
+  ruleImage?: string
+  /**链接规则**/
+  ruleLink?: string
+  /**正文规则**/
+  ruleContent?: string
+  /**正文url白名单**/
+  contentWhitelist?: string
+  /**正文url黑名单**/
+  contentBlacklist?: string
+  /**
+   * 跳转url拦截,
+   * js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入
+   * **/
+  shouldOverrideUrlLoading?: string
+  /**webView样式**/
+  style?: string
+  enableJs: boolean
+  loadWithBaseUrl: boolean
+  /**注入js**/
+  injectJs?: string
+  /*其它规则*/
+  /**最后更新时间,用于排序**/
+  lastUpdateTime: number
+  customOrder: number
+}
+type Source = BookSoure | RssSource
 
 export { Source, BookSoure, RssSource }

+ 0 - 116
modules/web/src/store/bookStore.js

@@ -1,116 +0,0 @@
-import { defineStore } from "pinia";
-import API from "@api";
-
-export const useBookStore = defineStore("book", {
-  state: () => {
-    return {
-      connectStatus: "正在连接后端服务器……",
-      /**@type {"primary" | "success" |"danger"} */
-      connectType: "primary",
-      newConnect: true,
-      /**@type {Array<{respondTime:number}>} */
-      searchBooks: [],
-      shelf: [],
-      catalog: [],
-      /**@type {{index: number,chapterPos:number}} */
-      readingBook: { index: 0, chapterPos: 0 },
-      popCataVisible: false,
-      contentLoading: true,
-      showContent: false,
-      config: {
-        theme: 0,
-        font: 0,
-        fontSize: 18,
-        readWidth: 800,
-        infiniteLoading: false,
-        customFontName: "",
-        jumpDuration: 1000,
-        spacing: {
-          paragraph: 1,
-          line: 0.8,
-          letter: 0,
-        },
-      },
-      miniInterface: false,
-      readSettingsVisible: false,
-    };
-  },
-  getters: {
-    bookProgress: (state) => {
-      if (state.catalog.length == 0) return;
-      // @ts-ignore
-      const { index, chapterPos, bookName, bookAuthor } = state.readingBook;
-      let title = state.catalog[index]?.title;
-      if (!title) return;
-      return {
-        name: bookName,
-        author: bookAuthor,
-        durChapterIndex: index,
-        durChapterPos: chapterPos,
-        durChapterTime: new Date().getTime(),
-        durChapterTitle: title,
-      };
-    },
-    theme: (state) => {
-      return state.config.theme;
-    },
-    isNight: (state) => state.config.theme == 6,
-  },
-  actions: {
-    setConnectStatus(connectStatus) {
-      this.connectStatus = connectStatus;
-    },
-    setConnectType(connectType) {
-      this.connectType = connectType;
-    },
-    setNewConnect(newConnect) {
-      this.newConnect = newConnect;
-    },
-    addBooks(books) {
-      this.shelf = books;
-    },
-    clearBooks() {
-      this.shelf = [];
-    },
-    setCatalog(catalog) {
-      this.catalog = catalog;
-    },
-    setPopCataVisible(visible) {
-      this.popCataVisible = visible;
-    },
-    setContentLoading(loading) {
-      this.contentLoading = loading;
-    },
-    setReadingBook(readingBook) {
-      this.readingBook = readingBook;
-    },
-    setConfig(config) {
-      this.config = Object.assign({}, this.config, config);
-    },
-    setReadSettingsVisible(visible) {
-      this.readSettingsVisible = visible;
-    },
-    setShowContent(visible) {
-      this.showContent = visible;
-    },
-    setMiniInterface(mini) {
-      this.miniInterface = mini;
-    },
-    async setSearchBooks(books) {
-      books.forEach((book) => {
-        let findBook = this.shelf.find((item) => item.bookUrl == book.bookUrl);
-        if (findBook === undefined) {
-          this.searchBooks.push(book);
-        }
-      });
-    },
-    clearSearchBooks() {
-      this.searchBooks = [];
-    },
-    //保存进度到app
-    async saveBookProgress() {
-      if (!this.bookProgress) return Promise.resolve();
-      return API.saveBookProgress(this.bookProgress);
-    },
-  },
-});

+ 127 - 0
modules/web/src/store/bookStore.ts

@@ -0,0 +1,127 @@
+import { defineStore } from 'pinia'
+import API from '@api'
+import type {
+  BaseBook,
+  Book,
+  BookChapter,
+  BookProgress,
+  SeachBook,
+} from '@/book'
+import type { webReadConfig } from '@/web'
+
+export const useBookStore = defineStore('book', {
+  state: () => {
+    return {
+      connectStatus: '正在连接后端服务器……',
+      connectType: 'primary' as 'primary' | 'success' | 'danger',
+      newConnect: true,
+      searchBooks: [] as SeachBook[],
+      shelf: [] as Book[],
+      catalog: [] as BookChapter[],
+      readingBook: {} as BaseBook & {
+        chapterPos: number
+        chapterIndex: number
+        isSeachBook?: boolean
+      },
+      popCataVisible: false,
+      contentLoading: true,
+      showContent: false,
+      config: {
+        theme: -1,
+        font: 0,
+        fontSize: 18,
+        readWidth: 800,
+        infiniteLoading: false,
+        customFontName: '',
+        jumpDuration: 1000,
+        spacing: {
+          paragraph: 1,
+          line: 0.8,
+          letter: 0,
+        },
+      } as webReadConfig,
+      miniInterface: false,
+      readSettingsVisible: false,
+    }
+  },
+  getters: {
+    bookProgress: (state): BookProgress | undefined => {
+      if (state.catalog.length == 0) return
+      const { chapterIndex, chapterPos, name, author } = state.readingBook
+      const title = state.catalog[chapterIndex]?.title
+      if (!title) return
+      return {
+        name,
+        author,
+        durChapterIndex: chapterIndex,
+        durChapterPos: chapterPos,
+        durChapterTime: new Date().getTime(),
+        durChapterTitle: title,
+      }
+    },
+    theme: state => {
+      return state.config.theme
+    },
+    configInited: state => state.config.theme !== -1,
+    isNight: state => state.config.theme == 6,
+  },
+  actions: {
+    setConnectStatus(connectStatus: string) {
+      this.connectStatus = connectStatus
+    },
+    setConnectType(connectType: 'primary' | 'success' | 'danger') {
+      this.connectType = connectType
+    },
+    setNewConnect(newConnect: boolean) {
+      this.newConnect = newConnect
+    },
+    addBooks(books: Book[]) {
+      this.shelf = books
+    },
+    clearBooks() {
+      this.shelf = []
+    },
+    setCatalog(catalog: BookChapter[]) {
+      this.catalog = catalog
+    },
+    setPopCataVisible(visible: boolean) {
+      this.popCataVisible = visible
+    },
+    setContentLoading(loading: boolean) {
+      this.contentLoading = loading
+    },
+    setReadingBook(readingBook: typeof this.readingBook) {
+      this.readingBook = readingBook
+    },
+    setConfig(config: webReadConfig) {
+      this.config = Object.assign({}, this.config, config)
+    },
+    setReadSettingsVisible(visible: boolean) {
+      this.readSettingsVisible = visible
+    },
+    setShowContent(visible: boolean) {
+      this.showContent = visible
+    },
+    setMiniInterface(mini: boolean) {
+      this.miniInterface = mini
+    },
+    async setSearchBooks(books: SeachBook[]) {
+      books.forEach(book => {
+        const isSeachBook = this.shelf.every(
+          item => item.bookUrl !== book.bookUrl,
+        )
+        if (isSeachBook === true) {
+          this.searchBooks.push(book)
+        }
+      })
+    },
+    clearSearchBooks() {
+      this.searchBooks = []
+    },
+    //保存进度到app
+    async saveBookProgress() {
+      if (!this.bookProgress) return Promise.resolve()
+      return API.saveBookProgress(this.bookProgress)
+    },
+  },
+})

+ 0 - 5
modules/web/src/store/index.js

@@ -1,5 +0,0 @@
-import { createPinia } from "pinia";
-
-export * from "./bookStore";
-export * from "./sourceStore";
-export default createPinia();

+ 5 - 0
modules/web/src/store/index.ts

@@ -0,0 +1,5 @@
+import { createPinia } from 'pinia'
+
+export * from './bookStore'
+export * from './sourceStore'
+export default createPinia()

+ 0 - 132
modules/web/src/store/sourceStore.js

@@ -1,132 +0,0 @@
-import { defineStore } from "pinia";
-import {
-  emptyBookSource,
-  emptyRssSource,
-  getSourceUniqueKey,
-  convertSourcesToMap,
-} from "@utils/souce";
-
-const isBookSource = /bookSource/i.test(location.href);
-const emptySource = isBookSource ? emptyBookSource : emptyRssSource;
-
-export const useSourceStore = defineStore("source", {
-  state: () => {
-    return {
-      /** @type {import("@/source").BookSoure[]} */
-      bookSources: [], // 临时存放所有书源,
-      /** @type {import("@/source").RssSource[]} */
-      rssSources: [], // 临时存放所有订阅源
-      /** @type {import("@/source").Source[]} */
-      savedSources: [], // 批量保存到阅读app成功的源
-      /** @type {import("@/source").Source} */
-      currentSource: JSON.parse(JSON.stringify(emptySource)), // 当前编辑的源
-      currentTab: localStorage.getItem("tabName") || "editTab",
-      editTabSource: {}, // 生成序列化的json数据
-      isDebuging: false,
-    };
-  },
-  getters: {
-    sources: (state) => (isBookSource ? state.bookSources : state.rssSources),
-    // @ts-ignore
-    sourcesMap: (state) => convertSourcesToMap(state.sources),
-    savedSourcesMap: (state) => convertSourcesToMap(state.savedSources),
-    currentSourceUrl: (state) =>
-      isBookSource
-        ? state.currentSource.bookSourceUrl
-        : state.currentSource.sourceUrl,
-    searchKey: (state) =>
-      isBookSource
-        ? state.currentSource.ruleSearch.checkKeyWord || "我的"
-        : null,
-  },
-  actions: {
-    startDebug() {
-      this.currentTab = "editDebug";
-      this.isDebuging = true;
-    },
-    debugFinish() {
-      this.isDebuging = false;
-    },
-
-    //拉取源后保存
-    saveSources(data) {
-      if (isBookSource) {
-        this.bookSources = data;
-      } else {
-        this.rssSources = data;
-      }
-    },
-    //批量推送
-    setPushReturnSources(returnSoures) {
-      this.savedSources = returnSoures;
-    },
-    //删除源
-    deleteSources(data) {
-      let sources = isBookSource ? this.bookSources : this.rssSources;
-      data.forEach((source) => {
-        let index = sources.indexOf(source);
-        if (index > -1) sources.splice(index, 1);
-      });
-    },
-    //保存当前编辑源
-    saveCurrentSource() {
-      let source = this.currentSource,
-        map = this.sourcesMap;
-      map.set(getSourceUniqueKey(source), JSON.parse(JSON.stringify(source)));
-      this.saveSources(Array.from(map.values()));
-    },
-    // 更改当前编辑的源qq
-    changeCurrentSource(source) {
-      this.currentSource = JSON.parse(JSON.stringify(source));
-    },
-    // update editTab tabName and editTab info
-    changeTabName(tabName) {
-      this.currentTab = tabName;
-      localStorage.setItem("tabName", tabName);
-    },
-    changeEditTabSource(source) {
-      this.editTabSource = JSON.parse(JSON.stringify(source));
-    },
-    editHistory(history) {
-      let historyObj;
-      if (localStorage.getItem("history")) {
-        historyObj = JSON.parse(localStorage.getItem("history"));
-        historyObj.new.push(history);
-        if (historyObj.new.length > 50) {
-          historyObj.new.shift();
-        }
-        if (historyObj.old.length > 50) {
-          historyObj.old.shift();
-        }
-        localStorage.setItem("history", JSON.stringify(historyObj));
-      } else {
-        const arr = { new: [history], old: [] };
-        localStorage.setItem("history", JSON.stringify(arr));
-      }
-    },
-    editHistoryUndo() {
-      if (localStorage.getItem("history")) {
-        let historyObj = JSON.parse(localStorage.getItem("history"));
-        historyObj.old.push(this.currentSource);
-        if (historyObj.new.length) {
-          this.currentSource = historyObj.new.pop();
-        }
-        localStorage.setItem("history", JSON.stringify(historyObj));
-      }
-    },
-    clearAllHistory() {
-      localStorage.setItem("history", JSON.stringify({ new: [], old: [] }));
-    },
-    clearEdit() {
-      this.editTabSource = {};
-      this.currentSource = JSON.parse(JSON.stringify(emptySource)); //复制一份新对象
-    },
-
-    // clear all source
-    clearAllSource() {
-      this.bookSources = [];
-      this.rssSources = [];
-      this.savedSources = [];
-    },
-  },
-});

+ 134 - 0
modules/web/src/store/sourceStore.ts

@@ -0,0 +1,134 @@
+import { defineStore } from 'pinia'
+import {
+  emptyBookSource,
+  emptyRssSource,
+  getSourceUniqueKey,
+  convertSourcesToMap,
+} from '@utils/souce'
+import type { BookSoure, RssSource, Source } from '@/source'
+
+const isBookSource = /bookSource/i.test(location.href)
+const emptySource = isBookSource ? emptyBookSource : emptyRssSource
+
+export const useSourceStore = defineStore('source', {
+  state: () => {
+    return {
+      bookSources: [] as BookSoure[], // 临时存放所有书源,
+      rssSources: [] as RssSource[], // 临时存放所有订阅源
+      savedSources: [] as Source[], // 批量保存到阅读app成功的源
+      currentSource: JSON.parse(JSON.stringify(emptySource)) as Source, // 当前编辑的源
+      currentTab: localStorage.getItem('tabName') || 'editTab',
+      editTabSource: {} as Source, // 生成序列化的json数据
+      isDebuging: false,
+    }
+  },
+  getters: {
+    sources: (state): Source[] =>
+      isBookSource ? state.bookSources : state.rssSources,
+    sourcesMap: function (): Map<string, Source> {
+      return convertSourcesToMap(this.sources)
+    },
+    savedSourcesMap: (state): Map<string, Source> =>
+      convertSourcesToMap(state.savedSources),
+    currentSourceUrl: state =>
+      isBookSource
+        ? (state.currentSource as BookSoure).bookSourceUrl
+        : (state.currentSource as RssSource).sourceUrl,
+    searchKey: (state): string =>
+      isBookSource
+        ? (state.currentSource as BookSoure)?.ruleSearch?.checkKeyWord || '我的'
+        : '',
+  },
+  actions: {
+    startDebug() {
+      this.currentTab = 'editDebug'
+      this.isDebuging = true
+    },
+    debugFinish() {
+      this.isDebuging = false
+    },
+
+    //拉取源后保存
+    saveSources(data: Source[]) {
+      if (isBookSource) {
+        this.bookSources = data as BookSoure[]
+      } else {
+        this.rssSources = data as RssSource[]
+      }
+    },
+    //批量推送
+    setPushReturnSources(returnSoures: Source[]) {
+      this.savedSources = returnSoures
+    },
+    //删除源
+    deleteSources(data: Source[]) {
+      const sources: Source[] = isBookSource
+        ? this.bookSources
+        : this.rssSources
+      data.forEach(source => {
+        const index = sources.indexOf(source)
+        if (index > -1) sources.splice(index, 1)
+      })
+    },
+    //保存当前编辑源
+    saveCurrentSource() {
+      const source = this.currentSource,
+        map = this.sourcesMap
+      map.set(getSourceUniqueKey(source), JSON.parse(JSON.stringify(source)))
+      this.saveSources(Array.from(map.values()))
+    },
+    // 更改当前编辑的源qq
+    changeCurrentSource(source: Source) {
+      this.currentSource = JSON.parse(JSON.stringify(source))
+    },
+    // update editTab tabName and editTab info
+    changeTabName(tabName: string) {
+      this.currentTab = tabName
+      localStorage.setItem('tabName', tabName)
+    },
+    changeEditTabSource(source: Source) {
+      this.editTabSource = JSON.parse(JSON.stringify(source))
+    },
+    editHistory(history: Source) {
+      let historyObj
+      if (localStorage.getItem('history')) {
+        historyObj = JSON.parse(localStorage.getItem('history')!)
+        historyObj.new.push(history)
+        if (historyObj.new.length > 50) {
+          historyObj.new.shift()
+        }
+        if (historyObj.old.length > 50) {
+          historyObj.old.shift()
+        }
+        localStorage.setItem('history', JSON.stringify(historyObj))
+      } else {
+        const arr = { new: [history], old: [] }
+        localStorage.setItem('history', JSON.stringify(arr))
+      }
+    },
+    editHistoryUndo() {
+      if (localStorage.getItem('history')) {
+        const historyObj = JSON.parse(localStorage.getItem('history')!)
+        historyObj.old.push(this.currentSource)
+        if (historyObj.new.length) {
+          this.currentSource = historyObj.new.pop()
+        }
+        localStorage.setItem('history', JSON.stringify(historyObj))
+      }
+    },
+    clearAllHistory() {
+      localStorage.setItem('history', JSON.stringify({ new: [], old: [] }))
+    },
+    clearEdit() {
+      this.editTabSource = {} as Source
+      this.currentSource = JSON.parse(JSON.stringify(emptySource)) //复制一份新对象
+    },
+
+    // clear all source
+    clearAllSource() {
+      this.bookSources = []
+      this.rssSources = []
+      this.savedSources = []
+    },
+  },
+})

+ 49 - 38
modules/web/src/utils/souce.ts

@@ -1,49 +1,60 @@
-import { Source } from '../source'
+import type { BookSoure, RssSource, Source } from '../source'
+import { isNullOrBlank } from './utils'
 
+const isBookSource = (source: Source): source is BookSoure =>
+  'bookSourceName' in source
 
-const isNullOrBlank = (string: string | null | undefined | number) => string == null || (string as string).length === 0 || /^\s+$/.test(string as string)
-const isBookSource = (source: Source) => "bookSourceName" in source
-
-export const isInvaildSource: (source: Source) => boolean = (source) => {
-    if (isBookSource(source)) {
-        return !isNullOrBlank(source.bookSourceName) &&
-            !isNullOrBlank(source.bookSourceUrl) &&
-            !isNullOrBlank(source.bookSourceType)
-    }
-    return !isNullOrBlank(source.sourceName) &&
-        !isNullOrBlank(source.sourceUrl)
+export const isInvaildSource: (source: Source) => boolean = source => {
+  if (isBookSource(source)) {
+    return (
+      !isNullOrBlank(source.bookSourceName) &&
+      !isNullOrBlank(source.bookSourceUrl) &&
+      !isNullOrBlank(source.bookSourceType)
+    )
+  }
+  return !isNullOrBlank(source.sourceName) && !isNullOrBlank(source.sourceUrl)
 }
 
-export const getSourceUniqueKey = (source: Source) => isBookSource(source) ? source.bookSourceUrl : source.sourceUrl;
+export const getSourceUniqueKey = (source: Source) =>
+  isBookSource(source) ? source.bookSourceUrl : source.sourceUrl
+export const getSourceName = (source: Source) =>
+  isBookSource(source) ? source.bookSourceName : source.sourceName
 
-export const isSourceMatches: (source: Source, searchKey: string) => boolean = (source, searchKey) => {
-    // TODO: 正则和普通字符串识别 识别 * . \ [ ] <= <! != = ?: () \d\w\s\...
-    if (isBookSource(source)) {
-        return (source.bookSourceName?.includes(searchKey) ||
-            source.bookSourceUrl?.includes(searchKey) ||
-            source.bookSourceGroup?.includes(searchKey) ||
-            source.bookSourceComment?.includes(searchKey)) ?? false
-    }
-    return (source.sourceName?.includes(searchKey) ||
-        source.sourceUrl?.includes(searchKey) ||
-        source.sourceGroup?.includes(searchKey) ||
-        source.sourceComment?.includes(searchKey)) ?? false
+export const isSourceMatches: (source: Source, searchKey: string) => boolean = (
+  source,
+  searchKey,
+) => {
+  // TODO: 正则和普通字符串识别 识别 * . \ [ ] <= <! != = ?: () \d\w\s\...
+  if (isBookSource(source)) {
+    return (
+      (source.bookSourceName.includes(searchKey) ||
+        source.bookSourceUrl.includes(searchKey) ||
+        source.bookSourceGroup?.includes(searchKey) ||
+        source.bookSourceComment?.includes(searchKey)) ??
+      false
+    )
+  }
+  return (
+    (source.sourceName.includes(searchKey) ||
+      source.sourceUrl.includes(searchKey) ||
+      source.sourceGroup?.includes(searchKey) ||
+      source.sourceComment?.includes(searchKey)) ??
+    false
+  )
 }
 
 export const convertSourcesToMap = (sources: Source[]): Map<string, Source> => {
-    const map = new Map();
-    sources.forEach((source) =>
-        map.set(getSourceUniqueKey(source), source)
-    );
-    return map;
+  const map = new Map()
+  sources.forEach(source => map.set(getSourceUniqueKey(source), source))
+  return map
 }
 
 export const emptyBookSource = {
-    ruleSearch: {},
-    ruleBookInfo: {},
-    ruleToc: {},
-    ruleContent: {},
-    ruleReview: {},
-    ruleExplore: {}
-}
-export const emptyRssSource = {}
+  ruleSearch: {},
+  ruleBookInfo: {},
+  ruleToc: {},
+  ruleContent: {},
+  ruleReview: {},
+  ruleExplore: {},
+} as BookSoure
+export const emptyRssSource = {} as RssSource

+ 0 - 31
modules/web/src/utils/utils.js

@@ -1,31 +0,0 @@
-import { formatDate } from "@vueuse/shared";
-
-export const isLegadoUrl = (/** @type {string} */ url) =>
-  /,\s*\{/.test(url) ||
-  !(
-    url.startsWith("http") ||
-    url.startsWith("data:") ||
-    url.startsWith("blob:")
-  );
-
-// @ts-ignore
-export const dateFormat = (/** @type {number} */ t) => {
-  let time = new Date().getTime();
-  let offset = Math.floor((time - t) / 1000);
-  let str = "";
-
-  if (offset <= 30) {
-    str = "刚刚";
-  } else if (offset < 60) {
-    str = offset + "秒前";
-  } else if (offset < 3600) {
-    str = Math.floor(offset / 60) + "分钟前";
-  } else if (offset < 86400) {
-    str = Math.floor(offset / 3600) + "小时前";
-  } else if (offset < 2592000) {
-    str = Math.floor(offset / 86400) + "天前";
-  } else {
-    str = formatDate(new Date(t), "YYYY-MM-DD");
-  }
-  return str;
-};

+ 55 - 0
modules/web/src/utils/utils.ts

@@ -0,0 +1,55 @@
+import { formatDate } from '@vueuse/shared'
+export const isNullOrBlank = (string: string | null | undefined | number) =>
+  string == null ||
+  (string as string).length === 0 ||
+  /^\s+$/.test(string as string)
+
+export const isLegadoUrl = (/** @type {string} */ url: string) =>
+  /,\s*\{/.test(url) ||
+  !(
+    url.startsWith('http') ||
+    url.startsWith('data:') ||
+    url.startsWith('blob:')
+  )
+
+/**
+ * 验证输入的URL是否符合阅读后端地址规则
+ * @param allowedProtocols 允许的协议,默认`["https:", "http:"]`
+ */
+export const validatorHttpUrl = (
+  http_url: string | URL,
+  allowedProtocols: string[] = ['https:', 'http:'],
+) => {
+  try {
+    const url = new URL(http_url)
+    const { protocol } = url
+    if (!allowedProtocols.includes(protocol))
+      throw new Error(
+        `Expected protocol ${allowedProtocols.join('/')}, but ${protocol}`,
+      )
+    return true
+  } catch {
+    return false
+  }
+}
+
+export const dateFormat = (/** @type {number} */ t: number) => {
+  const time = new Date().getTime()
+  const offset = Math.floor((time - t) / 1000)
+  let str = ''
+
+  if (offset <= 30) {
+    str = '刚刚'
+  } else if (offset < 60) {
+    str = offset + '秒前'
+  } else if (offset < 3600) {
+    str = Math.floor(offset / 60) + '分钟前'
+  } else if (offset < 86400) {
+    str = Math.floor(offset / 3600) + '小时前'
+  } else if (offset < 2592000) {
+    str = Math.floor(offset / 86400) + '天前'
+  } else {
+    str = formatDate(new Date(t), 'YYYY-MM-DD')
+  }
+  return str
+}

+ 300 - 275
modules/web/src/views/BookChapter.vue

@@ -106,25 +106,18 @@
   </div>
 </template>
 
-<script setup>
-import jump from "@/plugins/jump";
-import settings from "@/config/themeConfig";
-import API from "@api";
-import { useLoading } from "@/hooks/loading";
-import { useThrottleFn } from "@vueuse/shared";
-
-const content = ref();
+<script setup lang="ts">
+import jump from '@/plugins/jump'
+import settings from '@/config/themeConfig'
+import API from '@api'
+import { useLoading } from '@/hooks/loading'
+import { useThrottleFn } from '@vueuse/shared'
+import { isNullOrBlank } from '@/utils/utils'
+
+const content = ref()
 // loading spinner
-const { isLoading, loadingWrapper } = useLoading(content, "正在获取信息");
-const store = useBookStore();
-
-// 读取阅读配置
-try {
-  const browerConfig = JSON.parse(localStorage.getItem("config"));
-  if (browerConfig != null) store.setConfig(browerConfig);
-} catch {
-  localStorage.removeItem("config");
-}
+const { isLoading, loadingWrapper } = useLoading(content, '正在获取信息')
+const store = useBookStore()
 
 const {
   catalog,
@@ -135,232 +128,238 @@ const {
   bookProgress,
   theme,
   isNight,
-} = storeToRefs(store);
+} = storeToRefs(store)
 
 const chapterPos = computed({
   get: () => store.readingBook.chapterPos,
-  set: (value) => (store.readingBook.chapterPos = value),
-});
+  set: value => (store.readingBook.chapterPos = value),
+})
 const chapterIndex = computed({
-  get: () => store.readingBook.index,
-  set: (value) => (store.readingBook.index = value),
-});
+  get: () => store.readingBook.chapterIndex,
+  set: value => (store.readingBook.chapterIndex = value),
+})
+const isSeachBook = computed({
+  get: () => store.readingBook.isSeachBook,
+  set: value => (store.readingBook.isSeachBook = value),
+})
+
+// 当前阅读书籍readingBook持久化
+watch(
+  () => store.readingBook,
+  book => {
+    // 保存localStorage
+    // localStorage.setItem(book.bookUrl, JSON.stringify(book));
+    // 最近阅读
+    localStorage.setItem('readingRecent', JSON.stringify(book))
+    //保存 sessionStorage
+    sessionStorage.setItem('chapterIndex', book.chapterIndex.toString())
+    sessionStorage.setItem('chapterPos', book.chapterPos.toString())
+  },
+)
 
 // 无限滚动
-const infiniteLoading = computed(() => store.config.infiniteLoading);
-let scrollObserver;
-const loading = ref();
+const infiniteLoading = computed(() => store.config.infiniteLoading)
+let scrollObserver: IntersectionObserver | null
+const loading = ref()
 watchEffect(() => {
   if (!infiniteLoading.value) {
-    scrollObserver?.disconnect();
+    scrollObserver?.disconnect()
   } else {
-    scrollObserver?.observe(loading.value);
+    scrollObserver?.observe(loading.value)
   }
-});
+})
 const loadMore = () => {
-  let index = chapterData.value.slice(-1)[0].index;
+  const index = chapterData.value.slice(-1)[0].index
   if (catalog.value.length - 1 > index) {
-    getContent(index + 1, false);
-    store.saveBookProgress(); // 保存的是上一章的进度,不是预载的本章进度
+    getContent(index + 1, false)
+    store.saveBookProgress() // 保存的是上一章的进度,不是预载的本章进度
   }
-};
+}
 // IntersectionObserver回调 底部加载
-const onReachBottom = (entries) => {
-  if (isLoading.value) return;
-  for (let { isIntersecting } of entries) {
-    if (!isIntersecting) return;
-    loadMore();
+const onReachBottom = (entries: IntersectionObserverEntry[]) => {
+  if (isLoading.value) return
+  for (const { isIntersecting } of entries) {
+    if (!isIntersecting) return
+    loadMore()
   }
-};
+}
 
 // 字体
 const fontFamily = computed(() => {
   if (store.config.font >= 0) {
-    return settings.fonts[store.config.font];
+    return settings.fonts[store.config.font]
   }
-  return store.config.customFontName;
-});
+  return store.config.customFontName
+})
 const fontSize = computed(() => {
-  return store.config.fontSize + "px";
-});
+  return store.config.fontSize + 'px'
+})
 
 // 主题部分
-const bodyColor = computed(() => settings.themes[theme.value].body);
-const chapterColor = computed(() => settings.themes[theme.value].content);
-const popupColor = computed(() => settings.themes[theme.value].popup);
+const bodyColor = computed(() => settings.themes[theme.value].body)
+const chapterColor = computed(() => settings.themes[theme.value].content)
+const popupColor = computed(() => settings.themes[theme.value].popup)
 
 const readWidth = computed(() => {
   if (!miniInterface.value) {
-    return store.config.readWidth - 130 + "px";
+    return store.config.readWidth - 130 + 'px'
   } else {
-    return window.innerWidth + "px";
+    return window.innerWidth + 'px'
   }
-});
+})
 const popupWidth = computed(() => {
   if (!miniInterface.value) {
-    return store.config.readWidth - 33;
+    return store.config.readWidth - 33
   } else {
-    return window.innerWidth - 33;
+    return window.innerWidth - 33
   }
-});
+})
 const bodyTheme = computed(() => {
   return {
     background: bodyColor.value,
-  };
-});
+  }
+})
 const chapterTheme = computed(() => {
   return {
     background: chapterColor.value,
     width: readWidth.value,
-  };
-});
-const showToolBar = ref(false);
+  }
+})
+const showToolBar = ref(false)
 const leftBarTheme = computed(() => {
   return {
     background: popupColor.value,
     marginLeft: miniInterface.value
       ? 0
-      : -(store.config.readWidth / 2 + 68) + "px",
-    display: miniInterface.value && !showToolBar.value ? "none" : "block",
-  };
-});
+      : -(store.config.readWidth / 2 + 68) + 'px',
+    display: miniInterface.value && !showToolBar.value ? 'none' : 'block',
+  }
+})
 const rightBarTheme = computed(() => {
   return {
     background: popupColor.value,
     marginRight: miniInterface.value
       ? 0
-      : -(store.config.readWidth / 2 + 52) + "px",
-    display: miniInterface.value && !showToolBar.value ? "none" : "block",
-  };
-});
+      : -(store.config.readWidth / 2 + 52) + 'px',
+    display: miniInterface.value && !showToolBar.value ? 'none' : 'block',
+  }
+})
 
 /**
  * pc移动端判断 最大阅读宽度修正
  * 阅读宽度最小为640px 加上工具栏 68px 52px 取较大值 为 776px
  */
 const onResize = () => {
-  store.setMiniInterface(window.innerWidth < 776);
-  const width = store.config.readWidth; /**包含padding */
-  checkPageWidth(width);
-};
+  store.setMiniInterface(window.innerWidth < 776)
+  const width = store.config.readWidth /**包含padding */
+  checkPageWidth(width)
+}
 /** 判断阅读宽度是否超出页面 */
-const checkPageWidth = (readWidth) => {
-  if (store.miniInterface) return;
-  if (readWidth + 2 * 68 > window.innerWidth) store.config.readWidth -= 160;
-};
+const checkPageWidth = (readWidth: number) => {
+  if (store.miniInterface) return
+  if (readWidth + 2 * 68 > window.innerWidth) store.config.readWidth -= 160
+}
 watch(
   () => store.config.readWidth,
-  (width) => checkPageWidth(width),
-);
+  width => checkPageWidth(width),
+)
 // 顶部底部跳转
-const top = ref();
-const bottom = ref();
+const top = ref()
+const bottom = ref()
 const toTop = () => {
-  jump(top.value);
-};
+  jump(top.value)
+}
 const toBottom = () => {
-  jump(bottom.value);
-};
+  jump(bottom.value)
+}
 
 // 书架路由切换
-const router = useRouter();
+const router = useRouter()
 const toShelf = () => {
-  router.push("/");
-};
+  router.push('/')
+}
 
 // 获取章节内容
-const chapterData = ref([]);
-const noPoint = ref(true);
-const getContent = (index, reloadChapter = true, chapterPos = 0) => {
+const chapterData = ref<{ index: number; content: string[]; title: string }[]>(
+  [],
+)
+const noPoint = ref(true)
+const getContent = (index: number, reloadChapter = true, chapterPos = 0) => {
   if (reloadChapter) {
     //展示进度条
-    store.setShowContent(false);
+    store.setShowContent(false)
     //强制滚回顶层
-    jump(top.value, { duration: 0 });
+    jump(top.value, { duration: 0 })
     //从目录,按钮切换章节时保存进度 预加载时不保存
-    saveReadingBookProgressToBrowser(index, chapterPos);
-    chapterData.value = [];
+    saveReadingBookProgressToBrowser(index, chapterPos)
+    chapterData.value = []
   }
-  let bookUrl = sessionStorage.getItem("bookUrl");
-  let { title, index: chapterIndex } = catalog.value[index];
+  const bookUrl = store.readingBook.bookUrl
+  const { title, index: chapterIndex } = catalog.value[index]
 
   loadingWrapper(
     API.getBookContent(bookUrl, chapterIndex).then(
-      (res) => {
+      res => {
         if (res.data.isSuccess) {
-          let data = res.data.data;
-          let content = data.split(/\n+/);
-          chapterData.value.push({ index, content, title });
-          if (reloadChapter) toChapterPos(chapterPos);
+          const data = res.data.data
+          const content = data.split(/\n+/)
+          chapterData.value.push({ index, content, title })
+          if (reloadChapter) toChapterPos(chapterPos)
         } else {
-          ElMessage({ message: res.data.errorMsg, type: "error" });
-          let content = [res.data.errorMsg];
-          chapterData.value.push({ index, content, title });
+          ElMessage({ message: res.data.errorMsg, type: 'error' })
+          const content = [res.data.errorMsg]
+          chapterData.value.push({ index, content, title })
         }
-        store.setContentLoading(true);
-        noPoint.value = false;
-        store.setShowContent(true);
+        store.setContentLoading(true)
+        noPoint.value = false
+        store.setShowContent(true)
         if (!res.data.isSuccess) {
-          throw res.data;
+          throw res.data
         }
       },
-      (err) => {
-        ElMessage({ message: "获取章节内容失败", type: "error" });
-        let content = ["获取章节内容失败!"];
-        chapterData.value.push({ index, content, title });
-        store.setShowContent(true);
-        throw err;
+      err => {
+        ElMessage({ message: '获取章节内容失败', type: 'error' })
+        const content = ['获取章节内容失败!']
+        chapterData.value.push({ index, content, title })
+        store.setShowContent(true)
+        throw err
       },
     ),
-  );
-};
+  )
+}
 
 // 章节进度跳转和计算
-const chapter = ref();
-const chapterRef = ref();
-const toChapterPos = (pos) => {
+const chapter = ref()
+const chapterRef = ref()
+const toChapterPos = (pos: number) => {
   nextTick(() => {
     if (chapterRef.value.length === 1)
-      chapterRef.value[0].scrollToReadedLength(pos);
-  });
-};
+      chapterRef.value[0].scrollToReadedLength(pos)
+  })
+}
 
 // 60秒保存一次进度
 const saveBookProgressThrottle = useThrottleFn(
   () => store.saveBookProgress(),
   60000,
-);
+)
 
-const onReadedLengthChange = (index, pos) => {
-  saveReadingBookProgressToBrowser(index, pos);
-  saveBookProgressThrottle();
-};
+const onReadedLengthChange = (index: number, pos: number) => {
+  saveReadingBookProgressToBrowser(index, pos)
+  saveBookProgressThrottle()
+}
 
 // 文档标题
 watchEffect(() => {
-  document.title = catalog.value[chapterIndex.value]?.title || document.title;
-});
+  document.title = catalog.value[chapterIndex.value]?.title || document.title
+})
 
 // 阅读记录保存浏览器
-const saveReadingBookProgressToBrowser = (index, pos) => {
-  //保存localStorage
-  let bookUrl = sessionStorage.getItem("bookUrl");
-  var book = JSON.parse(localStorage.getItem(bookUrl));
-  book.index = index;
-  book.chapterPos = pos;
-  localStorage.setItem(bookUrl, JSON.stringify(book));
-  //最近阅读
-  book = JSON.parse(localStorage.getItem("readingRecent"));
-  book.chapterIndex = index;
-  book.chapterPos = pos;
-  localStorage.setItem("readingRecent", JSON.stringify(book));
-  //保存vuex
-  chapterIndex.value = index;
-  chapterPos.value = pos;
-  //保存sessionStorage
-  sessionStorage.setItem("chapterIndex", index);
-  sessionStorage.setItem("chapterPos", String(pos));
-};
+const saveReadingBookProgressToBrowser = (index: number, pos: number) => {
+  // 保存pinia
+  chapterIndex.value = index
+  chapterPos.value = pos
+}
 
 // 进度同步
 // 返回导航变化 同步请求会在获取书架前完成
@@ -371,186 +370,210 @@ const saveReadingBookProgressToBrowser = (index, pos) => {
  * 注意不用监听点击链接导航变化 不对Safari<14.5兼容处理
  **/
 const onVisibilityChange = () => {
-  if (document.visibilityState == "hidden") {
-    API.saveBookProgressWithBeacon(bookProgress.value);
+  const _bookProgress = bookProgress.value
+  if (document.visibilityState == 'hidden' && _bookProgress) {
+    API.saveBookProgressWithBeacon(_bookProgress)
   }
-};
+}
 // 定时同步
 
 // 章节切换
 const toNextChapter = () => {
-  store.setContentLoading(true);
-  let index = chapterIndex.value + 1;
-  if (typeof catalog.value[index] !== "undefined") {
+  store.setContentLoading(true)
+  const index = chapterIndex.value + 1
+  if (typeof catalog.value[index] !== 'undefined') {
     ElMessage({
-      message: "下一章",
-      type: "info",
-    });
-    getContent(index);
-    store.saveBookProgress();
+      message: '下一章',
+      type: 'info',
+    })
+    getContent(index)
+    store.saveBookProgress()
   } else {
     ElMessage({
-      message: "本章是最后一章",
-      type: "error",
-    });
+      message: '本章是最后一章',
+      type: 'error',
+    })
   }
-};
+}
 const toPreChapter = () => {
-  store.setContentLoading(true);
-  let index = chapterIndex.value - 1;
-  if (typeof catalog.value[index] !== "undefined") {
+  store.setContentLoading(true)
+  const index = chapterIndex.value - 1
+  if (typeof catalog.value[index] !== 'undefined') {
     ElMessage({
-      message: "上一章",
-      type: "info",
-    });
-    getContent(index);
-    store.saveBookProgress();
+      message: '上一章',
+      type: 'info',
+    })
+    getContent(index)
+    store.saveBookProgress()
   } else {
     ElMessage({
-      message: "本章是第一章",
-      type: "error",
-    });
+      message: '本章是第一章',
+      type: 'error',
+    })
   }
-};
+}
 
-let canJump = true;
+let canJump = true
 // 监听方向键
-const handleKeyPress = (event) => {
-  if (!canJump) return;
+const handleKeyPress = (event: KeyboardEvent) => {
+  if (!canJump) return
   switch (event.key) {
-    case "ArrowLeft":
-      event.stopPropagation();
-      event.preventDefault();
-      toPreChapter();
-      break;
-    case "ArrowRight":
-      event.stopPropagation();
-      event.preventDefault();
-      toNextChapter();
-      break;
-    case "ArrowUp":
-      event.stopPropagation();
-      event.preventDefault();
+    case 'ArrowLeft':
+      event.stopPropagation()
+      event.preventDefault()
+      toPreChapter()
+      break
+    case 'ArrowRight':
+      event.stopPropagation()
+      event.preventDefault()
+      toNextChapter()
+      break
+    case 'ArrowUp':
+      event.stopPropagation()
+      event.preventDefault()
       if (document.documentElement.scrollTop === 0) {
-        ElMessage.warning("已到达页面顶部");
+        ElMessage.warning('已到达页面顶部')
       } else {
-        canJump = false;
+        canJump = false
         jump(0 - document.documentElement.clientHeight + 100, {
           duration: store.config.jumpDuration,
           callback: () => (canJump = true),
-        });
+        })
       }
-      break;
-    case "ArrowDown":
-      event.stopPropagation();
-      event.preventDefault();
+      break
+    case 'ArrowDown':
+      event.stopPropagation()
+      event.preventDefault()
       if (
         document.documentElement.clientHeight +
           document.documentElement.scrollTop ===
         document.documentElement.scrollHeight
       ) {
-        ElMessage.warning("已到达页面底部");
+        ElMessage.warning('已到达页面底部')
       } else {
-        canJump = false;
+        canJump = false
         jump(document.documentElement.clientHeight - 100, {
           duration: store.config.jumpDuration,
           callback: () => (canJump = true),
-        });
+        })
       }
-      break;
+      break
   }
-};
+}
 
 // 阻止默认滚动事件
-const ignoreKeyPress = (event) => {
-  if (event.key === "ArrowUp" || event.key === "ArrowDown") {
-    event.preventDefault();
-    event.stopPropagation();
+const ignoreKeyPress = (event: {
+  key: string
+  preventDefault: () => void
+  stopPropagation: () => void
+}) => {
+  if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
+    event.preventDefault()
+    event.stopPropagation()
   }
-};
+}
 
 onMounted(() => {
   //获取书籍数据
-  let bookUrl = sessionStorage.getItem("bookUrl");
-  let bookName = sessionStorage.getItem("bookName");
-  let bookAuthor = sessionStorage.getItem("bookAuthor");
-  let chapterIndex = Number(sessionStorage.getItem("chapterIndex") || 0);
-  let chapterPos = Number(sessionStorage.getItem("chapterPos") || 0);
-  var book = JSON.parse(localStorage.getItem(bookUrl));
+  const bookUrl = sessionStorage.getItem('bookUrl')
+  const name = sessionStorage.getItem('bookName')
+  const author = sessionStorage.getItem('bookAuthor')
+  const chapterIndex = Number(sessionStorage.getItem('chapterIndex') || 0)
+  const chapterPos = Number(sessionStorage.getItem('chapterPos') || 0)
+  const isSeachBook = sessionStorage.getItem('isSeachBook') === 'true'
+  if (isNullOrBlank(bookUrl) || isNullOrBlank(name) || isNullOrBlank(author)) {
+    ElMessage.warning('书籍信息为空,即将自动返回书架页面...')
+    return setTimeout(toShelf, 500)
+  }
+  const book: typeof store.readingBook = {
+    // @ts-expect-error: bookUrl name author is NON_Blank string here
+    bookUrl,
+    // @ts-expect-error: bookUrl name author is NON_Blank string here
+    name,
+    // @ts-expect-error: bookUrl name author is NON_Blank string here
+    author,
+    chapterIndex,
+    chapterPos,
+    isSeachBook,
+  }
+  /*   const bookStr = localStorage.getItem(bookUrl);
+  if (isNullOrBlank(bookStr)) {
+    return setTimeout(toShelf, 500);
+  }
+  book = JSON.parse(bookStr as string);
   if (
     book == null ||
-    chapterIndex != book.index ||
+    chapterIndex != book.chapterIndex ||
     chapterPos != book.chapterPos
   ) {
     book = {
-      bookName: bookName,
-      bookAuthor: bookAuthor,
-      bookUrl: bookUrl,
-      index: chapterIndex,
-      chapterPos: chapterPos,
+      name: bookName!!,
+      author: bookAuthor!!,
+      bookUrl,
+      chapterIndex,
+      chapterPos,
+      isSeachBook
     };
     localStorage.setItem(bookUrl, JSON.stringify(book));
-  }
-  onResize();
-  window.addEventListener("resize", onResize);
+  } */
+  onResize()
+  window.addEventListener('resize', onResize)
   loadingWrapper(
-    API.getChapterList(bookUrl).then(
-      (res) => {
+    API.getChapterList(bookUrl as string).then(
+      res => {
         if (!res.data.isSuccess) {
-          ElMessage({ message: res.data.errorMsg, type: "error" });
-          setTimeout(toShelf, 500);
-          return;
+          ElMessage({ message: res.data.errorMsg, type: 'error' })
+          setTimeout(toShelf, 500)
+          return
         }
-        let data = res.data.data;
-        store.setCatalog(data);
-        store.setReadingBook(book);
+        const data = res.data.data
+        store.setCatalog(data)
+        store.setReadingBook(book)
 
-        getContent(chapterIndex, true, chapterPos);
-        window.addEventListener("keyup", handleKeyPress);
-        window.addEventListener("keydown", ignoreKeyPress);
+        getContent(chapterIndex, true, chapterPos)
+        window.addEventListener('keyup', handleKeyPress)
+        window.addEventListener('keydown', ignoreKeyPress)
         // 兼容Safari < 14
-        document.addEventListener("visibilitychange", onVisibilityChange);
+        document.addEventListener('visibilitychange', onVisibilityChange)
         //监听底部加载
         scrollObserver = new IntersectionObserver(onReachBottom, {
-          rootMargin: "-100% 0% 20% 0%",
-        });
-        infiniteLoading.value && scrollObserver.observe(loading.value);
+          rootMargin: '-100% 0% 20% 0%',
+        })
+        if (infiniteLoading.value === true)
+          scrollObserver.observe(loading.value)
         //第二次点击同一本书 页面标题不会变化
-        document.title = null;
-        document.title = bookName + " | " + catalog.value[chapterIndex].title;
+        document.title = '...'
+        document.title =
+          (name as string) + ' | ' + catalog.value[chapterIndex].title
       },
-      (err) => {
-        ElMessage({ message: "获取书籍目录失败", type: "error" });
-        throw err;
+      err => {
+        ElMessage({ message: '获取书籍目录失败', type: 'error' })
+        throw err
       },
     ),
-  );
-});
+  )
+})
 
 onUnmounted(() => {
-  window.removeEventListener("keyup", handleKeyPress);
-  window.removeEventListener("keydown", ignoreKeyPress);
-  window.removeEventListener("resize", onResize);
+  window.removeEventListener('keyup', handleKeyPress)
+  window.removeEventListener('keydown', ignoreKeyPress)
+  window.removeEventListener('resize', onResize)
   // 兼容Safari < 14
-  document.removeEventListener("visibilitychange", onVisibilityChange);
-  readSettingsVisible.value = false;
-  popCataVisible.value = false;
-  scrollObserver?.disconnect();
-  scrollObserver = null;
-});
+  document.removeEventListener('visibilitychange', onVisibilityChange)
+  readSettingsVisible.value = false
+  popCataVisible.value = false
+  scrollObserver?.disconnect()
+  scrollObserver = null
+})
 
 const addToBookShelfConfirm = async () => {
-  const bookUrl = sessionStorage.getItem("bookUrl");
-  const bookName = sessionStorage.getItem("bookName");
-  const isSeachBook = sessionStorage.getItem("isSeachBook");
-  const book = JSON.parse(localStorage.getItem(bookUrl));
-  sessionStorage.removeItem("isSeachBook");
+  const book = store.readingBook
   // 阅读的是搜索的书籍 并未在书架
-  if (isSeachBook === "true") {
-    await ElMessageBox.confirm(`是否将《${bookName}》放入书架?`, "放入书架", {
-      confirmButtonText: "确认",
-      cancelButtonText: "否",
-      type: "info",
+  if (book.isSeachBook === true) {
+    await ElMessageBox.confirm(`是否将《${book.name}》放入书架?`, '放入书架', {
+      confirmButtonText: '确认',
+      cancelButtonText: '否',
+      type: 'info',
       /*      
         ElMessageBox.confirm默认在触发hashChange事件时自动关闭
         按下物理返回键时触发hashChange事件
@@ -560,20 +583,22 @@ const addToBookShelfConfirm = async () => {
     })
       .then(() => {
         //选择是,无动作
+        isSeachBook.value = false
       })
       .catch(async () => {
         //选择否,删除书籍
-        await API.deleteBook(book);
-      });
+        await API.deleteBook(book)
+      })
+      .finally(() => sessionStorage.removeItem('isSeachBook'))
   }
-};
+}
 onBeforeRouteLeave(async (to, from, next) => {
-  console.log("onBeforeRouteLeave");
+  console.log('onBeforeRouteLeave')
   // 弹窗时停止响应按键翻页
-  window.removeEventListener("keyup", handleKeyPress);
-  await addToBookShelfConfirm();
-  next();
-});
+  window.removeEventListener('keyup', handleKeyPress)
+  await addToBookShelfConfirm()
+  next()
+})
 </script>
 
 <style lang="scss" scoped>
@@ -662,8 +687,8 @@ onBeforeRouteLeave(async (to, from, next) => {
   }
 
   .chapter {
-    font-family: "Microsoft YaHei", PingFangSC-Regular, HelveticaNeue-Light,
-      "Helvetica Neue Light", sans-serif;
+    font-family: 'Microsoft YaHei', PingFangSC-Regular, HelveticaNeue-Light,
+      'Helvetica Neue Light', sans-serif;
     text-align: left;
     padding: 0 65px;
     min-height: 100vh;
@@ -673,8 +698,8 @@ onBeforeRouteLeave(async (to, from, next) => {
     .content {
       font-size: 18px;
       line-height: 1.8;
-      font-family: "Microsoft YaHei", PingFangSC-Regular, HelveticaNeue-Light,
-        "Helvetica Neue Light", sans-serif;
+      font-family: 'Microsoft YaHei', PingFangSC-Regular, HelveticaNeue-Light,
+        'Helvetica Neue Light', sans-serif;
 
       .bottom-bar,
       .top-bar {

+ 268 - 300
modules/web/src/views/BookShelf.vue

@@ -27,7 +27,7 @@
               size="large"
               @click="
                 toDetail(
-                  readingRecent.url,
+                  readingRecent.bookUrl,
                   readingRecent.name,
                   readingRecent.author,
                   readingRecent.chapterIndex,
@@ -36,7 +36,7 @@
                   true,
                 )
               "
-              :class="{ 'no-point': readingRecent.url == '' }"
+              :class="{ 'no-point': readingRecent.bookUrl == '' }"
             >
               {{ readingRecent.name }}
             </el-tag>
@@ -78,309 +78,277 @@
   </div>
 </template>
 
-<script>
-import "@/assets/bookshelf.css";
-import "@/assets/fonts/shelffont.css";
-import { useBookStore } from "@/store";
-import githubUrl from "@/assets/imgs/github.png";
-import { useLoading } from "@/hooks/loading";
-import { Search as SearchIcon } from "@element-plus/icons-vue";
-import {baseURL_localStorage_key} from "@/api/axios"
+<script setup lang="ts">
+import '@/assets/bookshelf.css'
+import '@/assets/fonts/shelffont.css'
+import { useBookStore } from '@/store'
+import githubUrl from '@/assets/imgs/github.png'
+import { useLoading } from '@/hooks/loading'
+import { Search as SearchIcon } from '@element-plus/icons-vue'
+import { baseURL_localStorage_key } from '@/api/axios'
 import API, {
   legado_http_entry_point,
-  validatorHttpUrl,
-  setLeagdoHttpUrl,
-} from "@api";
-
-export default defineComponent({
-  beforeRouteEnter: (to, from, next) => {
-    API.getReadConfig()
-      .then((response) => response.data)
-      .then(({ isSuccess, data }) => {
-        if (isSuccess) {
-          next((vm) => {
-            console.log("初始化加载阅读界面配置成功");
-            // @ts-ignore
-            vm.saveReadConfig(data);
-          });
-        } else {
-          next();
-        }
-      })
-      .catch(() => next());
-  },
-  setup: () => {
-    const store = useBookStore();
-    const isNight = computed(() => store.isNight);
-
-    const readingRecent = ref({
-      name: "尚无阅读记录",
-      author: "",
-      url: "",
-      chapterIndex: 0,
-      chapterPos: 0,
-      isSeachBook: false,
-    });
-    const shelfWrapper = ref(null);
-    const { showLoading, closeLoading, loadingWrapper, isLoading } = useLoading(
-      shelfWrapper,
-      "正在获取书籍信息",
-    );
-
-    // 书架书籍和在线书籍搜索
-    const books = shallowRef([]);
-    const shelf = computed(() => store.shelf);
-    const searchWord = ref("");
-    const isSearching = ref(false);
-    watchEffect(() => {
-      if (isSearching.value && searchWord.value != "") return;
-      isSearching.value = false;
-      books.value = [];
-      if (searchWord.value == "") {
-        books.value = shelf.value;
-        return;
-      }
-      books.value = shelf.value.filter((book) => {
-        return (
-          book.name.includes(searchWord.value) ||
-          book.author.includes(searchWord.value)
-        );
-      });
-    });
-    //搜索在线书籍
-    const searchBook = () => {
-      if (searchWord.value == "") return;
-      books.value = [];
-      store.clearSearchBooks();
-      showLoading();
-      isSearching.value = true;
-      API.search(
-        searchWord.value,
-        (data) => {
-          if (isLoading) {
-            closeLoading();
-          }
-          try {
-            store.setSearchBooks(JSON.parse(data));
-            books.value = store.searchBooks;
-            //store.searchBooks.forEach((item) => books.value.push(item));
-          } catch (e) {
-            ElMessage.error("后端数据错误");
-            throw e;
-          }
-        },
-        () => {
-          closeLoading();
-          if (books.value.length == 0) {
-            ElMessage.info("搜索结果为空");
-          }
-        },
-      );
-    };
-
-    //连接状态
-    const connectStatus = computed(() => store.connectStatus);
-    const connectType = computed(() => store.connectType);
-    const newConnect = computed(() => store.newConnect);
-    const setLegadoRetmoteUrl = () => {
-      ElMessageBox.prompt(
-        "请输入 后端地址 ( 如:http://127.0.0.1:9527 或者通过内网穿透的地址)",
-        "提示",
-        {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          inputPlaceholder: legado_http_entry_point,
-          inputValidator: (value) => {
-            try {
-              validatorHttpUrl(value);
-            } catch (e) {
-              return e?.cause?.message ?? e.message;
-            }
-            return true;
-          },
-          beforeClose: (action, instance, done) => {
-            if (action === "confirm") {
-              store.setNewConnect(true);
-              instance.confirmButtonLoading = true;
-              instance.confirmButtonText = "校验中……";
-              // instance.inputValue
-              const url = new URL(instance.inputValue).toString();
-              API.testLeagdoHttpUrlConnection(url)
-                //API.getBookShelf()
-                .then(function (configStr) {
-                  saveReadConfig(configStr);
-                  instance.confirmButtonLoading = false;
-                  store.setConnectType("success");
-                  store.clearSearchBooks();
-                  store.setNewConnect(false);
-                  setLeagdoHttpUrl(url);
-                  if (url === location.origin) {
-                    localStorage.removeItem(baseURL_localStorage_key);
-                  } else {
-                    localStorage.setItem(baseURL_localStorage_key, url);
-                  }
-                  store.setConnectStatus("已连接 " + url.toString());
-                  fetchBookShelfData();
-                  done();
-                })
-                .catch(function (error) {
-                  instance.confirmButtonLoading = false;
-                  instance.confirmButtonText = "确定";
-                  ElMessage.error("访问失败,请检查您输入的 url");
-                  store.setNewConnect(false);
-                  throw error;
-                });
-            } else {
-              done();
-            }
-          },
-        },
-      );
-    };
-
-    const router = useRouter();
-    const handleBookClick = async (book) => {
-      const {
-        bookUrl,
-        name,
-        author,
-        durChapterIndex = 0,
-        durChapterPos = 0,
-      } = book;
-      // 判断是否为 searchBook
-      const isSeachBook = "respondTime" in book;
-      if (isSeachBook) {
-        await API.saveBook(book);
-      }
-      toDetail(
-        bookUrl,
-        name,
-        author,
-        durChapterIndex,
-        durChapterPos,
-        isSeachBook,
-      );
-    };
-    const toDetail = (
-      bookUrl,
-      bookName,
-      bookAuthor,
-      chapterIndex,
-      chapterPos,
-      isSeachBook,
-      fromReadRecentClick = false,
-    ) => {
-      if (bookName === "尚无阅读记录") return;
-      // 最近书籍不再书架上 自动搜索
-      if (isSeachBook === true && fromReadRecentClick) {
-        searchWord.value = bookName;
-        searchBook();
-        return;
+  parseLeagdoHttpUrlWithDefault,
+  setApiEntryPoint,
+} from '@api'
+import { validatorHttpUrl } from '@/utils/utils'
+import type { Book, SeachBook } from '@/book'
+import type { webReadConfig } from '@/web'
+
+const store = useBookStore()
+const isNight = computed(() => store.isNight)
+
+/** shortcuts of `store.setConfig` */
+const applyReadConfig = (config?: webReadConfig) => {
+  try {
+    if (config !== undefined) store.setConfig(config)
+  } catch {
+    ElMessage.info('阅读界面配置解析错误')
+  }
+}
+
+const readingRecent = ref<typeof store.readingBook>({
+  name: '尚无阅读记录',
+  author: '',
+  bookUrl: '',
+  chapterIndex: 0,
+  chapterPos: 0,
+  isSeachBook: false,
+})
+
+const shelfWrapper = ref<HTMLElement>()
+//const shelfWrapper = useTemplateRef<HTMLElement>("shelfWrapper")
+const { showLoading, closeLoading, loadingWrapper, isLoading } = useLoading(
+  shelfWrapper,
+  '正在获取书籍信息',
+)
+
+// 书架书籍和在线书籍搜索
+const books = shallowRef<Book[] | SeachBook[]>([])
+const shelf = computed(() => store.shelf)
+const searchWord = ref('')
+const isSearching = ref(false)
+watchEffect(() => {
+  if (isSearching.value && searchWord.value != '') return
+  isSearching.value = false
+  books.value = []
+  if (searchWord.value == '') {
+    books.value = shelf.value
+    return
+  }
+  books.value = shelf.value.filter(book => {
+    return (
+      book.name.includes(searchWord.value) ||
+      book.author.includes(searchWord.value)
+    )
+  })
+})
+//搜索在线书籍
+const searchBook = () => {
+  if (searchWord.value == '') return
+  books.value = []
+  store.clearSearchBooks()
+  showLoading()
+  isSearching.value = true
+  API.search(
+    searchWord.value,
+    searcBooks => {
+      if (isLoading) {
+        closeLoading()
       }
-      sessionStorage.setItem("bookUrl", bookUrl);
-      sessionStorage.setItem("bookName", bookName);
-      sessionStorage.setItem("bookAuthor", bookAuthor);
-      sessionStorage.setItem("chapterIndex", chapterIndex);
-      sessionStorage.setItem("chapterPos", chapterPos);
-      sessionStorage.setItem("isSeachBook", String(isSeachBook));
-      readingRecent.value = {
-        name: bookName,
-        author: bookAuthor,
-        url: bookUrl,
-        chapterIndex: chapterIndex,
-        chapterPos: chapterPos,
-        isSeachBook,
-      };
-      localStorage.setItem(
-        "readingRecent",
-        JSON.stringify(readingRecent.value),
-      );
-      router.push({
-        path: "/chapter",
-      });
-    };
-
-    const loadShelf = () => {
-      loadingWrapper(
-        store
-          .saveBookProgress()
-          //确保各种网络情况下同步请求先完成
-          .finally(fetchBookShelfData),
-      );
-    };
-
-    const saveReadConfig = (configStr) => {
       try {
-        store.setConfig(JSON.parse(configStr));
-      } catch {
-        ElMessage.info("阅读界面配置解析错误");
+        store.setSearchBooks(searcBooks)
+        books.value = store.searchBooks
+        //store.searchBooks.forEach((item) => books.value.push(item));
+      } catch (e) {
+        ElMessage.error('后端数据错误')
+        throw e
+      }
+    },
+    () => {
+      closeLoading()
+      if (books.value.length == 0) {
+        ElMessage.info('搜索结果为空')
       }
-    };
-
-    const fetchBookShelfData = () => {
-      return API.getBookShelf().then((response) => {
-        store.setConnectType("success");
-        if (response.data.isSuccess) {
-          //store.increaseBookNum(response.data.data.length);
-          store.addBooks(
-            response.data.data.sort(function (a, b) {
-              var x = a["durChapterTime"] || 0;
-              var y = b["durChapterTime"] || 0;
-              return y - x;
-            }),
-          );
+    },
+  )
+}
+
+//连接状态
+const connectStatus = computed(() => store.connectStatus)
+const connectType = computed(() => store.connectType)
+const newConnect = computed(() => store.newConnect)
+const setLegadoRetmoteUrl = () => {
+  ElMessageBox.prompt(
+    '请输入 后端地址 ( 如:http://127.0.0.1:9527 或者通过内网穿透的地址)',
+    '提示',
+    {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      inputPlaceholder: legado_http_entry_point,
+      inputValidator: value => validatorHttpUrl(value),
+      inputErrorMessage: '输入的格式不对',
+      beforeClose: (action, instance, done) => {
+        if (action === 'confirm') {
+          store.setNewConnect(true)
+          instance.confirmButtonLoading = true
+          instance.confirmButtonText = '校验中……'
+          // instance.inputValue
+          const url = new URL(instance.inputValue).toString()
+          API.getReadConfig(url)
+            //API.getBookShelf()
+            .then(function (config) {
+              applyReadConfig(config)
+              instance.confirmButtonLoading = false
+              store.setConnectType('success')
+              store.clearSearchBooks()
+              store.setNewConnect(false)
+              setApiEntryPoint(...parseLeagdoHttpUrlWithDefault(url))
+              if (url === location.origin) {
+                localStorage.removeItem(baseURL_localStorage_key)
+              } else {
+                localStorage.setItem(baseURL_localStorage_key, url)
+              }
+              store.setConnectStatus('已连接 ' + url.toString())
+              fetchBookShelfData()
+              done()
+            })
+            .catch(function (error) {
+              instance.confirmButtonLoading = false
+              instance.confirmButtonText = '确定'
+              ElMessage.error('访问失败,请检查您输入的 url')
+              store.setNewConnect(false)
+              throw error
+            })
         } else {
-          ElMessage.error(response.data.errorMsg ?? "后端返回格式错误!");
-        }
-        store.setConnectStatus("已连接 " + legado_http_entry_point);
-        store.setNewConnect(false);
-      });
-    };
-    onMounted(() => {
-      //获取最近阅读书籍
-      let readingRecentStr = localStorage.getItem("readingRecent");
-      if (readingRecentStr != null) {
-        readingRecent.value = JSON.parse(readingRecentStr);
-        if (typeof readingRecent.value.chapterIndex == "undefined") {
-          readingRecent.value.chapterIndex = 0;
+          done()
         }
+      },
+    },
+  )
+}
+
+const router = useRouter()
+const handleBookClick = async (book: SeachBook | Book) => {
+  // 判断是否为 searchBook
+  const isSeachBook = 'respondTime' in book
+  if (isSeachBook) {
+    await API.saveBook(book)
+  }
+  const {
+    bookUrl,
+    name,
+    author,
+    // @ts-expect-error: descruct with default value
+    durChapterIndex = 0,
+    // @ts-expect-error: descruct with default value
+    durChapterPos = 0,
+  } = book
+
+  toDetail(bookUrl, name, author, durChapterIndex, durChapterPos, isSeachBook)
+}
+const toDetail = (
+  bookUrl: string,
+  bookName: string,
+  bookAuthor: string,
+  chapterIndex: number,
+  chapterPos: number,
+  isSeachBook: boolean | undefined = false,
+  fromReadRecentClick = false,
+) => {
+  if (bookName === '尚无阅读记录') return
+  // 最近书籍不再书架上 自动搜索
+  if (
+    fromReadRecentClick &&
+    shelf.value.every(book => book.bookUrl !== bookUrl)
+  ) {
+    searchWord.value = bookName
+    searchBook()
+    return
+  }
+  sessionStorage.setItem('bookUrl', bookUrl)
+  sessionStorage.setItem('bookName', bookName)
+  sessionStorage.setItem('bookAuthor', bookAuthor)
+  sessionStorage.setItem('chapterIndex', String(chapterIndex))
+  sessionStorage.setItem('chapterPos', String(chapterPos))
+  sessionStorage.setItem('isSeachBook', String(isSeachBook))
+  readingRecent.value = {
+    name: bookName,
+    author: bookAuthor,
+    bookUrl,
+    chapterIndex,
+    chapterPos,
+    isSeachBook,
+  }
+  localStorage.setItem('readingRecent', JSON.stringify(readingRecent.value))
+  router.push({
+    path: '/chapter',
+  })
+}
+
+const loadShelf = async () => {
+  try {
+    if (store.configInited === false) {
+      const config = await API.getReadConfig()
+      applyReadConfig(config)
+    } else {
+    }
+    await store.saveBookProgress()
+    //确保各种网络情况下同步请求先完成
+    await fetchBookShelfData()
+  } catch (error: unknown) {
+    store.setConnectType('danger')
+    store.setConnectStatus('连接异常')
+    store.setNewConnect(false)
+    throw error
+  }
+}
+
+const fetchBookShelfData = () => {
+  return API.getBookShelf().then(response => {
+    store.setConnectType('success')
+    if (response.data.isSuccess) {
+      //store.increaseBookNum(response.data.data.length);
+      store.addBooks(
+        response.data.data.sort(function (a, b) {
+          const x = a['durChapterTime'] || 0
+          const y = b['durChapterTime'] || 0
+          return y - x
+        }),
+      )
+    } else {
+      if (
+        response.data.errorMsg.includes('还没有添加小说') &&
+        shelf.value.length > 0
+      ) {
+        ElNotification.warning({
+          title: '提示',
+          message: '当前书架上的书籍已经被删除',
+          position: 'bottom-right',
+        })
+        return store.clearBooks()
       }
-      console.log("bookshelf mounted");
-      API.testLeagdoHttpUrlConnection()
-        //.then(saveReadConfig) 应该在组件挂载前读取阅读配置
-        .then(loadShelf)
-        .catch(function (error) {
-          store.setConnectType("danger");
-          store.setConnectStatus("连接异常");
-          ElMessage.error(
-            "后端连接失败异常,请检查阅读WEB服务或者设置其它可用链接",
-          );
-          store.setNewConnect(false);
-          throw error;
-        });
-    });
-    return {
-      setLegadoRetmoteUrl,
-      isNight,
-      connectStatus,
-      connectType,
-      newConnect,
-      saveReadConfig, //expose it so beforeRouteEnter next can access it
-      readingRecent,
-      searchBook,
-      books,
-      handleBookClick,
-      toDetail,
-      isSearching,
-      SearchIcon,
-      githubUrl,
-      searchWord,
-    };
-  },
-});
+      ElMessage.error(response.data.errorMsg ?? '后端返回格式错误!')
+    }
+    store.setConnectStatus('已连接 ' + legado_http_entry_point)
+    store.setNewConnect(false)
+  })
+}
+onMounted(() => {
+  //获取最近阅读书籍
+  const readingRecentStr = localStorage.getItem('readingRecent')
+  if (readingRecentStr != null) {
+    readingRecent.value = JSON.parse(readingRecentStr)
+    if (typeof readingRecent.value.chapterIndex == 'undefined') {
+      readingRecent.value.chapterIndex = 0
+    }
+  }
+  console.log('bookshelf mounted')
+  loadingWrapper(loadShelf())
+})
 </script>
 
-<style lang="scss" scoped>
+<style scoped>
 .index-wrapper {
   height: 100%;
   width: 100%;
@@ -438,12 +406,12 @@ export default defineComponent({
 
         .recent-book {
           font-size: 10px;
-          // font-weight: 400;
+          /*           // font-weight: 400;
           // margin: 12px 0;
           // font-weight: 500;
-          // color: #6B7C87;
+          // color: #6B7C87; */
           cursor: pointer;
-          // padding: 6px 18px;
+          /*           // padding: 6px 18px; */
         }
       }
     }
@@ -464,7 +432,7 @@ export default defineComponent({
       .setting-connect {
         font-size: 8px;
         margin-top: 16px;
-        // color: #6B7C87;
+        /*         // color: #6B7C87; */
         cursor: pointer;
       }
     }
@@ -538,7 +506,7 @@ export default defineComponent({
 }
 
 .night {
-  :deep(.navigation-wrapper) {
+  .navigation-wrapper {
     background-color: #454545;
 
     .navigation-title {

+ 12 - 11
modules/web/src/views/SourceEditor.vue

@@ -5,22 +5,23 @@
     <source-tab-tools class="right" />
   </div>
 </template>
-<script setup>
-import bookSourceConfig from "@/config/bookSourceEditConfig";
-import rssSourceConfig from "@/config/rssSourceEditConfig";
-import "@/assets/sourceeditor.css";
-import { useDark } from "@vueuse/core";
+<script setup lang="ts">
+import bookSourceConfig from '@/config/bookSourceEditConfig'
+import rssSourceConfig from '@/config/rssSourceEditConfig'
+import '@/assets/sourceeditor.css'
+import { useDark } from '@vueuse/core'
+import type { SourceConfig } from '@/config/sourceConfig'
 
-useDark();
+useDark()
 
-let config;
+let config: SourceConfig
 
 if (/bookSource/i.test(location.href)) {
-  config = bookSourceConfig;
-  document.title = "书源管理";
+  config = bookSourceConfig as SourceConfig
+  document.title = '书源管理'
 } else {
-  config = rssSourceConfig;
-  document.title = "订阅源管理";
+  config = rssSourceConfig as SourceConfig
+  document.title = '订阅源管理'
 }
 </script>
 <style lang="scss" scoped>

+ 14 - 0
modules/web/src/web.d.ts

@@ -0,0 +1,14 @@
+export type webReadConfig = {
+  theme: number
+  font: number
+  fontSize: number
+  readWidth: number
+  infiniteLoading: boolean
+  customFontName: string
+  jumpDuration: number
+  spacing: {
+    paragraph: number
+    line: number
+    letter: number
+  }
+}

+ 16 - 0
modules/web/tsconfig.app.json

@@ -0,0 +1,16 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+  "exclude": ["src/**/__tests__/*"],
+  "compilerOptions": {
+    "composite": true,
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"],
+      "@api": ["./src/api"],
+      "@utils/*": ["./src/utils/*"]
+    }
+  }
+}

+ 11 - 0
modules/web/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    },
+    {
+      "path": "./tsconfig.app.json"
+    }
+  ],
+}

+ 19 - 0
modules/web/tsconfig.node.json

@@ -0,0 +1,19 @@
+{
+  "extends": "@tsconfig/node20/tsconfig.json",
+  "include": [
+    "vite.config.*",
+    "vitest.config.*",
+    "cypress.config.*",
+    "nightwatch.conf.*",
+    "playwright.config.*"
+  ],
+  "compilerOptions": {
+    "composite": true,
+    "noEmit": true,
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "types": ["node"]
+  }
+}

+ 7 - 6
modules/web/vite.config.js → modules/web/vite.config.ts

@@ -8,16 +8,16 @@ import Components from "unplugin-vue-components/vite";
 import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
 
 // https://vitejs.dev/config/
-export default ({ mode }) =>
-  defineConfig({
+export default defineConfig(({ mode }) => {
+  return {
     plugins: [
       vue(),
       AutoImport({
         imports: ["vue", "vue-router", "pinia"],
-        include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
-        dirs: ["src/components", "src/store"],
+        include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/],
+        dirs: ["src/components", "src/store", "*.d.ts"],
         eslintrc: {
-          enabled: true,
+          //enabled: true,
         },
         resolvers: [
           ElementPlusResolver(),
@@ -72,4 +72,5 @@ export default ({ mode }) =>
         },
       },
     },
-  });
+  }
+});