├── .editorconfig ├── .env ├── .env.dev ├── .env.prod ├── .github ├── FUNDING.yml └── workflows │ ├── build-android.yml │ ├── build-electron.yml │ └── codeql.yml ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── @types └── shim-router.d.ts ├── LICENSE ├── README.md ├── auto-imports.d.ts ├── docs ├── readme.md └── 缓存架构.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── icons.svg ├── icons │ ├── apple-icon-120x120.png │ ├── apple-icon-152x152.png │ ├── apple-icon-167x167.png │ ├── apple-icon-180x180.png │ ├── favicon-128x128.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── icon-128x128.png │ ├── icon-192x192.png │ ├── icon-256x256.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── ms-icon-144x144.png │ └── safari-pinned-tab.svg └── img │ ├── bg-paper-dark.jpeg │ ├── bg-paper.jpg │ └── note.png ├── quasar.config.ts ├── src-capacitor ├── capacitor.config.json ├── package-lock.json └── package.json ├── src-electron ├── electron-env.d.ts ├── electron-main.ts ├── electron-preload.ts └── icons │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src-pwa ├── custom-service-worker.ts ├── manifest.json ├── pwa-env.d.ts ├── register-service-worker.ts └── tsconfig.json ├── src ├── App.vue ├── boot │ ├── .gitkeep │ ├── app.ts │ ├── dayjs.ts │ ├── md-editor.ts │ ├── quasar.ts │ ├── quasar │ │ └── icon.ts │ └── v-viewer.ts ├── components │ ├── BlurHash.vue │ ├── BookCard.vue │ ├── Comment.vue │ ├── DragPageSticky.vue │ ├── SearchInput.vue │ ├── TelegramLoginTemp.vue │ ├── app │ │ ├── Container │ │ │ ├── AuthenticationGuard.vue │ │ │ ├── ImagePreview.vue │ │ │ └── index.vue │ │ ├── Header.vue │ │ ├── Side.vue │ │ ├── index.ts │ │ └── useLayout.ts │ ├── biz │ │ └── MyShelf │ │ │ └── AddToShelf.vue │ ├── grid │ │ ├── QGrid.vue │ │ ├── QGridItem.vue │ │ └── index.ts │ ├── html │ │ ├── Editor │ │ │ ├── Html.vue │ │ │ └── MarkDown.vue │ │ ├── HtmlEditor.vue │ │ └── HtmlReader.vue │ └── index.ts ├── composition │ ├── biz │ │ └── useInitRequest.ts │ ├── useFnLoading.ts │ ├── useIsActivated.ts │ ├── useMasonry.ts │ ├── useMedia.ts │ ├── useMergeState.ts │ ├── useResizeObserver.ts │ ├── useTimeoutFn.ts │ └── useToNowRef.ts ├── const │ ├── empty.ts │ ├── index.ts │ └── provide.ts ├── css │ ├── app.scss │ ├── mixin.scss │ ├── quasar.variables.scss │ └── read.scss ├── declarations.d.ts ├── directives │ └── longPress.ts ├── env.d.ts ├── global.d.ts ├── pages │ ├── Announcement │ │ ├── Announcement.vue │ │ ├── AnnouncementDetail.vue │ │ └── announcementFormat.ts │ ├── Book │ │ ├── BookInfo.vue │ │ ├── BookList.vue │ │ ├── BookRank.vue │ │ ├── EditChapter.vue │ │ ├── EditInfo.vue │ │ └── Read │ │ │ ├── Read.vue │ │ │ └── history.ts │ ├── Collaborator │ │ ├── List.vue │ │ ├── components │ │ │ └── Card.vue │ │ └── store │ │ │ ├── data.ts │ │ │ └── index.ts │ ├── Community.vue │ ├── Forum │ │ ├── List │ │ │ ├── components │ │ │ │ └── ForumList.vue │ │ │ └── index.vue │ │ └── index.vue │ ├── History.vue │ ├── Home.vue │ ├── Login │ │ ├── Index.vue │ │ ├── Login.vue │ │ ├── Register.vue │ │ ├── Reset.vue │ │ └── VueTurnstile.vue │ ├── MyShelf │ │ ├── List.vue │ │ └── components │ │ │ ├── NavBackToParentFolder.vue │ │ │ ├── RenameDialog.vue │ │ │ ├── ShelfBook.vue │ │ │ ├── ShelfCard.vue │ │ │ └── ShelfFolder.vue │ ├── Search.vue │ ├── Setting.vue │ ├── Test.vue │ └── User │ │ ├── BookEditor.vue │ │ ├── Profile.vue │ │ └── Publish.vue ├── router │ ├── index.ts │ └── routes.ts ├── services │ ├── apiServer.ts │ ├── book │ │ ├── index.ts │ │ └── types.ts │ ├── chapter │ │ ├── index.ts │ │ └── types.ts │ ├── comment │ │ ├── index.ts │ │ └── types.ts │ ├── context │ │ ├── index.ts │ │ └── type.ts │ ├── forum │ │ ├── index.ts │ │ └── types.ts │ ├── internal │ │ ├── ServerError.ts │ │ ├── readme │ │ └── request │ │ │ ├── createRequestQueue.ts │ │ │ ├── fetch.ts │ │ │ ├── getVisitorId.ts │ │ │ ├── index.ts │ │ │ └── signalr │ │ │ ├── RetryPolicy.ts │ │ │ ├── cache.ts │ │ │ ├── index.ts │ │ │ └── inspector.ts │ ├── path │ │ └── index.ts │ ├── types.ts │ ├── user │ │ ├── index.ts │ │ └── type.ts │ └── utils │ │ ├── index.ts │ │ ├── useCacheNotify.ts │ │ └── useServerNotify.ts ├── stores │ ├── app.ts │ ├── bookListData.ts │ ├── index.ts │ ├── plugin │ │ └── piniaLoading.ts │ ├── setting.ts │ ├── shelf.ts │ └── store-flag.d.ts ├── types │ ├── collaborator.ts │ ├── shelf.ts │ └── utils.ts └── utils │ ├── bbcode │ ├── index.ts │ └── simple.ts │ ├── biz │ └── unAuthenticationNotify.ts │ ├── createDirective.ts │ ├── dark.ts │ ├── debounceInFrame.ts │ ├── delay.ts │ ├── getErrMsg.ts │ ├── hash.ts │ ├── migrations │ └── shelf │ │ └── struct │ │ ├── action.ts │ │ └── types.ts │ ├── rateLimitQueue.ts │ ├── safeCall.ts │ ├── sanitizeHtml.ts │ ├── session.ts │ ├── sleep.ts │ ├── storage │ └── db │ │ ├── base.ts │ │ ├── index.ts │ │ └── memory.ts │ ├── thresholdInFrame.ts │ ├── time.ts │ └── useForwardRef.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # env得到的值全是字符串,无所谓是不是number/string了 2 | 3 | # 只有 VUE_ 开头的 env 变量会被导入项目逻辑中 4 | 5 | # 人机检查密钥 6 | VUE_CAPTCHA_SITE_KEY=0x4AAAAAAADgWLX3ngufVh5F 7 | # token有效期,ms;当前是30s 8 | VUE_APP_TOKEN_EXP_TIME=30000 9 | # APP标识,方便多实例共享localhost等域名时,区分cache前缀 10 | VUE_APP_NAME=LightNovelShelf 11 | # 版本号VER 12 | # VUE_APP_VER=0x00000001 13 | # 是否打印ws的返回信息,占位 14 | VUE_TRACE_SERVER= 15 | # session有效期,ms; 默认30s 16 | VUE_SESSION_TOKEN_VALIDITY=30000 -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | # 如果不是共享配置,用 .env.development.local 代替 2 | # https://next.cli.vuejs.org/guide/mode-and-env.html#environment-variables 3 | 4 | # 是否打印ws的返回信息 5 | VUE_TRACE_SERVER=1 6 | -------------------------------------------------------------------------------- /.env.prod: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/.env.prod -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ['wuyu8512'] 4 | custom: ['https://afdian.com/a/wuyu8512'] 5 | -------------------------------------------------------------------------------- /.github/workflows/build-android.yml: -------------------------------------------------------------------------------- 1 | name: Build CI For Android 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: 设置 JDK 17 | uses: actions/setup-java@v4 18 | with: 19 | java-version: '21' 20 | distribution: 'temurin' 21 | 22 | - name: 设置 Android SDK 23 | uses: android-actions/setup-android@v3 24 | 25 | - name: 设置环境变量 26 | run: export PATH=$PATH:$ANDROID_SDK_ROOT/tools; PATH=$PATH:$ANDROID_SDK_ROOT/platform-tools 27 | 28 | - name: 设置 Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '20.x' 32 | cache: 'npm' 33 | 34 | - name: 安装依赖 35 | run: npm i 36 | 37 | - name: 构建 Android 38 | run: npm run build:android 39 | 40 | - name: 上传 Apk 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: app-release-unsigned 44 | path: ./dist/capacitor/android/apk/release/app-release-unsigned.apk 45 | -------------------------------------------------------------------------------- /.github/workflows/build-electron.yml: -------------------------------------------------------------------------------- 1 | name: Build CI For Electron 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | strategy: 13 | matrix: 14 | os-version: [ubuntu-latest, windows-latest, macos-latest] 15 | 16 | runs-on: ${{ matrix.os-version }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: 设置 Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '22.x' 25 | cache: 'npm' 26 | 27 | - name: 安装依赖 28 | run: npm i 29 | 30 | - name: 构建 Electron 31 | run: npm run build:electron 32 | 33 | - name: 上传构建文件 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: ${{ matrix.os-version }} 37 | path: ./dist/electron/轻书架* 38 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '28 12 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | - name: Perform CodeQL Analysis 56 | uses: github/codeql-action/analyze@v2 57 | with: 58 | category: "/language:${{matrix.language}}" 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .thumbs.db 3 | node_modules 4 | 5 | # Quasar core related directories 6 | .quasar 7 | /dist 8 | /quasar.config.*.temporary.compiled* 9 | 10 | # Cordova related directories and files 11 | /src-cordova/node_modules 12 | /src-cordova/platforms 13 | /src-cordova/plugins 14 | /src-cordova/www 15 | 16 | # Capacitor related directories and files 17 | /src-capacitor/www 18 | /src-capacitor/node_modules 19 | 20 | # Log files 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Editor directories and files 26 | .idea 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | 32 | # local .env files 33 | .env.local* 34 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/.npmrc -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "singleQuote": true, 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "editorconfig.editorconfig", 6 | "vue.volar", 7 | "wayou.vscode-todo-highlight" 8 | ], 9 | "unwantedRecommendations": [ 10 | "octref.vetur", 11 | "hookyqr.beautify", 12 | "dbaeumer.jshint", 13 | "ms-vscode.vscode-typescript-tslint-plugin" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.bracketPairColorization.enabled": true, 3 | "editor.guides.bracketPairs": true, 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.codeActionsOnSave": ["source.fixAll.eslint"], 7 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"], 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "files.associations": { 10 | ".npmrc": "ini" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /@types/shim-router.d.ts: -------------------------------------------------------------------------------- 1 | // This can be directly added to any of your `.ts` files like `router.ts` 2 | // It can also be added to a `.d.ts` file, in which case you will need to add an export 3 | // to ensure it is treated as a module 4 | export {} 5 | 6 | import 'vue-router' 7 | 8 | declare module 'vue-router' { 9 | interface RouteMeta {} 10 | 11 | type HistoryLocation = string 12 | 13 | /** 14 | * Internal normalized version of {@link ScrollPositionCoordinates} that always 15 | * has `left` and `top` coordinates. 16 | * 17 | * @internal 18 | */ 19 | type _ScrollPositionNormalized = { 20 | behavior?: ScrollOptions['behavior'] 21 | left: number 22 | top: number 23 | } 24 | 25 | /** @link https://github.com/vuejs/router/blob/v4.0.15/src/history/html5.ts#L24-L31 */ 26 | interface StateEntry extends HistoryState { 27 | back: HistoryLocation | null 28 | current: HistoryLocation 29 | forward: HistoryLocation | null 30 | position: number 31 | replaced: boolean 32 | scroll: _ScrollPositionNormalized | null | false 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 轻书架 (light_novel_shelf) 2 | 3 | A Online Novel Reading Project 4 | 5 | ## Install the dependencies 6 | 7 | ```bash 8 | yarn 9 | # or 10 | npm install 11 | ``` 12 | 13 | ### Start the app in development mode (hot-code reloading, error reporting, etc.) 14 | 15 | ```bash 16 | quasar dev 17 | ``` 18 | 19 | ### Lint the files 20 | 21 | ```bash 22 | yarn lint 23 | # or 24 | npm run lint 25 | ``` 26 | 27 | ### Format the files 28 | 29 | ```bash 30 | yarn format 31 | # or 32 | npm run format 33 | ``` 34 | 35 | ### Build the app for production 36 | 37 | ```bash 38 | quasar build 39 | ``` 40 | 41 | ### Customize the configuration 42 | 43 | See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js). 44 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | markdown preview ext: 2 | 3 | - [shd101wyy.markdown-preview-enhanced](https://github.com/shd101wyy/vscode-markdown-preview-enhanced) 4 | -------------------------------------------------------------------------------- /docs/缓存架构.md: -------------------------------------------------------------------------------- 1 | ```mermaid 2 | sequenceDiagram 3 | 4 | participant C as Client 5 | participant S as Server 6 | 7 | opt first connect 8 | C ->> S: ws: hello 9 | S ->> C: connected 10 | end 11 | 12 | opt request 13 | C ->> S: /path?query 14 | S ->> C: reponse 15 | C -->> Cache: [save cache] 16 | end 17 | 18 | Note right of S: disconnect 19 | 20 | alt re-connect 21 | C -->> S: [waiting re-connect result] 22 | else re-connect success 23 | C ->> S: /path?query 24 | S ->> C: reponse 25 | else re-connect fail 26 | C ->> Cache: /path?query 27 | Cache ->> C: [return cache] 28 | end 29 | 30 | Note right of S: re-connected 31 | 32 | opt request 33 | C ->> S: /path?query 34 | S ->> C: reponse 35 | C -->> Cache: [save cache] 36 | end 37 | ``` 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import pluginQuasar from '@quasar/app-vite/eslint' 3 | import prettierSkipFormatting from '@vue/eslint-config-prettier/skip-formatting' 4 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' 5 | import importPlugin from 'eslint-plugin-import' 6 | import pluginVue from 'eslint-plugin-vue' 7 | 8 | export default [ 9 | { 10 | /** 11 | * Ignore the following files. 12 | * Please note that pluginQuasar.configs.recommended already ignores 13 | * the "node_modules" folder for you (and all other Quasar project 14 | * relevant folders and files). 15 | * 16 | * ESLint requires "ignores" key to be the only one in this object 17 | */ 18 | // ignores: [] 19 | }, 20 | 21 | ...pluginQuasar.configs.recommended(), 22 | js.configs.recommended, 23 | 24 | importPlugin.flatConfigs.recommended, 25 | { 26 | settings: { 27 | 'import/resolver': { 28 | typescript: { 29 | alwaysTryTypes: true, 30 | }, 31 | }, 32 | }, 33 | }, 34 | 35 | /** 36 | * https://eslint.vuejs.org 37 | * 38 | * pluginVue.configs.base 39 | * -> Settings and rules to enable correct ESLint parsing. 40 | * pluginVue.configs[ 'flat/essential'] 41 | * -> base, plus rules to prevent errors or unintended behavior. 42 | * pluginVue.configs["flat/strongly-recommended"] 43 | * -> Above, plus rules to considerably improve code readability and/or dev experience. 44 | * pluginVue.configs["flat/recommended"] 45 | * -> Above, plus rules to enforce subjective community defaults to ensure consistency. 46 | */ 47 | ...pluginVue.configs['flat/essential'], 48 | 49 | // https://github.com/vuejs/eslint-config-typescript 50 | ...defineConfigWithVueTs(vueTsConfigs.recommended), 51 | 52 | { 53 | languageOptions: { 54 | ecmaVersion: 'latest', 55 | sourceType: 'module', 56 | }, 57 | 58 | // add your custom rules here 59 | rules: { 60 | // https://eslint.org/docs/latest/rules/no-undef#handled_by_typescript 61 | // done by ts 62 | 'no-undef': 'off', 63 | 64 | 'prefer-promise-reject-errors': 'off', 65 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], 66 | 67 | // allow debugger during development only 68 | 'no-debugger': 'error', 69 | 'no-console': 'warn', 70 | 71 | // https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/order.md 72 | 'import/order': [ 73 | 'warn', 74 | { 75 | 'newlines-between': 'always', 76 | groups: ['builtin', 'external', 'internal', 'type'], 77 | pathGroups: [ 78 | { 79 | pattern: 'src/utils/**', 80 | group: 'internal', 81 | position: 'before', 82 | }, 83 | { 84 | pattern: '{src/,}stores/**', 85 | group: 'internal', 86 | position: 'before', 87 | }, 88 | { 89 | pattern: '{src/,}components{/,}**', 90 | group: 'internal', 91 | position: 'before', 92 | }, 93 | { 94 | pattern: '{src/,}composition{/,}**', 95 | group: 'internal', 96 | position: 'before', 97 | }, 98 | { 99 | pattern: '**/**.css', 100 | group: 'type', 101 | position: 'after', 102 | }, 103 | ], 104 | pathGroupsExcludedImportTypes: [], 105 | warnOnUnassignedImports: true, 106 | alphabetize: { 107 | order: 'asc', 108 | orderImportKind: 'asc', 109 | caseInsensitive: true, 110 | }, 111 | }, 112 | ], 113 | 'import/consistent-type-specifier-style': [2, 'prefer-top-level'], 114 | 115 | '@typescript-eslint/no-unused-vars': 0, 116 | '@typescript-eslint/no-require-imports': 0, 117 | '@typescript-eslint/no-explicit-any': 0, 118 | '@typescript-eslint/no-namespace': 0, 119 | '@typescript-eslint/no-empty-object-type': 0, 120 | '@typescript-eslint/no-unused-expressions': ['error', { allowShortCircuit: true }], 121 | 122 | 'vue/multi-word-component-names': 0, 123 | 'vue/block-lang': 0, 124 | }, 125 | }, 126 | 127 | prettierSkipFormatting, 128 | ] 129 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= productName %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "light_novel_shelf", 3 | "version": "0.0.1", 4 | "description": "A Online Novel Reading Project", 5 | "productName": "轻书架", 6 | "author": "wuyu ", 7 | "type": "module", 8 | "private": false, 9 | "scripts": { 10 | "lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"", 11 | "lint:fix": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\" --fix", 12 | "format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore", 13 | "dev": "quasar dev", 14 | "build": "quasar build", 15 | "build:pwa": "quasar build -m pwa", 16 | "build:android": "quasar build -m capacitor -T android", 17 | "build:ios": "quasar build -m capacitor -T ios", 18 | "build:electron": "quasar build -m electron", 19 | "postinstall": "quasar prepare" 20 | }, 21 | "dependencies": { 22 | "@fingerprintjs/fingerprintjs": "^4.6.0", 23 | "@joplin/turndown": "^4.0.79", 24 | "@joplin/turndown-plugin-gfm": "^1.0.61", 25 | "@microsoft/signalr": "^8.0.7", 26 | "@microsoft/signalr-protocol-msgpack": "^8.0.7", 27 | "@quasar/extras": "^1.16.17", 28 | "@vueuse/core": "^12.5.0", 29 | "blurhash": "^2.0.5", 30 | "cropperjs": "^1.6.2", 31 | "dayjs": "^1.11.13", 32 | "dompurify": "^3.2.4", 33 | "hash.js": "^1.1.7", 34 | "immer": "^10.1.1", 35 | "localforage": "^1.10.0", 36 | "lodash.isequal": "^4.5.0", 37 | "md-editor-v3": "^5.2.2", 38 | "minimasonry": "^1.3.2", 39 | "nanoid": "^5.0.9", 40 | "overlayscrollbars-vue": "^0.5.9", 41 | "pako": "^2.1.0", 42 | "pinia": "^2.3.1", 43 | "quasar": "^2.17.7", 44 | "register-service-worker": "^1.7.2", 45 | "screenfull": "^6.0.2", 46 | "v-viewer": "^3.0.21", 47 | "vue": "^3.5.13", 48 | "vue-router": "^4.5.0", 49 | "vuedraggable": "^4.1.0" 50 | }, 51 | "devDependencies": { 52 | "@electron/packager": "^18.3.6", 53 | "@eslint/js": "^9.20.0", 54 | "@quasar/app-vite": "^2.2.0", 55 | "@types/lodash.isequal": "^4.5.8", 56 | "@types/minimasonry": "^1.3.5", 57 | "@types/pako": "^2.0.3", 58 | "@types/sanitize-html": "^2.13.0", 59 | "@types/sortablejs": "^1.15.8", 60 | "@types/turndown": "^5.0.5", 61 | "@vue/eslint-config-prettier": "^10.2.0", 62 | "@vue/eslint-config-typescript": "^14.4.0", 63 | "autoprefixer": "^10.4.20", 64 | "electron": "^34.1.1", 65 | "eslint": "^9.20.1", 66 | "eslint-import-resolver-typescript": "^3.7.0", 67 | "eslint-plugin-import": "^2.31.0", 68 | "eslint-plugin-vue": "^9.32.0", 69 | "globals": "^15.14.0", 70 | "prettier": "^3.5.0", 71 | "typescript": "~5.7.3", 72 | "unplugin-auto-import": "^19.0.0", 73 | "vue-tsc": "^2.2.0", 74 | "workbox-build": "^7.3.0", 75 | "workbox-cacheable-response": "^7.3.0", 76 | "workbox-core": "^7.3.0", 77 | "workbox-expiration": "^7.3.0", 78 | "workbox-precaching": "^7.3.0", 79 | "workbox-routing": "^7.3.0", 80 | "workbox-strategies": "^7.3.0" 81 | }, 82 | "engines": { 83 | "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", 84 | "npm": ">= 6.13.4", 85 | "yarn": ">= 1.21.1" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | import autoprefixer from 'autoprefixer' 4 | // import rtlcss from 'postcss-rtlcss' 5 | 6 | export default { 7 | plugins: [ 8 | // https://github.com/postcss/autoprefixer 9 | autoprefixer({ 10 | overrideBrowserslist: [ 11 | 'last 4 Chrome versions', 12 | 'last 4 Firefox versions', 13 | 'last 4 Edge versions', 14 | 'last 4 Safari versions', 15 | 'last 4 Android versions', 16 | 'last 4 ChromeAndroid versions', 17 | 'last 4 FirefoxAndroid versions', 18 | 'last 4 iOS versions', 19 | ], 20 | }), 21 | 22 | // https://github.com/elchininet/postcss-rtlcss 23 | // If you want to support RTL css, then 24 | // 1. yarn/pnpm/bun/npm install postcss-rtlcss 25 | // 2. optionally set quasar.config.js > framework > lang to an RTL language 26 | // 3. uncomment the following line (and its import statement above): 27 | // rtlcss() 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/favicon.ico -------------------------------------------------------------------------------- /public/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /public/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/icons/apple-icon-167x167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/apple-icon-167x167.png -------------------------------------------------------------------------------- /public/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/icons/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/favicon-128x128.png -------------------------------------------------------------------------------- /public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/favicon-96x96.png -------------------------------------------------------------------------------- /public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/icon-256x256.png -------------------------------------------------------------------------------- /public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /public/img/bg-paper-dark.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/img/bg-paper-dark.jpeg -------------------------------------------------------------------------------- /public/img/bg-paper.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/img/bg-paper.jpg -------------------------------------------------------------------------------- /public/img/note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/public/img/note.png -------------------------------------------------------------------------------- /src-capacitor/capacitor.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "app.lightnovel.bookshelf", 3 | "appName": "轻书架", 4 | "webDir": "www" 5 | } 6 | -------------------------------------------------------------------------------- /src-capacitor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "light_novel_shelf", 3 | "version": "0.0.1", 4 | "description": "A Online Novel Reading Project", 5 | "author": "wuyu ", 6 | "private": true, 7 | "dependencies": { 8 | "@capacitor/app": "^6.0.0", 9 | "@capacitor/cli": "^6.0.0", 10 | "@capacitor/core": "^6.0.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src-electron/electron-env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | QUASAR_PUBLIC_FOLDER: string 4 | QUASAR_ELECTRON_PRELOAD_FOLDER: string 5 | QUASAR_ELECTRON_PRELOAD_EXTENSION: string 6 | APP_URL: string 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src-electron/electron-main.ts: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | 5 | import { app, BrowserWindow } from 'electron' 6 | 7 | // needed in case process is undefined under Linux 8 | const platform = process.platform || os.platform() 9 | 10 | const currentDir = fileURLToPath(new URL('.', import.meta.url)) 11 | 12 | let mainWindow: BrowserWindow | undefined 13 | 14 | function createWindow() { 15 | /** 16 | * Initial window options 17 | */ 18 | mainWindow = new BrowserWindow({ 19 | icon: path.resolve(currentDir, 'icons/icon.png'), // tray icon 20 | width: 1000, 21 | height: 600, 22 | useContentSize: true, 23 | webPreferences: { 24 | contextIsolation: true, 25 | // More info: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/electron-preload-script 26 | preload: path.resolve( 27 | currentDir, 28 | path.join( 29 | process.env.QUASAR_ELECTRON_PRELOAD_FOLDER, 30 | 'electron-preload' + process.env.QUASAR_ELECTRON_PRELOAD_EXTENSION, 31 | ), 32 | ), 33 | }, 34 | }) 35 | 36 | if (process.env.DEV) { 37 | mainWindow.loadURL(process.env.APP_URL) 38 | } else { 39 | mainWindow.loadFile('index.html') 40 | } 41 | 42 | if (process.env.DEBUGGING) { 43 | // if on DEV or Production with debug enabled 44 | mainWindow.webContents.openDevTools() 45 | } else { 46 | // we're on production; no access to devtools pls 47 | mainWindow.webContents.on('devtools-opened', () => { 48 | mainWindow?.webContents.closeDevTools() 49 | }) 50 | } 51 | 52 | mainWindow.on('closed', () => { 53 | mainWindow = undefined 54 | }) 55 | } 56 | 57 | app.whenReady().then(createWindow) 58 | 59 | app.on('window-all-closed', () => { 60 | if (platform !== 'darwin') { 61 | app.quit() 62 | } 63 | }) 64 | 65 | app.on('activate', () => { 66 | if (mainWindow === undefined) { 67 | createWindow() 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /src-electron/electron-preload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used specifically for security reasons. 3 | * Here you can access Nodejs stuff and inject functionality into 4 | * the renderer thread (accessible there through the "window" object) 5 | * 6 | * WARNING! 7 | * If you import anything from node_modules, then make sure that the package is specified 8 | * in package.json > dependencies and NOT in devDependencies 9 | * 10 | * Example (injects window.myAPI.doAThing() into renderer thread): 11 | * 12 | * import { contextBridge } from 'electron' 13 | * 14 | * contextBridge.exposeInMainWorld('myAPI', { 15 | * doAThing: () => {} 16 | * }) 17 | * 18 | * WARNING! 19 | * If accessing Node functionality (like importing @electron/remote) then in your 20 | * electron-main.ts you will need to set the following when you instantiate BrowserWindow: 21 | * 22 | * mainWindow = new BrowserWindow({ 23 | * // ... 24 | * webPreferences: { 25 | * // ... 26 | * sandbox: false // <-- to be able to import @electron/remote in preload script 27 | * } 28 | * } 29 | */ 30 | -------------------------------------------------------------------------------- /src-electron/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/src-electron/icons/icon.icns -------------------------------------------------------------------------------- /src-electron/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/src-electron/icons/icon.ico -------------------------------------------------------------------------------- /src-electron/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/src-electron/icons/icon.png -------------------------------------------------------------------------------- /src-pwa/custom-service-worker.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file (which will be your service worker) 3 | * is picked up by the build system ONLY if 4 | * quasar.config file > pwa > workboxMode is set to "InjectManifest" 5 | */ 6 | 7 | declare const self: ServiceWorkerGlobalScope & typeof globalThis & { skipWaiting: () => void } 8 | 9 | import { clientsClaim } from 'workbox-core' 10 | import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching' 11 | import { registerRoute, NavigationRoute } from 'workbox-routing' 12 | 13 | self.skipWaiting() 14 | clientsClaim() 15 | 16 | // Use with precache injection 17 | precacheAndRoute(self.__WB_MANIFEST) 18 | 19 | cleanupOutdatedCaches() 20 | 21 | // Non-SSR fallbacks to index.html 22 | // Production SSR fallbacks to offline.html (except for dev) 23 | if (process.env.MODE !== 'ssr' || process.env.PROD) { 24 | registerRoute( 25 | new NavigationRoute(createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML), { 26 | denylist: [new RegExp(process.env.PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/], 27 | }), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src-pwa/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "orientation": "portrait", 3 | "background_color": "#ffffff", 4 | "theme_color": "#027be3", 5 | "icons": [ 6 | { 7 | "src": "icons/icon-128x128.png", 8 | "sizes": "128x128", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "icons/icon-192x192.png", 13 | "sizes": "192x192", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "icons/icon-256x256.png", 18 | "sizes": "256x256", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "icons/icon-384x384.png", 23 | "sizes": "384x384", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "icons/icon-512x512.png", 28 | "sizes": "512x512", 29 | "type": "image/png" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src-pwa/pwa-env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | SERVICE_WORKER_FILE: string 4 | PWA_FALLBACK_HTML: string 5 | PWA_SERVICE_WORKER_REGEX: string 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src-pwa/register-service-worker.ts: -------------------------------------------------------------------------------- 1 | import { Notify } from 'quasar' 2 | import { register } from 'register-service-worker' 3 | 4 | // The ready(), registered(), cached(), updatefound() and updated() 5 | // events passes a ServiceWorkerRegistration instance in their arguments. 6 | // ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration 7 | 8 | register(process.env.SERVICE_WORKER_FILE, { 9 | // The registrationOptions object will be passed as the second argument 10 | // to ServiceWorkerContainer.register() 11 | // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter 12 | 13 | // registrationOptions: { scope: './' }, 14 | 15 | ready(/* registration */) { 16 | // console.log('Service worker is active.') 17 | }, 18 | 19 | registered(/* registration */) { 20 | // console.log('Service worker has been registered.') 21 | }, 22 | 23 | cached(/* registration */) { 24 | // console.log('Content has been cached for offline use.') 25 | }, 26 | 27 | updatefound(/* registration */) { 28 | // console.log('New content is downloading.') 29 | }, 30 | 31 | updated(/* registration */) { 32 | // console.log('New content is available; please refresh.') 33 | Notify.create({ 34 | message: '有新内容可用了,请刷新或重新打开网页', 35 | position: 'bottom', 36 | timeout: 0, 37 | type: 'positive', 38 | }) 39 | }, 40 | 41 | offline() { 42 | // console.log('No internet connection found. App is running in offline mode.') 43 | }, 44 | 45 | error(/* err */) { 46 | // console.error('Error during service worker registration:', err) 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /src-pwa/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["WebWorker", "ESNext"] 5 | }, 6 | "include": ["*.ts", "*.d.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /src/boot/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LightNovelShelf/Web/21891d4e8ad8bf7313f8c51b44e23b439812681e/src/boot/.gitkeep -------------------------------------------------------------------------------- /src/boot/app.ts: -------------------------------------------------------------------------------- 1 | import { defineBoot } from '#q-app/wrappers' 2 | 3 | let app = null 4 | 5 | export default defineBoot(({ app: _app }) => { 6 | app = _app 7 | }) 8 | 9 | export { app } 10 | -------------------------------------------------------------------------------- /src/boot/dayjs.ts: -------------------------------------------------------------------------------- 1 | import { defineBoot } from '#q-app/wrappers' 2 | import dayjs from 'dayjs' 3 | import zh from 'dayjs/locale/zh' 4 | import isSameOrAfter from 'dayjs/plugin/isSameOrAfter' 5 | import relativeTime from 'dayjs/plugin/relativeTime' 6 | import updateLocale from 'dayjs/plugin/updateLocale' 7 | 8 | export default defineBoot(() => { 9 | dayjs.extend(relativeTime) 10 | dayjs.extend(isSameOrAfter) 11 | dayjs.extend(updateLocale) 12 | 13 | dayjs.locale(zh) 14 | dayjs.updateLocale(zh.name, { relativeTime: { ...zh.relativeTime, s: '%d秒' } }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/boot/md-editor.ts: -------------------------------------------------------------------------------- 1 | import { defineBoot } from '#q-app/wrappers' 2 | import Cropper from 'cropperjs' 3 | import { config } from 'md-editor-v3' 4 | import * as prettier from 'prettier' 5 | import parserMarkdown from 'prettier/plugins/markdown' 6 | import screenfull from 'screenfull' 7 | 8 | export default defineBoot(() => { 9 | config({ 10 | editorExtensions: { 11 | prettier: { 12 | prettierInstance: prettier, 13 | parserMarkdownInstance: parserMarkdown, 14 | }, 15 | screenfull: { 16 | instance: screenfull, 17 | }, 18 | cropper: { 19 | instance: Cropper, 20 | }, 21 | }, 22 | markdownItPlugins(plugins) { 23 | return plugins.map((p) => { 24 | if (p.type === 'image') { 25 | return { 26 | ...p, 27 | plugin: (md, pluginOptions) => { 28 | md.renderer.rules.image = function (tokens, idx, options, env, self) { 29 | const token = tokens[idx] 30 | const src = token.attrs[token.attrIndex('src')][1] 31 | // 将src后的hash作为图片样式 32 | const hash = src.split('#')[1] || '' 33 | return `` 34 | } 35 | 36 | // 判断段落中是否只有图片,如果是则返回 div.illus,否则返回 p 37 | md.renderer.rules.paragraph_open = function (tokens, idx, options, env, self) { 38 | const nextToken = tokens[idx + 1] 39 | if (nextToken && nextToken.children?.every((t) => t.type === 'image')) { 40 | return '
' 41 | } 42 | return '

' 43 | } 44 | 45 | md.renderer.rules.paragraph_close = function (tokens, idx, options, env, self) { 46 | const prevToken = tokens[idx - 1] 47 | if (prevToken && prevToken.children?.every((t) => t.type === 'image')) { 48 | return '

' 49 | } 50 | return '

' 51 | } 52 | }, 53 | options: {}, 54 | } 55 | } 56 | 57 | return p 58 | }) 59 | }, 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/boot/quasar.ts: -------------------------------------------------------------------------------- 1 | import { defineBoot } from '#q-app/wrappers' 2 | import { Dark, Quasar } from 'quasar' 3 | import mdiIconSet from 'quasar/icon-set/svg-mdi-v7.js' 4 | 5 | import { Dark as DarkSet } from 'src/utils/dark' 6 | 7 | import * as myIcons from './quasar/icon' 8 | 9 | export default defineBoot(() => { 10 | Dark.set(DarkSet.get()) 11 | Quasar.iconSet.set(mdiIconSet) 12 | Quasar.iconSet.iconMapFn = (iconName) => { 13 | const icon = myIcons[iconName] 14 | if (icon !== undefined) { 15 | return { icon } 16 | } 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /src/boot/quasar/icon.ts: -------------------------------------------------------------------------------- 1 | export { 2 | mdiMagnify, 3 | mdiArrowLeft, 4 | mdiArrowRight, 5 | mdiMenu, 6 | mdiHome, 7 | mdiInformation, 8 | mdiBullhorn, 9 | mdiBook, 10 | mdiForum, 11 | mdiBroadcast, 12 | mdiBroadcastOff, 13 | mdiBell, 14 | mdiBrightnessAuto, 15 | mdiHistory, 16 | mdiMessageText, 17 | mdiCog, 18 | mdiHelpCircle, 19 | mdiMessageAlert, 20 | mdiAccountMultiple, 21 | mdiChevronRight, 22 | mdiChevronLeft, 23 | mdiSkipNext, 24 | mdiSkipPrevious, 25 | mdiAccountCircle, 26 | mdiAccountOutline, 27 | mdiEmail, 28 | mdiLock, 29 | mdiSend, 30 | mdiEye, 31 | mdiAlertCircle, 32 | mdiAlert, 33 | mdiExclamation, 34 | mdiFormatSize, 35 | mdiTune, 36 | mdiPalette, 37 | mdiGradientVertical, 38 | mdiFolderHeartOutline, 39 | mdiHeartOutline, 40 | mdiHeartRemoveOutline, 41 | mdiLogoutVariant, 42 | mdiRefresh, 43 | mdiHeart, 44 | mdiPlus, 45 | mdiClose, 46 | mdiCheckCircle, 47 | mdiCheckboxBlankCircleOutline, 48 | mdiMenuDown, 49 | mdiChevronUp, 50 | mdiChevronDown, 51 | mdiArrowUp, 52 | mdiArrowDown, 53 | mdiShieldCheck, 54 | mdiEyeOff, 55 | mdiAccount, 56 | mdiCloseCircle, 57 | mdiFolderOpen, 58 | mdiFormatListBulleted, 59 | mdiDelete, 60 | mdiFullscreen, 61 | mdiFullscreenExit, 62 | mdiDragVariant, 63 | mdiFire, 64 | mdiPinwheel, 65 | mdiFormatBold, 66 | mdiFormatItalic, 67 | mdiFormatStrikethrough, 68 | mdiFormatUnderline, 69 | mdiFormatListNumbered, 70 | mdiFormatSubscript, 71 | mdiFormatSuperscript, 72 | mdiLink, 73 | mdiFormatQuoteClose, 74 | mdiFormatAlignCenter, 75 | mdiFormatAlignRight, 76 | mdiFormatAlignJustify, 77 | mdiPrinter, 78 | mdiFormatIndentDecrease, 79 | mdiFormatIndentIncrease, 80 | mdiFormatClear, 81 | mdiFormatColorText, 82 | mdiFormatAlignLeft, 83 | mdiMinus, 84 | mdiUndo, 85 | mdiRedo, 86 | mdiFormatHeader1, 87 | mdiFormatHeader2, 88 | mdiFormatHeader3, 89 | mdiFormatHeader4, 90 | mdiFormatHeader5, 91 | mdiFormatHeader6, 92 | mdiCodeTags, 93 | mdiNumeric1Box, 94 | mdiNumeric2Box, 95 | mdiNumeric3Box, 96 | mdiNumeric4Box, 97 | mdiNumeric5Box, 98 | mdiNumeric6Box, 99 | mdiNumeric7Box, 100 | mdiFormatFont, 101 | mdiContentSave, 102 | mdiSquareEditOutline, 103 | mdiWalletOutline, 104 | mdiWeatherNight, 105 | mdiWeatherSunny, 106 | mdiAccountCog, 107 | mdiPen, 108 | mdiFuriganaHorizontal, 109 | mdiAccountSupervisor, 110 | mdiCalendarRangeOutline, 111 | mdiTicketConfirmation, 112 | mdiLinkVariant, 113 | mdiCalendarAccount, 114 | mdiCheck, 115 | mdiPlusBox, 116 | mdiCloudUpload, 117 | mdiNotificationClearAll, 118 | mdiCheckAll, 119 | mdiController, 120 | mdiWeb, 121 | mdiVideo, 122 | mdiImage, 123 | mdiStar, 124 | mdiCommentMultiple, 125 | mdiCircleDouble, 126 | mdiLockPlus, 127 | } from '@quasar/extras/mdi-v7' 128 | -------------------------------------------------------------------------------- /src/boot/v-viewer.ts: -------------------------------------------------------------------------------- 1 | import { defineBoot } from '#q-app/wrappers' 2 | /* eslint-disable-next-line */ 3 | import Viewer from 'v-viewer' 4 | 5 | export default defineBoot(({ app }) => { 6 | app.use(Viewer, { 7 | defaultOptions: { 8 | navbar: false, 9 | }, 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/components/BlurHash.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/BookCard.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 71 | 72 | 127 | -------------------------------------------------------------------------------- /src/components/DragPageSticky.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/components/SearchInput.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 102 | 103 | -------------------------------------------------------------------------------- /src/components/TelegramLoginTemp.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 82 | -------------------------------------------------------------------------------- /src/components/app/Container/AuthenticationGuard.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 26 | -------------------------------------------------------------------------------- /src/components/app/Container/ImagePreview.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 36 | 37 | 42 | -------------------------------------------------------------------------------- /src/components/app/Container/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/app/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppHeader } from './Header.vue' 2 | export { default as AppSide } from './Side.vue' 3 | export { default as AppContainer } from './Container/index.vue' 4 | -------------------------------------------------------------------------------- /src/components/app/useLayout.ts: -------------------------------------------------------------------------------- 1 | import { inject, computed, reactive, toRefs } from 'vue' 2 | 3 | const layoutStore = reactive({ 4 | siderShow: false, 5 | siderBreakpoint: 1023, 6 | headerHeight: 58, 7 | }) 8 | 9 | export const useLayout = () => { 10 | const ql = inject('_q_l_') as any 11 | 12 | return { 13 | ...toRefs(layoutStore), 14 | headerOffset: computed(() => ql.header.offset), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/biz/MyShelf/AddToShelf.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 | 68 | -------------------------------------------------------------------------------- /src/components/grid/QGrid.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/components/grid/QGridItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/components/grid/index.ts: -------------------------------------------------------------------------------- 1 | import { defineAsyncComponent } from 'vue' 2 | 3 | export const QGrid = defineAsyncComponent(() => import('./QGrid.vue')) 4 | export const QGridItem = defineAsyncComponent(() => import('./QGridItem.vue')) 5 | -------------------------------------------------------------------------------- /src/components/html/HtmlEditor.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/html/HtmlReader.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 109 | 110 | 119 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { defineAsyncComponent } from 'vue' 2 | 3 | export const HtmlEditor = defineAsyncComponent(() => import('./html/HtmlEditor.vue')) 4 | export const Comment = defineAsyncComponent(() => import('./Comment.vue')) 5 | export const BlurHash = defineAsyncComponent(() => import('./BlurHash.vue')) 6 | export const TelegramLoginTemp = defineAsyncComponent(() => import('./TelegramLoginTemp.vue')) 7 | export const DragPageSticky = defineAsyncComponent(() => import('./DragPageSticky.vue')) 8 | -------------------------------------------------------------------------------- /src/composition/biz/useInitRequest.ts: -------------------------------------------------------------------------------- 1 | import { onActivated, onMounted, onDeactivated } from 'vue' 2 | import { onBeforeRouteLeave, useRoute } from 'vue-router' 3 | 4 | import type { UseTimeoutFnAction } from '../useTimeoutFn' 5 | import type { AnyFunc } from 'src/types/utils' 6 | import type { Ref } from 'vue' 7 | 8 | /** 请求初始化流程 */ 9 | export function useInitRequest( 10 | cb: UseTimeoutFnAction<[], Promise> | AnyFunc, 11 | config?: { 12 | before?: AnyFunc 13 | after?: AnyFunc 14 | isActive?: Ref 15 | onlyRouteEnter?: boolean 16 | }, 17 | ) { 18 | let first = true 19 | 20 | const call = async () => { 21 | if (config?.before) config?.before() 22 | if ('syncCall' in cb) await cb.syncCall() 23 | else cb() 24 | if (config?.after) config?.after() 25 | } 26 | 27 | // 在每次判断路由为前进或第一次进入时加载数据 28 | onMounted(async () => { 29 | first = false 30 | await call() 31 | }) 32 | 33 | const router = useRoute() 34 | onActivated(async () => { 35 | if (first && (router.meta.reload || (config?.isActive ? !config?.isActive?.value : false))) { 36 | await call() 37 | } 38 | }) 39 | 40 | if (config?.onlyRouteEnter) { 41 | onBeforeRouteLeave(() => (first = true)) 42 | } else { 43 | onDeactivated(() => (first = true)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/composition/useFnLoading.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | import type { AnyFunc } from 'src/types/utils' 4 | import type { Ref } from 'vue' 5 | 6 | export interface UseFnLoadingReturn

extends AnyFunc { 7 | loading: Ref 8 | } 9 | 10 | /** 包裹函数,返回函数执行loading */ 11 | export function useLoadingFn

(fn: AnyFunc): UseFnLoadingReturn { 12 | /** 13 | * loading相关的变量 14 | * 15 | * @description 16 | * 记录promise是因为要记录产生loading对应的promise,这样的话,假如两次快速调用函数,也不会因为第一次返回了就把loading写为false 17 | */ 18 | const context = [null, ref(false)] as [unknown, Ref] 19 | const [, loading] = context 20 | 21 | function _fn(...args: P): R { 22 | loading.value = true 23 | 24 | /** 运行结果 */ 25 | const evalResult = (() => { 26 | try { 27 | return fn(...args) 28 | } catch (e) { 29 | return Promise.reject(e) 30 | } 31 | })() 32 | 33 | // 记录context 34 | context[0] = evalResult 35 | 36 | Promise.resolve(evalResult).finally(() => { 37 | // 判断这个loading是不是自己的context的 38 | if (context[0] === evalResult) { 39 | // 如果是,清除conetxt记录避免内存泄露 40 | context[0] = null 41 | // 然后重置loading 42 | loading.value = false 43 | } 44 | // 如果loading是别人的,那就等别人去重置 45 | }) 46 | 47 | return evalResult as R 48 | } 49 | 50 | _fn.loading = loading 51 | 52 | return _fn 53 | } 54 | -------------------------------------------------------------------------------- /src/composition/useIsActivated.ts: -------------------------------------------------------------------------------- 1 | import { onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, toRaw } from 'vue' 2 | import { onBeforeRouteLeave, useRoute } from 'vue-router' 3 | 4 | import type { Ref } from 'vue' 5 | 6 | /** keep-alive的组件是否激活展示 */ 7 | export function useIsActivated(): Ref { 8 | const isActivated = ref(true) 9 | // toRaw得到第一次挂载时所在路由 10 | const route = toRaw(useRoute()) 11 | 12 | onMounted(() => (isActivated.value = true)) 13 | onActivated(() => (isActivated.value = true)) 14 | 15 | /** 16 | * 使用 onBeforeRouteLeave 是因为 onDeactivated 的触发 晚于 useRoute 的改变 17 | * 这会导致外部判断 isActivated 不够准确: 18 | * 即使往别的页面走了, `watch(() => [route, isActivated], () => {})` 也还是因为 isActivated 为 true 而 无法知道用户已经要走了 19 | */ 20 | onBeforeRouteLeave((to, from, next) => { 21 | isActivated.value = to.name === route.name 22 | 23 | next() 24 | }) 25 | onDeactivated(() => (isActivated.value = false)) 26 | onBeforeUnmount(() => (isActivated.value = false)) 27 | 28 | return isActivated 29 | } 30 | -------------------------------------------------------------------------------- /src/composition/useMasonry.ts: -------------------------------------------------------------------------------- 1 | import MiniMasonry from 'minimasonry' 2 | import { onMounted, onBeforeUnmount, ref } from 'vue' 3 | 4 | import type { Ref } from 'vue' 5 | 6 | export interface UseMasonryAction { 7 | layout: () => void 8 | destroy: () => void 9 | } 10 | 11 | export function useMasonry(ele: Ref): UseMasonryAction { 12 | /** masonry实例 */ 13 | const instance = ref(null) 14 | 15 | onMounted(() => { 16 | instance.value = new MiniMasonry({ 17 | container: ele.value, 18 | surroundingGutter: false, 19 | gutter: 15, 20 | }) 21 | }) 22 | 23 | const actions: UseMasonryAction = { 24 | layout: () => instance.value?.layout(), 25 | destroy: () => instance.value?.destroy(), 26 | } 27 | 28 | onBeforeUnmount(actions.destroy) 29 | 30 | return actions 31 | } 32 | -------------------------------------------------------------------------------- /src/composition/useMedia.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch, onUnmounted } from 'vue' 2 | 3 | import type { Ref } from 'vue' 4 | 5 | /** 6 | * 返回当前屏幕是否匹配传入的query 7 | * 8 | * @example 9 | * ``` 10 | * useMedia(ref('(min-width: 1080px)')) 11 | * ``` 12 | */ 13 | export function useMedia(query: Ref, defaultVal = false): Ref { 14 | /** 是否匹配 */ 15 | const isMatch = ref(defaultVal) 16 | 17 | let mql: MediaQueryList = window.matchMedia(query.value) 18 | 19 | /** 因为 MediaQueryList change的时候不会触发vue的渲染,所以这里用一个callback来单独触发一次 */ 20 | const matchChangeHandle = () => { 21 | isMatch.value = mql.matches 22 | } 23 | mql.addEventListener('change', matchChangeHandle) 24 | 25 | // query里变化了就重新监听 26 | watch(query, (nextQuery) => { 27 | /** 先清理 */ 28 | mql.removeEventListener('change', matchChangeHandle) 29 | /** 重建 */ 30 | mql = window.matchMedia(nextQuery) 31 | /** 重新监听 */ 32 | mql.addEventListener('change', matchChangeHandle) 33 | }) 34 | 35 | onUnmounted(() => { 36 | mql.removeEventListener('change', matchChangeHandle) 37 | }) 38 | 39 | return isMatch 40 | } 41 | -------------------------------------------------------------------------------- /src/composition/useMergeState.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, watch } from 'vue' 2 | 3 | import type { Ref, WritableComputedRef } from 'vue' 4 | 5 | /** @private */ 6 | const nil = Symbol() 7 | 8 | /** @private */ 9 | type Nil = typeof nil 10 | 11 | export interface UseMergeStateAction { 12 | reset(): void 13 | } 14 | 15 | /** 实现 有内部中间状态的 受控逻辑 */ 16 | export function useMergeState(propsValue: Ref): [WritableComputedRef, UseMergeStateAction] { 17 | const state = ref(nil) 18 | 19 | const val = computed({ 20 | get() { 21 | return state.value === nil ? propsValue.value : (state.value as T) 22 | }, 23 | set(newVal) { 24 | state.value = newVal 25 | }, 26 | }) 27 | 28 | function reset() { 29 | state.value = nil 30 | } 31 | 32 | watch(propsValue, reset, { flush: 'sync' }) 33 | 34 | return [val, { reset }] 35 | } 36 | -------------------------------------------------------------------------------- /src/composition/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted } from 'vue' 2 | 3 | import type { Ref } from 'vue' 4 | 5 | export interface UseResizeObserverAction { 6 | observer: ResizeObserver 7 | } 8 | 9 | /** 对某个元素初始化resize监视 */ 10 | export function useResizeObserver(ref: Ref, cb: () => void): UseResizeObserverAction { 11 | const observer = new ResizeObserver(cb) 12 | 13 | onMounted(() => { 14 | observer.observe(ref.value) 15 | }) 16 | 17 | onUnmounted(() => { 18 | observer.disconnect() 19 | }) 20 | 21 | return { observer } 22 | } 23 | -------------------------------------------------------------------------------- /src/composition/useTimeoutFn.ts: -------------------------------------------------------------------------------- 1 | import { computed, onDeactivated, onUnmounted, ref } from 'vue' 2 | 3 | import { NOOP } from 'src/const/empty' 4 | 5 | import type { AnyVoidFunc, AnyAsyncFunc, AnyFunc } from 'src/types/utils' 6 | import type { Ref } from 'vue' 7 | 8 | import { useLoadingFn } from './useFnLoading' 9 | 10 | /** 延时执行 */ 11 | export interface UseTimeoutFnAction

extends AnyAsyncFunc> { 12 | /** 同步执行cb,不走延时 */ 13 | syncCall: AnyFunc 14 | /** 手动取消 */ 15 | cancel: AnyVoidFunc 16 | /** loading,从调用开始置为true(包括delay等待的时间),函数调用后置为false */ 17 | loading: Ref 18 | } 19 | 20 | /** 延时执行配置项 */ 21 | export interface UseTimeoutFnConfig { 22 | /** 自动取消,设置为 false 可以阻止自动取消行为 */ 23 | cancelOnUnMount?: boolean 24 | } 25 | 26 | /** 27 | * cancel错误,方便业务判断是cb错误还是只是取消 28 | * 29 | * @example 30 | * ```js 31 | * const fn = useTimeoutFn(function () { throw new Error('test') }) 32 | * fn().catch(e => e === CANCEL_ERR ? ( console.log('取消执行'); ) : ( console.log('执行错误'); ) ) 33 | * ``` 34 | */ 35 | export const CANCEL_ERR = new Error('cancel') 36 | 37 | /** 38 | * onActivated回调延时 39 | * 40 | * @description 41 | * 这个延时是为了解决 42 | * **用户在快速后退页面时,会在 onActivated 周期产生大量无用请求** 43 | * 的问题 44 | */ 45 | const DELAY_MS = 200 46 | 47 | /** 48 | * 延时执行 49 | * 50 | * @description 51 | * 当组件被 deactivate 或者 unmount 的时候就取消相关cb的执行计划 52 | * 53 | * @description 54 | * 重复调用时只有最后一次调用有效 55 | * 56 | * @example 57 | * ```js 58 | * const fn = useTimeoutFn(function () { fetch('baidu.com') }); 59 | * 60 | * fn().catch( 61 | * e => e === CANCEL_ERR 62 | * ? ( console.log('取消执行') ) 63 | * : ( console.log('执行错误') ) 64 | * ) 65 | * ``` 66 | */ 67 | export function useTimeoutFn

( 68 | cb: AnyFunc, 69 | delay: number = DELAY_MS, 70 | config?: UseTimeoutFnConfig, 71 | ): UseTimeoutFnAction { 72 | let timeoutContext: NodeJS.Timeout | undefined 73 | let rejector: ((err?: unknown) => void) | undefined 74 | /** 是否有执行计划 */ 75 | const scheduled = ref(false) 76 | 77 | /** 清理context相关变量 */ 78 | function reset() { 79 | timeoutContext = undefined 80 | rejector = undefined 81 | scheduled.value = false 82 | } 83 | 84 | /** 给函数包裹上loading标志 */ 85 | const _cb = useLoadingFn(cb) 86 | 87 | function fn(...args: P) { 88 | // 取消上一个执行计划,实现单发 89 | fn.cancel() 90 | 91 | const promise = new Promise((resolve, reject) => { 92 | scheduled.value = true 93 | rejector = reject 94 | 95 | timeoutContext = setTimeout(() => { 96 | try { 97 | resolve(_cb(...args)) 98 | } catch (e) { 99 | reject(e) 100 | } 101 | 102 | reset() 103 | }, delay) 104 | }) 105 | 106 | // 兜底错误,避免开发控制台一直报错 107 | // 一定要用then来兜底,用catch的话会使外部的catch无法触发从而无法监听取消事件或者cb执行错误事件 108 | promise.then(NOOP, NOOP) 109 | 110 | return promise 111 | } 112 | 113 | fn.syncCall = function (...args: P): R { 114 | scheduled.value = true 115 | 116 | const evalResult = _cb(...args) 117 | Promise.resolve(evalResult).finally(reset) 118 | return evalResult 119 | } 120 | fn.cancel = function () { 121 | timeoutContext && clearTimeout(timeoutContext) 122 | rejector && rejector(CANCEL_ERR) 123 | reset() 124 | } 125 | 126 | fn.loading = computed(() => scheduled.value || _cb.loading.value) 127 | 128 | // 组件卸载时取消执行 129 | onDeactivated(() => config?.cancelOnUnMount !== false && fn.cancel()) 130 | onUnmounted(() => config?.cancelOnUnMount !== false && fn.cancel()) 131 | 132 | // activated 时不管,这里只负责取消,业务自己确定调用时机,避免出现无法取消的多余执行 133 | 134 | return fn 135 | } 136 | -------------------------------------------------------------------------------- /src/composition/useToNowRef.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { toValue } from 'vue' 3 | 4 | import { parseTime, toNow } from 'src/utils/time' 5 | 6 | import type { Dayjs } from 'dayjs' 7 | import type { MaybeRefOrGetter } from 'vue' 8 | 9 | /** 10 | * 返回一个定时刷新的 'xx天前' 文案 11 | * 12 | * @example 13 | * ```ts 14 | * const book = ref<{ LastUpdateTime: Date }>({ LastUpdateTime: new Date() }) 15 | * onMounted(async () => { 16 | * Promise.resolve().then(() => { 17 | * book.value.LastUpdateTime = new Date() 18 | * }) 19 | * }) 20 | * 21 | * const lastUpdateTimeSourceRef = computed(() => book.value.LastUpdateTime) 22 | * const lastUpdateTime = useToNow(lastUpdateTimeSourceRef) 23 | * 24 | * // 这样合起来写也行↓ 25 | * // const lastUpdateTime = useToNow(() => book.value.LastUpdateTime) 26 | * 27 | * return { lastUpdateTime } 28 | * ``` 29 | */ 30 | export function useToNowRef( 31 | dateGetter: MaybeRefOrGetter, 32 | ): ComputedRef { 33 | const dateRef = computed(() => { 34 | const dateVal = toValue(dateGetter) 35 | if (!dateVal) { 36 | return null 37 | } 38 | 39 | return parseTime(dateVal) 40 | }) 41 | 42 | /** 43 | * 最后一次更新 nowRef 对应的 dateRef 值 44 | * 45 | * @description 46 | * 新增这个值的用意是应对vue的组件复用; 47 | * 复用的组件不会重新跑setup也就不会重新构建nowRef, 48 | * 导致会出现前一帧时间还在正常更新,下一帧来了新的dateRef,与缓存中的nowRef去进行toNow调用了 49 | */ 50 | const lastDateRef = shallowRef(unref(dateRef)) 51 | const nowRef = shallowRef(dayjs()) 52 | 53 | function refreshNowRef() { 54 | nowRef.value = dayjs() 55 | // 更新 nowRef 时也更新 lastDateRef 56 | // 直接赋值同一个对象,免掉对象内存申请 57 | lastDateRef.value = dateRef.value 58 | } 59 | 60 | // 刷新nowRef 61 | watchEffect((onClean) => { 62 | if ( 63 | // 如果date传进来是空值,保证一次 nowRef 是最新的 64 | // 防止now是几千年前存下来的值 65 | !unref(dateRef) || 66 | // 如果date和lastDate不同,更新一次nowRef 67 | // 防止now是几个小时前的值但新的date是个几分钟的值,变相出现未来值了 68 | // 这里故意使用引用全等,意在强调 lastDate 与 date 是公用一份内存(/一个对象)的 69 | unref(dateRef) !== unref(lastDateRef) 70 | ) { 71 | refreshNowRef() 72 | } 73 | 74 | // 注意先更新,后取值 75 | const date = unref(dateRef) 76 | const now = unref(nowRef) 77 | 78 | if ( 79 | // 如果date传进来是空值,无需定时刷新 80 | // 无效时间无需刷新 81 | !date || 82 | // 如果date跟现在不在同一天,不刷了,爱咋咋滴 83 | !date.isSame(now, 'day') 84 | ) { 85 | return 86 | } 87 | 88 | // 秒级别的差异,每秒刷新一次 89 | // => "x秒前" 90 | if (date.diff(now, 'second') < 60) { 91 | const timeout = setTimeout(refreshNowRef, 1_000 * 1) 92 | 93 | onClean(() => clearTimeout(timeout)) 94 | return 95 | } 96 | 97 | // 分钟级别的差异,每分钟刷新一次 98 | // => "x分钟前" 99 | if (date.diff(now, 'minute') < 60) { 100 | const timeout = setTimeout(refreshNowRef, 1_000 * 60) 101 | onClean(() => clearTimeout(timeout)) 102 | return 103 | } 104 | 105 | // 小时级别的差异,每半小时刷新一次 106 | // => "x小时前" 107 | const timeout = setTimeout(refreshNowRef, 1_000 * 60 * 30) 108 | onClean(() => clearTimeout(timeout)) 109 | return 110 | }) 111 | 112 | return computed(() => { 113 | // console.log('re-calc toNow', dateRef.value.format(), nowRef.value.format()) 114 | return dateRef.value ? toNow(dateRef.value, { now: nowRef.value, notNegative: true }) : '' 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /src/const/empty.ts: -------------------------------------------------------------------------------- 1 | /** 空函数,占位用 */ 2 | export function NOOP() { 3 | // 4 | } 5 | -------------------------------------------------------------------------------- /src/const/index.ts: -------------------------------------------------------------------------------- 1 | /** 代表选项中的全部 */ 2 | export const ALL_VALUE = 'ALL_VALUE' 3 | -------------------------------------------------------------------------------- /src/const/provide.ts: -------------------------------------------------------------------------------- 1 | export const PROVIDE = { 2 | IMAGE_PREVIEW: Symbol(), 3 | } 4 | -------------------------------------------------------------------------------- /src/css/app.scss: -------------------------------------------------------------------------------- 1 | // app global css in SCSS form 2 | 3 | .flex-align-center { 4 | display: flex; 5 | align-items: center; 6 | } 7 | 8 | .flex-space { 9 | flex: 1; 10 | } 11 | 12 | .mx-auto { 13 | margin-left: auto; 14 | margin-right: auto; 15 | } 16 | 17 | .cursor-pointer { 18 | cursor: pointer; 19 | } 20 | 21 | .m0 { 22 | margin: 0; 23 | } 24 | 25 | .p0 { 26 | margin: 0; 27 | } 28 | 29 | a { 30 | text-decoration: none; 31 | } 32 | 33 | body.desktop { 34 | &::-webkit-scrollbar { 35 | display: none; 36 | } 37 | } 38 | 39 | .text-opacity { 40 | opacity: 0.6; 41 | } 42 | -------------------------------------------------------------------------------- /src/css/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin ellipsis($line) { 2 | display: -webkit-box; 3 | -webkit-box-orient: vertical; 4 | -webkit-line-clamp: $line; 5 | white-space: normal; 6 | overflow: hidden; 7 | text-overflow: ellipsis; 8 | word-break: break-all; 9 | } 10 | -------------------------------------------------------------------------------- /src/css/quasar.variables.scss: -------------------------------------------------------------------------------- 1 | // Quasar SCSS (& Sass) Variables 2 | // -------------------------------------------------- 3 | // To customize the look and feel of this app, you can override 4 | // the Sass/SCSS variables found in Quasar's source Sass/SCSS files. 5 | 6 | // Check documentation for full list of Quasar variables 7 | 8 | // Your own variables (that are declared here) and Quasar's own 9 | // ones will be available out of the box in your .vue/.scss/.sass files 10 | 11 | // It's highly recommended to change the default colors 12 | // to match your app's branding. 13 | // Tip: Use the "Theme Builder" on Quasar's documentation website. 14 | 15 | $primary: #1976d2; 16 | $secondary: #26a69a; 17 | $accent: #9c27b0; 18 | 19 | $dark: #1d1d1d; 20 | $dark-page: #121212; 21 | 22 | $positive: #21ba45; 23 | $negative: #c10015; 24 | $info: #31ccec; 25 | $warning: #f2c037; 26 | 27 | // https://material.io/design/layout/responsive-layout-grid.html#breakpoints 28 | $breakpoint-xs: 599px !default; 29 | $breakpoint-sm: 959px !default; 30 | $breakpoint-md: 1263px !default; 31 | $breakpoint-lg: 1919px !default; 32 | 33 | $tooltip-mobile-padding: 6px 10px !default; 34 | $tooltip-mobile-fontsize: 10px !default; 35 | 36 | // 覆盖默认样式 37 | .q-notification { 38 | transition: 39 | transform 0.5s, 40 | opacity 0.5s !important; 41 | } 42 | 43 | .q-badge--floating { 44 | right: unset !important; 45 | left: 100% !important; 46 | transform: translateX(-50%); 47 | } 48 | 49 | .q-item__label--caption { 50 | color: #757575 !important; 51 | } 52 | 53 | div.q-tab-panels { 54 | background-color: inherit; 55 | 56 | .q-tab-panel { 57 | padding: 0; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@joplin/turndown' 2 | declare module '@joplin/turndown-plugin-gfm' 3 | -------------------------------------------------------------------------------- /src/directives/longPress.ts: -------------------------------------------------------------------------------- 1 | import { createDirective } from 'src/utils/createDirective' 2 | 3 | /** 4 | * 长按 5 | * 6 | * @param {number} delay 长按多久算长按,单位ms,默认 100 7 | */ 8 | export const longPress = createDirective({}) 9 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | NODE_ENV: string 4 | VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined 5 | VUE_ROUTER_BASE: string | undefined 6 | /** Code runs in development mode */ 7 | DEV: boolean 8 | /** Code runs in production mode */ 9 | PROD: boolean 10 | /** Code runs in development mode or `--debug` flag was set for production mode */ 11 | DEBUGGING: boolean 12 | /** Quasar CLI mode (spa, pwa, …) */ 13 | MODE: string 14 | 15 | // 环境变量 16 | VUE_APP_NAME: string 17 | VUE_APP_TOKEN_EXP_TIME: string 18 | VUE_SESSION_TOKEN_VALIDITY: string 19 | VUE_CAPTCHA_SITE_KEY: string 20 | VUE_TRACE_SERVER: string 21 | VUE_COMMIT_SHA: string 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | turnstile: any 4 | Sanitizer: any 5 | onloadTurnstileCallback: () => void 6 | onTelegramAuth: (user: any) => void 7 | } 8 | interface Element { 9 | setHTML: any 10 | } 11 | } 12 | 13 | export {} 14 | -------------------------------------------------------------------------------- /src/pages/Announcement/Announcement.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 85 | 86 | 91 | -------------------------------------------------------------------------------- /src/pages/Announcement/AnnouncementDetail.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 63 | 64 | 69 | -------------------------------------------------------------------------------- /src/pages/Announcement/announcementFormat.ts: -------------------------------------------------------------------------------- 1 | import sanitizerHtml from 'src/utils/sanitizeHtml' 2 | import { parseTime } from 'src/utils/time' 3 | 4 | import { useToNowRef } from 'src/composition/useToNowRef' 5 | 6 | import type { Dayjs } from 'dayjs' 7 | import type { Announcement as _Announcement } from 'src/services/context/type' 8 | import type { Ref } from 'vue' 9 | 10 | export interface Announcement { 11 | Id: number 12 | Title: string 13 | Create: Dayjs 14 | Before: Ref 15 | Content: string 16 | PreviewContent: string 17 | } 18 | 19 | function getPreview(html: string): string { 20 | const div = document.createElement('div') 21 | div.innerHTML = sanitizerHtml(html) 22 | const text = div.textContent!.trim() 23 | div.remove() 24 | return text.length > 50 ? text.substring(0, 50) + '...' : text 25 | } 26 | 27 | export function announcementFormat(element: _Announcement): Announcement { 28 | return { 29 | Id: element.Id, 30 | Create: parseTime(element.CreateTime), 31 | Before: useToNowRef(() => element.CreateTime), 32 | PreviewContent: getPreview(element.Content), 33 | Content: element.Content, 34 | Title: element.Title, 35 | } 36 | } 37 | 38 | export function announcementListFormat(announcementList: _Announcement[]): Announcement[] { 39 | const re: Announcement[] = [] 40 | announcementList.forEach((element) => { 41 | re.push(announcementFormat(element)) 42 | }) 43 | return re 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/Book/BookList.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 126 | 127 | 134 | -------------------------------------------------------------------------------- /src/pages/Book/BookRank.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /src/pages/Book/EditChapter.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/pages/Book/EditInfo.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/pages/Book/Read/history.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from 'quasar' 2 | 3 | import { userReadPositionDB } from 'src/utils/storage/db' 4 | 5 | import { saveReadPosition } from 'src/services/book' 6 | 7 | import type { Ref } from 'vue' 8 | 9 | function findElementNode(node: Node): Element { 10 | return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : findElementNode(node.parentNode!) 11 | } 12 | 13 | function readXPath(element: Element, context: Element = document.body): string { 14 | if (context === document.body) { 15 | /* eslint-disable */ 16 | if (element.id !== '') { 17 | return '//*[@id="' + element.id + '"]' 18 | } 19 | } 20 | if (context && element === context) { 21 | return '//*' 22 | } 23 | 24 | let ix = 1, 25 | siblings = element.parentNode!.childNodes 26 | 27 | for (let i = 0, l = siblings.length; i < l; i++) { 28 | let sibling = siblings[i] 29 | if (sibling === element) { 30 | return readXPath(element.parentNode as Element, context) + '/' + element.tagName.toLowerCase() + '[' + ix + ']' 31 | } else if (sibling.nodeType === 1 && (sibling as Element).tagName === element.tagName) { 32 | ix++ 33 | } 34 | } 35 | return '' 36 | } 37 | 38 | export function loadHistory(uid: number, BookId: number) { 39 | return userReadPositionDB.get(`${uid}_${BookId}`) 40 | } 41 | 42 | export async function saveHistory( 43 | uid: number, 44 | BookId: number, 45 | bookParam: { 46 | Id: number 47 | xpath: string 48 | }, 49 | ) { 50 | userReadPositionDB.set(`${uid}_${BookId}`, { 51 | cid: bookParam.Id, 52 | xPath: bookParam.xpath, 53 | top: document.scrollingElement!.scrollTop, 54 | }) 55 | await saveReadPosition({ Bid: BookId, Cid: bookParam.Id, XPath: bookParam.xpath }) 56 | } 57 | 58 | export function scrollToHistory(dom: Element, xPath: string, offset: Ref) { 59 | try { 60 | let rst = document.evaluate(xPath, dom, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null) 61 | let target = rst.iterateNext() as Element 62 | if (target) { 63 | document.scrollingElement!.scrollTop = target.getBoundingClientRect().top - offset.value 64 | } 65 | } catch (e) { 66 | console.log(e) 67 | } 68 | } 69 | 70 | export async function syncReading( 71 | dom: Element, 72 | uid: Ref, 73 | bookParam: { 74 | BookId: Ref 75 | CId: Ref 76 | }, 77 | offset: Ref, 78 | ) { 79 | let visibleDom: Element[] = [] 80 | let doSync = debounce(async () => { 81 | let topTarget = visibleDom.reduce((res: { target: Element; rect: DOMRect } | null, target: Element) => { 82 | // target.style.background = null 83 | let rect = target.getBoundingClientRect() 84 | if (rect.top >= offset.value) { 85 | if (res) { 86 | if (rect.top < res.rect.top) { 87 | res = { 88 | target, 89 | rect, 90 | } 91 | } 92 | } else { 93 | res = { 94 | target, 95 | rect, 96 | } 97 | } 98 | } 99 | return res 100 | }, null) 101 | if (topTarget) { 102 | // topTarget.target.style.background = 'red' 103 | // console.log(topTarget.target, readXPath(topTarget.target)) 104 | let xpath = readXPath(topTarget.target, dom) 105 | await saveHistory(uid.value, bookParam?.BookId.value, { 106 | Id: bookParam?.CId.value, 107 | xpath, 108 | }) 109 | } 110 | }, 300) 111 | let io = new IntersectionObserver((entities) => { 112 | entities.forEach((entity) => { 113 | if (entity.target instanceof HTMLElement) { 114 | let domTarget = entity.target as HTMLElement 115 | domTarget.style.background = '' 116 | if (entity.isIntersecting) { 117 | visibleDom.push(domTarget) 118 | } else { 119 | visibleDom = visibleDom.filter((target) => target !== domTarget) 120 | } 121 | } 122 | doSync() 123 | }) 124 | }) 125 | 126 | let walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT, (node) => { 127 | return node.nodeValue!.trim().length > 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP 128 | }) 129 | while (walker.nextNode()) { 130 | try { 131 | let dom = findElementNode(walker.currentNode) 132 | io.observe(dom) 133 | } catch (e) { 134 | console.log(e) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/pages/Collaborator/List.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 47 | 65 | 76 | -------------------------------------------------------------------------------- /src/pages/Collaborator/components/Card.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 42 | 43 | 46 | 47 | 108 | -------------------------------------------------------------------------------- /src/pages/Collaborator/store/data.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | 3 | import type { Card } from 'src/types/collaborator' 4 | 5 | const imgs: string[] = [ 6 | 'https://img.acgdmzy.com:45112/images/2021/08/22/08579907e581.webp', 7 | 'https://i0.hdslb.com/bfs/archive/307afe2558b4bb3a4a655d284a47459b9c6cd3fa.jpg', 8 | 'https://i0.hdslb.com/bfs/bangumi/image/8b1657cc9ded02796ce317ff7e1fd36f2dc9a0bb.jpg', 9 | 'https://i0.hdslb.com/bfs/manga-static/1cecbe6033d31cc9a49f4c1df88258a0abf72e07.jpg', 10 | 'https://i0.hdslb.com/bfs/archive/ab734a5ec06f568c27ea2212bbfb5e22d31284ae.jpg', 11 | ] 12 | 13 | const mock = (): Card[] => { 14 | return new Array(40).fill('').map((_, idx) => { 15 | return { 16 | Id: nanoid(), 17 | Job: 'Epub', 18 | Avatar: imgs[idx % imgs.length], 19 | Title: nanoid(idx % 2 === 1 ? 40 : 6), 20 | Description: nanoid(idx % 2 === 1 ? 40 : 6), 21 | } 22 | }) 23 | } 24 | 25 | /** 固化本地的贡献者列表数据 */ 26 | export const collaborators = mock() 27 | -------------------------------------------------------------------------------- /src/pages/Collaborator/store/index.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | import { collaborators } from './data' 4 | 5 | /** 贡献列表store */ 6 | export const useCollaborators = defineStore('page.collaborator', { 7 | state() { 8 | return { collaborators } 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /src/pages/Community.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/pages/Forum/List/components/ForumList.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 72 | 73 | 78 | -------------------------------------------------------------------------------- /src/pages/Forum/List/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/pages/History.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /src/pages/Login/Index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/Login/VueTurnstile.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/pages/MyShelf/components/NavBackToParentFolder.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 70 | 71 | 77 | -------------------------------------------------------------------------------- /src/pages/MyShelf/components/RenameDialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 19 | 20 | 66 | -------------------------------------------------------------------------------- /src/pages/MyShelf/components/ShelfBook.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 27 | -------------------------------------------------------------------------------- /src/pages/MyShelf/components/ShelfCard.vue: -------------------------------------------------------------------------------- 1 |