├── .gitignore ├── .vscode └── extensions.json ├── README.md ├── doc ├── ai-search.png ├── col-view.png ├── feed-follow.png ├── feed-items.png ├── img-reader.png ├── login.png ├── mobile-feed.png ├── mobile-reader.png ├── podcast-reader.png ├── text-reader.png └── video-reader.png ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── data.json ├── favicon.ico └── logo.svg ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── default.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── lib.rs │ └── main.rs └── tauri.conf.json ├── src ├── App.vue ├── api │ └── index.ts ├── assets │ └── vue.svg ├── components │ ├── ConfirmDialog.vue │ ├── Greet.vue │ ├── HelpDialog.vue │ ├── ImagePreviewDialog.vue │ └── MPlayer.vue ├── layout │ ├── IndexLayout copy.vue │ ├── IndexLayout.vue │ ├── SideNav.vue │ ├── settings │ │ ├── Settings.vue │ │ └── sub │ │ │ ├── SettingsAbout.vue │ │ │ ├── SettingsAppearance.vue │ │ │ ├── SettingsGeneral.vue │ │ │ └── SettingsIntegrated.vue │ └── sub │ │ ├── FeedDialog.vue │ │ ├── HelpDialog.vue │ │ ├── PlayList.vue │ │ ├── SearchDialog.vue │ │ └── SideBar.vue ├── main.ts ├── plugins │ ├── ImgPreview.ts │ ├── confirm.ts │ ├── index.ts │ └── vuetify.ts ├── repository │ ├── index.ts │ ├── model.ts │ └── repository.ts ├── router │ └── index.ts ├── service │ ├── index.ts │ ├── rag.ts │ ├── recommend.ts │ └── types.ts ├── store │ ├── base.ts │ ├── feeds.ts │ ├── index.ts │ ├── items.ts │ ├── playlist.ts │ ├── settings.ts │ └── types.ts ├── types │ └── colorthief.d.ts ├── utils │ ├── dateFormat.ts │ ├── dbHelper.ts │ ├── debound.ts │ ├── http.ts │ ├── mdUtils.ts │ ├── scroll.ts │ ├── scrollListener.ts │ ├── useCalView.ts │ ├── useElResize.ts │ ├── useHotkeys.ts │ ├── useItem.ts │ └── useSideChapter.ts ├── views │ ├── Combo.vue │ ├── Discovery.vue │ ├── Download.vue │ ├── FeedAssistant.vue │ ├── InjectionSymbols.ts │ ├── Items.vue │ ├── Login.vue │ ├── RelatedArticles.vue │ ├── Welcome.vue │ ├── discover │ │ ├── RssEditor.vue │ │ └── RssList.vue │ ├── item │ │ ├── CardItem.vue │ │ ├── ContentItem.vue │ │ ├── Index.vue │ │ ├── MagazineItem.vue │ │ ├── TextItem.vue │ │ └── types.ts │ └── reader │ │ ├── Index.vue │ │ ├── InjectionSymbols.ts │ │ ├── Reader.vue │ │ ├── index.ts │ │ └── sub │ │ ├── BasicReader.vue │ │ ├── ImageReader.vue │ │ ├── PodcastReader.vue │ │ └── VideoReader.vue └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | dev-dist 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webfollow-app 2 | 3 | > RSS reader suppot fever api 4 | 5 | - Strive to be the best reader experience 6 | - 致力成为体验最佳的阅读器 7 | 8 | ## Feature 9 | 10 | **feature** 11 | 12 | - [x] fever api 13 | - [x] data in local 14 | - [x] ai search 15 | - [x] recommend 16 | - [x] llm summary + llm filter 17 | - [x] pwa 18 | 19 | **base** 20 | 21 | - [x] auto-reader 22 | - [x] text-reader 23 | - [x] podcast-reader 24 | - [x] img-reader 25 | - [x] video-reader 26 | - [x] auto-view 27 | - [x] column-view 28 | - [x] list-view 29 | - [x] card-view 30 | - [x] text-view 31 | - [x] content-view 32 | 33 | **todo list** 34 | 35 | - [ ] export opml 36 | - [ ] data reset 37 | 38 | ## Demo 39 | 40 | [online](https://webfollow.cc) 41 | 42 | ## Preview 43 | 44 | **AI** 45 | 46 | ![](./doc/ai-search.png) 47 | 48 | **Mobile** 49 | 50 | ![](./doc/mobile-feed.png) 51 | ![](./doc/mobile-reader.png) 52 | 53 | **PC** 54 | 55 | ![](./doc/col-view.png) 56 | ![](./doc/text-reader.png) 57 | ![](./doc/podcast-reader.png) 58 | ![](./doc/video-reader.png) 59 | ![](./doc/img-reader.png) 60 | ![](./doc/feed-items.png) 61 | ![](./doc/login.png) 62 | ![](./doc/feed-follow.png) 63 | 64 | ## Use 65 | 66 | ``` 67 | npm install 68 | ``` 69 | 70 | ``` 71 | npm run dev 72 | ``` 73 | 74 | ## Notice 75 | 76 | - Enhancements to feed subscription editing for fever 77 | -------------------------------------------------------------------------------- /doc/ai-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/ai-search.png -------------------------------------------------------------------------------- /doc/col-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/col-view.png -------------------------------------------------------------------------------- /doc/feed-follow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/feed-follow.png -------------------------------------------------------------------------------- /doc/feed-items.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/feed-items.png -------------------------------------------------------------------------------- /doc/img-reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/img-reader.png -------------------------------------------------------------------------------- /doc/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/login.png -------------------------------------------------------------------------------- /doc/mobile-feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/mobile-feed.png -------------------------------------------------------------------------------- /doc/mobile-reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/mobile-reader.png -------------------------------------------------------------------------------- /doc/podcast-reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/podcast-reader.png -------------------------------------------------------------------------------- /doc/text-reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/text-reader.png -------------------------------------------------------------------------------- /doc/video-reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/doc/video-reader.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WebFollow - 在线 RSS 阅读器 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | Webfollow 36 | 93 | 125 | 126 | 127 | 134 | 135 | 136 | 137 | 138 | 139 |
140 |
141 | 142 |

加载中....

143 |
144 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webfollow", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@mdi/font": "7.0.96", 14 | "@tauri-apps/api": ">=2.0.0", 15 | "@tauri-apps/plugin-shell": ">=2.0.0", 16 | "@types/turndown": "^5.0.5", 17 | "colorthief": "^2.6.0", 18 | "core-js": "^3.29.0", 19 | "marked": "^14.1.3", 20 | "pinia": "^2.2.4", 21 | "roboto-fontface": "*", 22 | "swiper": "^11.1.14", 23 | "turndown": "^7.2.0", 24 | "vite-plugin-pwa": "^0.21.1", 25 | "vue": "^3.3.4", 26 | "vue-router": "4", 27 | "vuetify": "^3.0.0" 28 | }, 29 | "devDependencies": { 30 | "@tauri-apps/cli": ">=2.0.0", 31 | "@types/node": "^22.7.5", 32 | "@vitejs/plugin-vue": "^5.0.5", 33 | "sass": "^1.60.0", 34 | "typescript": "^5.2.2", 35 | "unplugin-fonts": "^1.0.3", 36 | "vite": "^5.3.1", 37 | "vite-plugin-vuetify": "^2.0.4", 38 | "vue-tsc": "^2.0.22" 39 | } 40 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Layer 1 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "webfollow" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "webfollow_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.0.0", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2.0.0", features = [ "protocol-asset"] } 22 | tauri-plugin-shell = "2.0.0" 23 | serde = { version = "1", features = ["derive"] } 24 | serde_json = "1" 25 | tauri-plugin-http = "2" 26 | 27 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "shell:allow-open", 9 | "http:allow-fetch" 10 | ] 11 | } -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weekend-project-space/webfollow-app/0fed22af4ff19802ef497a8053d3e0e0adac642f/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command 2 | #[tauri::command] 3 | fn greet(name: &str) -> String { 4 | format!("Hello, {}! You've been greeted from Rust!", name) 5 | } 6 | 7 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 8 | pub fn run() { 9 | tauri::Builder::default() 10 | .plugin(tauri_plugin_shell::init()) 11 | .invoke_handler(tauri::generate_handler![greet]) 12 | .run(tauri::generate_context!()) 13 | .expect("error while running tauri application"); 14 | } 15 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | webfollow_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2.0.0", 3 | "productName": "webfollow", 4 | "version": "0.1.0", 5 | "identifier": "cc.webfollow.app", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "pnpm build", 10 | "frontendDist": "../dist" 11 | }, 12 | 13 | "app": { 14 | "windows": [{ 15 | "title": "webfollow", 16 | "width": 800, 17 | "height": 600 18 | 19 | }], 20 | "security": { 21 | "csp": { 22 | "default-src": "'self' customprotocol: asset: https: http: data: 'unsafe-inline' 'unsafe-eval'", 23 | "img-src": "'self' data: https: http: asset:", 24 | "connect-src": "'self' ipc: http://ipc.localhost https: http:", 25 | "font-src": "'self' https: http: data:", 26 | "style-src": "'self' 'unsafe-inline' https: http:" 27 | }, 28 | "freezePrototype": true, 29 | "assetProtocol": { 30 | "enable": true, 31 | "scope": { 32 | "allow": ["$APPDATA/db/**", "$RESOURCE/**"], 33 | "deny": ["$APPDATA/db/*.stronghold"] 34 | } 35 | } 36 | } 37 | }, 38 | "bundle": { 39 | "active": true, 40 | "targets": "all", 41 | "icon": [ 42 | "icons/32x32.png", 43 | "icons/128x128.png", 44 | "icons/128x128@2x.png", 45 | "icons/icon.icns", 46 | "icons/icon.ico" 47 | ] 48 | } 49 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 114 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | request 3 | } from "../utils/http"; 4 | 5 | export function login(token: string) { 6 | return request({ 7 | 'api': '1', 8 | api_key: token 9 | }) 10 | } 11 | 12 | export function groups() { 13 | return request({ 14 | 'groups': '0' 15 | }) 16 | } 17 | 18 | export function feeds() { 19 | return request({ 20 | 'feeds': '0' 21 | }) 22 | } 23 | 24 | /** 25 | * 26 | * @param {group_ids:'', feed_ids:'', max_id:0,since_id:0,with_ids:1} params 27 | * @returns 28 | */ 29 | export function items(params: object) { 30 | return request(Object.assign({ 31 | 'items': '0' 32 | }, params)) 33 | } 34 | 35 | 36 | export function favicons(params: object): Promise { 37 | return request(Object.assign({ 38 | 'favicons': '0' 39 | }, params)) 40 | } 41 | 42 | 43 | export function listUnreadItemIds() { 44 | return request({ 45 | 'unread_item_ids': '0' 46 | }) 47 | } 48 | 49 | export function listSavedItemIds() { 50 | return request({ 51 | 'saved_item_ids': '0' 52 | }) 53 | } 54 | 55 | /** 56 | * 57 | * @param {'mark': 'item' | 'feed' | 'group',as:unread|read|saved|unsaved,id, before:''} params 58 | * @returns 59 | */ 60 | export function mark(params: object) { 61 | return request(Object.assign({ 62 | mark: 'item' 63 | }, params)) 64 | } 65 | 66 | // 扩宽接口 {as: create update remove feed_url, group_id, feed_id} 67 | 68 | export function extFeed(params: object, options = {}) { 69 | return ext('feeds', params, options) 70 | } 71 | 72 | 73 | type extType = 'fail_feed_ids' | 'feeds' 74 | 75 | /** 76 | * 扩展接口 77 | * @param params 78 | * @param options 79 | * @returns 80 | */ 81 | export function ext(type: extType, params = {}, options = {}) { 82 | return request(Object.assign({ [type]: '0' }, params), options) 83 | } -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ConfirmDialog.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 75 | -------------------------------------------------------------------------------- /src/components/Greet.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /src/components/HelpDialog.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 69 | -------------------------------------------------------------------------------- /src/components/ImagePreviewDialog.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 90 | 91 | 171 | -------------------------------------------------------------------------------- /src/components/MPlayer.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 199 | 200 | 205 | -------------------------------------------------------------------------------- /src/layout/IndexLayout.vue: -------------------------------------------------------------------------------- 1 | 20 | 102 | 109 | -------------------------------------------------------------------------------- /src/layout/settings/Settings.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 121 | 122 | 137 | -------------------------------------------------------------------------------- /src/layout/settings/sub/SettingsAbout.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 40 | 41 | 44 | -------------------------------------------------------------------------------- /src/layout/settings/sub/SettingsGeneral.vue: -------------------------------------------------------------------------------- 1 | 127 | 128 | 152 | -------------------------------------------------------------------------------- /src/layout/settings/sub/SettingsIntegrated.vue: -------------------------------------------------------------------------------- 1 | 117 | 118 | 226 | -------------------------------------------------------------------------------- /src/layout/sub/FeedDialog.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 54 | 55 | 82 | -------------------------------------------------------------------------------- /src/layout/sub/HelpDialog.vue: -------------------------------------------------------------------------------- 1 | 129 | 130 | 139 | -------------------------------------------------------------------------------- /src/layout/sub/PlayList.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 66 | 67 | 99 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | // Plugins 4 | import { 5 | registerPlugins 6 | } from "./plugins/index.ts" 7 | 8 | const app = createApp(App) 9 | 10 | registerPlugins(app) 11 | 12 | app.mount('#app') -------------------------------------------------------------------------------- /src/plugins/ImgPreview.ts: -------------------------------------------------------------------------------- 1 | // src/utils/useImgPreview.ts 2 | 3 | import { ref, createApp, h, defineComponent } from 'vue'; 4 | import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue'; 5 | 6 | // 添加类型定义 7 | interface ImgPreviewInstance { 8 | destroy: () => void; 9 | init: () => void; 10 | } 11 | 12 | interface ImgPreviewOptions { 13 | selector?: string; 14 | excludeClass?: string; 15 | } 16 | 17 | function createImgPreview(vuetify: any, options: ImgPreviewOptions = {}): ImgPreviewInstance { 18 | const dialog = ref(false); 19 | const imgSrc = ref(''); 20 | const currentIndex = ref(0); 21 | const images = ref([]); 22 | 23 | const { 24 | selector = '.reading .reader-warp', 25 | excludeClass = 'noclick' 26 | } = options; 27 | 28 | // 使用 WeakMap 来存储事件监听器引用 29 | const eventListeners = new WeakMap(); 30 | 31 | const parent = document.createElement('div'); 32 | document.body.appendChild(parent); 33 | 34 | 35 | 36 | const collectImages = () => { 37 | const container = document.querySelector(selector); 38 | if (!container) return; 39 | 40 | try { 41 | const imgElements = container.querySelectorAll(`img:not(.${excludeClass})`); 42 | const validImages = Array.from(imgElements) 43 | .map(img => (img as HTMLImageElement)) 44 | .filter(src => src); // 过滤掉无效的src 45 | 46 | images.value = validImages; 47 | } catch (error) { 48 | console.error('Error collecting images:', error); 49 | } 50 | }; 51 | 52 | // 优化导航逻辑 53 | const navigateImage = (direction: 'prev' | 'next') => { 54 | if (images.value.length <= 1) return; 55 | const delta = direction === 'prev' ? -1 : 1; 56 | currentIndex.value = (currentIndex.value + delta + images.value.length) % images.value.length; 57 | imgSrc.value = images.value[currentIndex.value].src; 58 | }; 59 | 60 | const handleKeyDown = (event: KeyboardEvent) => { 61 | if (!dialog.value) return; 62 | 63 | if (event.key === 'ArrowLeft') { 64 | navigateImage('prev'); 65 | } else if (event.key === 'ArrowRight') { 66 | navigateImage('next'); 67 | } else if (event.key === 'Escape') { 68 | closePreview(); 69 | } 70 | }; 71 | 72 | const openPreview = (src: string) => { 73 | collectImages(); 74 | currentIndex.value = images.value.findIndex(img => img.src === src); 75 | imgSrc.value = src; 76 | dialog.value = true; 77 | }; 78 | 79 | const closePreview = () => { 80 | dialog.value = false; 81 | imgSrc.value = ''; 82 | }; 83 | 84 | // 使用防抖处理图片点击 85 | const handleImageClick = (() => { 86 | let timeout: number; 87 | return (event: MouseEvent) => { 88 | if (timeout) { 89 | window.clearTimeout(timeout); 90 | } 91 | 92 | timeout = window.setTimeout(() => { 93 | const target = event.target as HTMLElement; 94 | if (document.querySelector('.reading')?.contains(target) && target.tagName === 'IMG' && !target.classList.contains(excludeClass)) { 95 | openPreview((target as HTMLImageElement).src); 96 | } 97 | }, 100); 98 | }; 99 | })(); 100 | 101 | const app = createApp(defineComponent({ 102 | setup() { 103 | return () => h(ImagePreviewDialog, { 104 | modelValue: dialog.value, 105 | 'onUpdate:modelValue': (v: boolean) => dialog.value = v, 106 | imgSrc: imgSrc.value, 107 | images: images.value.map(img => img.src), 108 | onNavigate: (direction: 'prev' | 'next' | 'goto' | number) => { 109 | if (typeof direction === 'number') { 110 | // 处理点击缩略图的情况 111 | currentIndex.value = direction; 112 | imgSrc.value = images.value[direction].src; 113 | } else if (direction === 'prev' || direction === 'next') { 114 | // 处理前进/后退导航 115 | navigateImage(direction); 116 | } else if (direction === 'goto') { 117 | images.value[currentIndex.value].scrollIntoView({ behavior: 'smooth', block: 'center' }); 118 | closePreview(); 119 | } 120 | } 121 | }); 122 | } 123 | })); 124 | app.use(vuetify) 125 | app.mount(parent) 126 | 127 | const init = () => { 128 | const mainContainer: HTMLElement | null = document.querySelector('.v-main'); 129 | if (!mainContainer) { 130 | console.warn('Main container not found'); 131 | return; 132 | } 133 | 134 | // 移除旧的事件监听器 135 | if (eventListeners.has(mainContainer)) { 136 | const oldListener = eventListeners.get(mainContainer); 137 | mainContainer.removeEventListener('click', oldListener); 138 | } 139 | 140 | // 添加新的事件监听器 141 | mainContainer.addEventListener('click', handleImageClick); 142 | eventListeners.set(mainContainer, handleImageClick); 143 | window.addEventListener('keydown', handleKeyDown); 144 | log('imgPreview init') 145 | }; 146 | 147 | const destroy = () => { 148 | const mainContainer: HTMLElement | null = document.querySelector('.v-main'); 149 | if (mainContainer && eventListeners.has(mainContainer)) { 150 | const listener = eventListeners.get(mainContainer); 151 | mainContainer.removeEventListener('click', listener); 152 | eventListeners.delete(mainContainer); 153 | } 154 | 155 | window.removeEventListener('keydown', handleKeyDown); 156 | parent.remove(); 157 | app.unmount(); 158 | }; 159 | 160 | document.addEventListener('DOMContentLoaded', () => { 161 | setTimeout(init, 1000); 162 | }); 163 | 164 | return { 165 | destroy, 166 | init 167 | }; 168 | } 169 | 170 | // 优化插件导出 171 | export default { 172 | install(app: any, options?: ImgPreviewOptions) { 173 | const vuetify = app.config.globalProperties.$vuetify; 174 | app.config.globalProperties.$useImgPreview = createImgPreview(vuetify, options); 175 | }, 176 | }; -------------------------------------------------------------------------------- /src/plugins/confirm.ts: -------------------------------------------------------------------------------- 1 | import { createApp, h, ref } from "vue"; 2 | import ConfirmDialog from "@/components/ConfirmDialog.vue"; 3 | export interface ConfirmOptions { 4 | title?: string; 5 | message: string; 6 | } 7 | 8 | const createConfirm = (vuetify: any) => { 9 | return (options: ConfirmOptions): Promise => { 10 | return new Promise((resolve) => { 11 | const div = document.createElement("div"); 12 | document.body.appendChild(div); 13 | 14 | const app = createApp({ 15 | setup() { 16 | const visible = ref(true); 17 | 18 | const destroy = () => { 19 | app.unmount(); 20 | div.remove(); 21 | }; 22 | 23 | const handleConfirm = () => { 24 | visible.value = false; 25 | destroy(); 26 | resolve(true); 27 | }; 28 | 29 | const handleCancel = () => { 30 | visible.value = false; 31 | destroy(); 32 | resolve(false); 33 | }; 34 | 35 | return () => 36 | h(ConfirmDialog, { 37 | ...options, 38 | modelValue: visible.value, 39 | show: () => (visible.value = true), 40 | onConfirm: handleConfirm, 41 | onCancel: handleCancel, 42 | }); 43 | }, 44 | }); 45 | app.use(vuetify); 46 | app.mount(div); 47 | }); 48 | } 49 | }; 50 | 51 | export let confirm: (options: ConfirmOptions) => Promise; 52 | 53 | export default { 54 | install(app: any) { 55 | const vuetify = app.config.globalProperties.$vuetify; 56 | confirm = createConfirm(vuetify); 57 | app.config.globalProperties.$confirm = confirm; 58 | }, 59 | }; -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/index.js 3 | * 4 | * Automatically included in `./src/main.js` 5 | */ 6 | 7 | // Plugins 8 | import vuetify from './vuetify' 9 | import router from '../router' 10 | import { createPinia } from 'pinia' 11 | import ConfirmPlugin from './confirm' 12 | import ImgPreviewPlugin from './ImgPreview' 13 | const pinia = createPinia() 14 | export function registerPlugins(app: any) { 15 | app.config.globalProperties.$vuetify = vuetify; 16 | app 17 | .use(pinia) 18 | .use(vuetify) 19 | .use(router) 20 | .use(ConfirmPlugin) 21 | .use(ImgPreviewPlugin) 22 | } 23 | -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/vuetify.js 3 | * 4 | * Framework documentation: https://vuetifyjs.com` 5 | */ 6 | 7 | // Styles 8 | import '@mdi/font/css/materialdesignicons.css' 9 | import 'vuetify/styles' 10 | 11 | // Composables 12 | import { 13 | createVuetify 14 | } from 'vuetify' 15 | import { VBtn } from 'vuetify/components' 16 | 17 | // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides 18 | export default createVuetify({ 19 | theme: { 20 | themes: { 21 | light: { 22 | colors: { 23 | primary: '#27AE60', 24 | secondary: '#F39C12', 25 | success: '#8E44AD', 26 | // info: '#E74C3C', 27 | }, 28 | }, 29 | dark: { 30 | colors: { 31 | background: '#292929', 32 | primary: '#27AE60', 33 | secondary: '#F39C12', 34 | success: '#8E44AD', 35 | // info: '#E74C3C' 36 | }, 37 | }, 38 | }, 39 | }, 40 | aliases: { 41 | CBtn: VBtn 42 | }, 43 | defaults: { 44 | CBtn: { 45 | variant: 'text', 46 | size: '40px' 47 | }, 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /src/repository/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | checkDBExists 3 | } from '@/utils/dbHelper' 4 | 5 | export type { Group, Feed, Item } from './model'; 6 | 7 | export { groupRepo, feedRepo, itemRepo, type Page } from './repository' 8 | 9 | export async function isDbExists(): Promise { 10 | await checkDBExists() 11 | } -------------------------------------------------------------------------------- /src/repository/model.ts: -------------------------------------------------------------------------------- 1 | import { DbStore } from '../utils/dbHelper' 2 | 3 | export interface Group extends DbStore { 4 | title: string; 5 | } 6 | 7 | export interface Feed extends DbStore { 8 | title: string; 9 | url: string; 10 | siteUrl: string; 11 | groupId?: number 12 | } 13 | 14 | export interface Item extends DbStore { 15 | feedId: number, 16 | title: string; 17 | author: string; 18 | description: string; 19 | pubDate: number; 20 | link: string; 21 | enclosure?: string, 22 | rank?: number 23 | } 24 | -------------------------------------------------------------------------------- /src/repository/repository.ts: -------------------------------------------------------------------------------- 1 | 2 | import { DbStore, IndexedDB, getOne } from '../utils/dbHelper' 3 | 4 | import { Feed, Group, Item } from './model'; 5 | 6 | const { create, 7 | read, 8 | update, 9 | remove, 10 | getAll, 11 | listAll, findAll, count, getIdsInTimeRange, findTimeRange, whereOne, openStore, exists 12 | } = IndexedDB(db => { 13 | // 创建 Groups 对象存储 14 | if (!db.objectStoreNames.contains('groups')) { 15 | const groupStore = db.createObjectStore('groups', { keyPath: 'id' }); 16 | groupStore.createIndex('title', 'title', { unique: false }); 17 | } 18 | 19 | // 创建 Feeds 对象存储 20 | if (!db.objectStoreNames.contains('feeds')) { 21 | const feedStore = db.createObjectStore('feeds', { keyPath: 'id' }); 22 | feedStore.createIndex('title', 'title', { unique: false }); 23 | feedStore.createIndex('groupId', 'groupId', { unique: false }); 24 | feedStore.createIndex('siteUrl', 'siteUrl', { unique: false }); 25 | } 26 | 27 | // 创建 Items 对象存储 28 | if (!db.objectStoreNames.contains('items')) { 29 | const itemStore = db.createObjectStore('items', { keyPath: 'id' }); 30 | itemStore.createIndex('feedId', 'feedId', { unique: false }); 31 | itemStore.createIndex('author', 'author', { unique: false }); 32 | itemStore.createIndex('description', 'description', { unique: false }); 33 | itemStore.createIndex('pubDate', 'pubDate', { unique: false }); 34 | itemStore.createIndex('link', 'link', { unique: false }); 35 | itemStore.createIndex('enclosure', 'enclosure', { unique: false }); 36 | } 37 | }) 38 | 39 | 40 | class Repo { 41 | protected storename: string; 42 | public constructor(storeName: string) { 43 | this.storename = storeName 44 | } 45 | async get(id: number): Promise { 46 | return read(this.storename, id) 47 | } 48 | async save(data: T): Promise { 49 | if (await this.get(data.id)) { 50 | return update(this.storename, data.id, data) 51 | } 52 | return create(this.storename, data) 53 | } 54 | async del(id: number): Promise { 55 | return remove(this.storename, id) 56 | } 57 | async delAll(): Promise { 58 | return Promise.all((await this.getAll()).map(t => this.del(t.id))) 59 | } 60 | async getAll(): Promise { 61 | return getAll(this.storename) 62 | } 63 | async listAll(conditionFn: ((item: T) => boolean) | undefined): Promise { 64 | if (conditionFn) { 65 | return await listAll(this.storename, conditionFn) 66 | } else { 67 | return getAll(this.storename) 68 | } 69 | } 70 | async findAll(conditionFn: (item: T) => boolean, page: number = 0, size: number = 50, sortFn?: (x: T, y: T) => number): Promise> { 71 | const data = await findAll(this.storename, conditionFn, size, page, sortFn) 72 | return { isLast: data.length != size, data } 73 | } 74 | async count(): Promise { 75 | return count(this.storename) 76 | } 77 | async maxId(): Promise { 78 | return (await whereOne(this.storename, (x: T, y: T) => (x.id > (y ? y.id : 0)) ? x : y))?.id; 79 | } 80 | async existsId(id: number): Promise { 81 | return exists(this.storename, id) 82 | } 83 | } 84 | 85 | export interface Page { 86 | isLast: boolean, 87 | data: T[] 88 | } 89 | 90 | 91 | class ItemRepo extends Repo { 92 | 93 | /** 94 | * 根据时间过滤按feedrank大小排序 95 | * @param time 96 | * @param feedRanks 97 | * @param condition 98 | * @param page 99 | * @param size 100 | * @returns 101 | */ 102 | // time: number, feedRanks: any | null, 103 | async findTimeAll(time: number, condition: ((item: Item) => boolean), page: number = 0, size: number = 50): Promise> { 104 | // const store = await openStore(this.storename) 105 | // const range = IDBKeyRange.lowerBound(time); 106 | // const request = store.index("pubDate").openCursor(range); 107 | // const items: Item[] = await withCursor(request, (item: Item) => { 108 | // const rank = feedRanks[item.feedId] || 10; 109 | // item.rank = rank 110 | // if (condition) { 111 | // if (condition(item)) { 112 | // return [item, true] 113 | // } else { 114 | // return [null, true] 115 | // } 116 | // } 117 | // return [item, true] 118 | // }, (a, b) => (a.rank && b.rank ? a.rank - b.rank : 1)) 119 | // const startOffset = page * size; 120 | // const endOffset = startOffset + size; 121 | // let data: Item[] = [] 122 | // if (startOffset < items.length) { 123 | // data = items.slice(startOffset, endOffset) 124 | // } 125 | const data: Item[] = await findTimeRange(this.storename, time, new Date().getTime() / 1000, page, size, condition) 126 | // console.log(page, data.length, data.length != size) 127 | return { isLast: data.length != size, data } 128 | } 129 | 130 | async countByFeedId(feedId: number): Promise { 131 | const store0 = await openStore(this.storename) 132 | const feedIndex = store0.index('feedId') 133 | const req = feedIndex.count(feedId) 134 | return getOne(req) 135 | } 136 | 137 | async getIdsInTimeRange(startTime: number, endTime: number): Promise { 138 | return await getIdsInTimeRange(this.storename, startTime, endTime) 139 | } 140 | } 141 | 142 | export const groupRepo = new Repo('groups') 143 | export const feedRepo = new Repo('feeds') 144 | export const itemRepo = new ItemRepo('items') -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | // Composables 2 | import { 3 | createRouter, 4 | createWebHistory 5 | } from 'vue-router' 6 | 7 | const routes = [{ 8 | path: '/', 9 | component: () => import('@/layout/IndexLayout.vue'), 10 | children: [{ 11 | path: '/:type/:id', 12 | component: () => import('@/views/Items.vue'), 13 | props: true 14 | }, { 15 | path: '/:type', 16 | component: () => import('@/views/Items.vue'), 17 | props: true 18 | }, 19 | { 20 | path: '/', 21 | component: () => import('@/views/Welcome.vue'), 22 | }, 23 | { 24 | path: '/welcome', 25 | component: () => import('@/views/Welcome.vue'), 26 | }, 27 | { 28 | path: '/subscribe', 29 | component: () => import('@/views/Discovery.vue'), 30 | }, { 31 | path: '/search', 32 | component: () => import('@/views/FeedAssistant.vue'), 33 | }, { 34 | path: '/filter', 35 | component: () => import('@/views/RelatedArticles.vue'), 36 | }, { 37 | path: '/download', 38 | component: () => import('@/views/Download.vue'), 39 | }, { 40 | path: '/combo', 41 | component: () => import('@/views/Combo.vue'), 42 | }, { 43 | path: '/login', 44 | component: () => import('@/views/Login.vue'), 45 | }, { 46 | path: '/rss', 47 | name: 'RSSList', 48 | component: () => import('../views/discover/RssList.vue') 49 | }] 50 | }] 51 | 52 | const router = createRouter({ 53 | history: createWebHistory(), 54 | routes, 55 | }) 56 | 57 | export default router 58 | -------------------------------------------------------------------------------- /src/service/recommend.ts: -------------------------------------------------------------------------------- 1 | import { itemRepo } from "@/repository" 2 | let feeds = JSON.parse(localStorage.getItem('readfeeds') || '{}') 3 | 4 | // 后期需要根据时间往下掉 5 | export function readItem(feedId: number, itemId: number) { 6 | log('read-item', itemId) 7 | if (feeds['itemids']) { 8 | feeds.itemids.push(itemId) 9 | let feedread = feeds.feedread || {} 10 | if (feedread.hasOwnProperty(feedId)) { 11 | feedread[feedId] = feedread[feedId] + 1 12 | } else { 13 | feedread[feedId] = 1 14 | } 15 | } else { 16 | if (feeds.hasOwnProperty(feedId)) { 17 | feeds[feedId] = feeds[feedId] + 1 18 | } else { 19 | feeds[feedId] = 1 20 | } 21 | feeds = { itemids: [itemId], feedread: feeds } 22 | } 23 | localStorage.setItem('readfeeds', JSON.stringify(feeds)) 24 | } 25 | 26 | 27 | export async function ranks() { 28 | const feedItemCounts: any = {} 29 | for await (let feedId of Object.keys(feeds)) { 30 | feedItemCounts[feedId] = await itemRepo.count() 31 | } 32 | return listRank(feedItemCounts) 33 | } 34 | 35 | function listRank(feedItemCounts: any): any { 36 | let feedranks: any = {} 37 | let total = 0 38 | for (let feedId in feeds) { 39 | total += feeds[feedId] 40 | } 41 | for (let feedId in feeds) { 42 | let readQty = feeds[feedId] 43 | if (readQty) { 44 | const rank = 10 - (readQty > feedItemCounts[feedId] ? 1 : (readQty / feedItemCounts[feedId])) * 7 - (readQty > total ? 1 : readQty / total) * 3 45 | feedranks[feedId] = rank 46 | } 47 | } 48 | return feedranks 49 | } -------------------------------------------------------------------------------- /src/service/types.ts: -------------------------------------------------------------------------------- 1 | import { Item } from '../repository' 2 | 3 | export interface Subscription { 4 | id: number, 5 | title: string, 6 | unreadQty?: number 7 | feeds: SubscriptionFeed[] 8 | } 9 | 10 | export interface SubscriptionFeed { 11 | id: number, 12 | title: string, 13 | url: string, 14 | siteUrl: string, 15 | unreadQty: number, 16 | isFailure?: boolean, 17 | icon?: string, 18 | groupId?: number 19 | } 20 | 21 | export enum ItemType { BASIC, IMAGE, VIDEO, PODCAST } 22 | 23 | 24 | export enum LsItemType { GROUP, FEED, FILTER, ITEMS, SAVED, ALL, RECOMMEND } 25 | 26 | export interface FeedItem extends Item { 27 | isRead?: boolean, 28 | isSaved?: boolean, 29 | thumbnail?: string, 30 | summary: string, 31 | datestr: string, 32 | images?: string[], 33 | type: ItemType | string, 34 | html: string, 35 | feed?: SubscriptionFeed 36 | } 37 | -------------------------------------------------------------------------------- /src/store/base.ts: -------------------------------------------------------------------------------- 1 | // stores/counter.js 2 | import { defineStore } from 'pinia' 3 | import { reactive, Reactive } from 'vue' 4 | import { Marked, listSavedIds, listUnreadIds, listFailFeedIds, read as read0, unread as unread0, save as save0, unsave as unsave0 } from '@/service' 5 | 6 | function setItem(key: string, dataset: Set) { 7 | localStorage.setItem(key, JSON.stringify(Array.from(dataset))) 8 | } 9 | 10 | export const useBaseStore = defineStore('base', () => { 11 | const saved_item_ids: Reactive> = reactive(new Set(JSON.parse(localStorage.getItem('sids') || "[]"))) 12 | const unread_item_ids: Reactive> = reactive(new Set(JSON.parse(localStorage.getItem('urids') || "[]"))) 13 | const fail_feed_ids: Reactive> = reactive(new Set(JSON.parse(localStorage.getItem('efids') || "[]"))) 14 | 15 | async function read(id: number, marked: Marked = Marked.ITEM, before?: number, after?: number, feedId?: number) { 16 | await read0(id, marked, before, after, feedId) 17 | if (marked == Marked.ITEM) { 18 | unread_item_ids.delete(id) 19 | } else { 20 | unread_item_ids.clear(); 21 | (await listUnreadIds()).forEach((item: number) => unread_item_ids.add(item)); 22 | } 23 | setItem('urids', unread_item_ids) 24 | } 25 | 26 | async function unread(id: number, marked: Marked = Marked.ITEM, before?: number) { 27 | await unread0(id, marked, before) 28 | if (marked == Marked.ITEM) { 29 | unread_item_ids.add(id) 30 | } else { 31 | unread_item_ids.clear(); 32 | (await listUnreadIds()).forEach((item: number) => unread_item_ids.add(item)); 33 | } 34 | setItem('urids', unread_item_ids) 35 | } 36 | 37 | async function save(id: number) { 38 | await save0(id) 39 | saved_item_ids.add(id) 40 | setItem('sids', saved_item_ids) 41 | } 42 | 43 | async function unsave(id: number) { 44 | await unsave0(id) 45 | saved_item_ids.delete(id) 46 | setItem('sids', saved_item_ids) 47 | } 48 | 49 | function clearFailFeedIds() { 50 | fail_feed_ids.clear() 51 | localStorage.removeItem('efids') 52 | } 53 | 54 | async function initData(urids: number[], sids: number[], efids: number[]) { 55 | sids.forEach((item: number) => saved_item_ids.add(item)); 56 | urids.forEach((item: number) => unread_item_ids.add(item)); 57 | efids.forEach((item: number) => fail_feed_ids.add(item)); 58 | localStorage.setItem('sids', JSON.stringify(sids)) 59 | localStorage.setItem('urids', JSON.stringify(urids)) 60 | localStorage.setItem('efids', JSON.stringify(efids)) 61 | } 62 | 63 | async function refresh(refreshCb: () => Promise, noRefreshCb: () => Promise) { 64 | const d = await listUnreadIds() 65 | // 非标准接口 66 | let fids: number[] = [] 67 | try { 68 | fids = await listFailFeedIds() 69 | } catch { 70 | 71 | } 72 | if (d.length != unread_item_ids.size || fids.length != fail_feed_ids.size) { 73 | unread_item_ids.clear() 74 | saved_item_ids.clear() 75 | initData(d, await listSavedIds(), fids) 76 | await refreshCb() 77 | } else { 78 | await noRefreshCb() 79 | } 80 | 81 | } 82 | 83 | return { saved_item_ids, unread_item_ids, fail_feed_ids, read, unread, save, unsave, refresh, clearFailFeedIds } 84 | }) 85 | 86 | -------------------------------------------------------------------------------- /src/store/feeds.ts: -------------------------------------------------------------------------------- 1 | // stores/counter.js 2 | import { 3 | defineStore, 4 | storeToRefs 5 | } from 'pinia' 6 | import { 7 | ref, 8 | watch, 9 | onMounted, 10 | Ref, 11 | } from 'vue' 12 | import { 13 | listSubscription, 14 | sumUnread, 15 | } from '@/service' 16 | import { extFeed } from '@/api' 17 | import { 18 | useBaseStore 19 | } from './base' 20 | import { useSettingsStore } from './settings' 21 | import { useRoute } from 'vue-router' 22 | import { Subscription } from '@/service/types' 23 | import { feedRepo, Group, itemRepo } from '@/repository' 24 | 25 | export const useFeedsStore = defineStore('feeds', () => { 26 | const { 27 | unread_item_ids, 28 | fail_feed_ids, 29 | refresh: refreshBase 30 | } = useBaseStore() 31 | const { 32 | automation 33 | } = storeToRefs(useSettingsStore()) 34 | const groups: Ref = ref([]) 35 | const route = useRoute() 36 | const data: Ref = ref([]) 37 | 38 | const subscriptions: Ref = ref([]) 39 | const nextUnReadUrl = ref('') 40 | 41 | let readUrls: any[] = [] 42 | 43 | 44 | async function buildFeeds() { 45 | // init subscriptions 46 | const efids = new Set(fail_feed_ids) 47 | const items = await itemRepo.listAll(undefined) 48 | // 需要重构,首次加载构建数结构,后期只更新数量 49 | const follow = await Promise.all(data.value?.map(async g => { 50 | try { 51 | await Promise.all(g.feeds.map(async f => { 52 | f.unreadQty = await sumUnread(items, f.id, unread_item_ids) 53 | f.isFailure = efids.has(f.id) 54 | })); 55 | 56 | g.feeds.sort((a, b) => { 57 | //错误的在最后 有未读的进行字母排序 无的放后面 58 | let a0 = a.isFailure ? 1 : 0 59 | let b0 = b.isFailure ? 1 : 0 60 | if (a0 == 1 || b0 == 1) { 61 | return a0 - b0 62 | } 63 | if (a.unreadQty != 0 && b.unreadQty != 0) { 64 | return a.title.localeCompare(b.title) 65 | } else if (a.unreadQty == 0 && b.unreadQty == 0) { 66 | return a.title.localeCompare(b.title) 67 | } else { 68 | return b.unreadQty - a.unreadQty 69 | } 70 | }) 71 | g.unreadQty = g.feeds.map(f => f.unreadQty).reduce((x, y) => x + y) 72 | return g 73 | } catch { 74 | return g 75 | } 76 | }) || []) 77 | follow.sort((a, b) => { 78 | if (a.unreadQty != 0 && b.unreadQty != 0) { 79 | return a.title.localeCompare(b.title) 80 | } else if (a.unreadQty == 0 && b.unreadQty == 0) { 81 | return a.title.localeCompare(b.title) 82 | } else { 83 | return (b.unreadQty || 0) - (a.unreadQty || 0) 84 | } 85 | }) 86 | subscriptions.value = follow 87 | // init readUrls 88 | updateReadUrls() 89 | } 90 | 91 | async function refreshFeedUnreadQty() { 92 | // 直接使用 unread_item_ids 计算未读数量 93 | const items = await itemRepo.listAll(undefined) 94 | subscriptions.value?.forEach(async g => { 95 | await Promise.all(g.feeds.map(async f => { 96 | f.unreadQty = await sumUnread(items, f.id, unread_item_ids) 97 | })) 98 | g.unreadQty = g.feeds.map(f => f.unreadQty).reduce((x, y) => x + y, 0) 99 | return g 100 | }) 101 | 102 | updateReadUrls() 103 | } 104 | 105 | async function refresh() { 106 | const r = await listSubscription() 107 | if (r) { 108 | data.value = r[0] 109 | groups.value = r[1] 110 | await buildFeeds() 111 | } 112 | } 113 | 114 | watch(route, () => { 115 | updateNextUnReadUrl() 116 | }) 117 | watch(automation, () => { 118 | updateReadUrls() 119 | }, { deep: true }) 120 | 121 | 122 | function updateReadUrls() { 123 | readUrls = [{ url: '/explore', unreadQty: 1 }, { url: '/next', unreadQty: 1 }, ...automation.value.filters.map(f => ({ url: '/filter/' + f.id, unreadQty: 1 })), { url: '/all', unreadQty: unread_item_ids.size }] 124 | subscriptions.value?.forEach(g => { 125 | readUrls.push({ url: '/c/' + g.id, unreadQty: g.unreadQty }) 126 | g.feeds.forEach(f => { 127 | readUrls.push({ url: '/f/' + f.id, unreadQty: f.unreadQty }) 128 | }) 129 | }) 130 | updateNextUnReadUrl() 131 | } 132 | 133 | function updateNextUnReadUrl() { 134 | nextUnReadUrl.value = getNextUnReadUrl(route.fullPath) 135 | } 136 | 137 | function getNextUnReadUrl(currentUrl: string): string { 138 | let canNextUrl = false 139 | for (let i = 0; i < readUrls.length; i++) { 140 | if (canNextUrl && readUrls[i].unreadQty && readUrls[i].unreadQty > 0) { 141 | return readUrls[i].url 142 | } 143 | if (readUrls[i].url == currentUrl) { 144 | canNextUrl = true 145 | } 146 | } 147 | return '' 148 | } 149 | 150 | function getPrevUnReadUrl(currentUrl: string): string { 151 | let prevUrl = '' 152 | for (let i = 0; i < readUrls.length; i++) { 153 | if (readUrls[i].url == currentUrl) { 154 | return prevUrl 155 | } 156 | if (readUrls[i].unreadQty && readUrls[i].unreadQty > 0) { 157 | prevUrl = readUrls[i].url 158 | } 159 | } 160 | return '' 161 | } 162 | 163 | webfollowApp.getUnReadUrl = function (currentUrl: string, isNext: boolean = true): string { 164 | if (isNext) { 165 | return getNextUnReadUrl(currentUrl) 166 | } 167 | return getPrevUnReadUrl(currentUrl) 168 | } 169 | 170 | onMounted(async () => { 171 | refresh() 172 | watch(unread_item_ids, refreshFeedUnreadQty) 173 | }) 174 | 175 | async function deleteFeed(id: number, auotRefresh: boolean = true) { 176 | await extFeed({ feed_id: id, as: 'remove' }) 177 | await feedRepo.del(id); 178 | (await itemRepo.listAll(item => item.feedId == id)).forEach(item => { 179 | itemRepo.del(item.id) 180 | }) 181 | if (auotRefresh) { 182 | await refreshBase(async () => { }, async () => { }) 183 | } 184 | await refresh() 185 | } 186 | 187 | async function updateFeed(id: number, groupId: number) { 188 | const feed = await feedRepo.get(id) 189 | if (feed) { 190 | await extFeed({ feed_id: id, group_id: groupId, feed_url: feed.url, as: 'update' }) 191 | feed.groupId = groupId 192 | await feedRepo.save(feed) 193 | await refresh() 194 | } 195 | } 196 | 197 | return { 198 | groups, 199 | subscriptions, 200 | deleteFeed, 201 | updateFeed, 202 | nextUnReadUrl, 203 | refresh, 204 | } 205 | }) -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | // stores/counter.js 2 | import { defineStore, storeToRefs } from 'pinia' 3 | import { useBaseStore } from './base' 4 | import { useFeedsStore } from './feeds' 5 | import { useItemsStore } from './items' 6 | import { usePlayListStore } from './playlist' 7 | import { pull as pulllocal, setRanks } from '@/service' 8 | import { ranks as getRanks } from '@/service/recommend' 9 | import { clearIndexedDB } from '@/utils/dbHelper' 10 | import { computed, Ref, watch, ref, onMounted, reactive, Reactive } from 'vue' 11 | import { PageRoute, TopNav } from './types' 12 | import { LsItemType } from '@/service/types' 13 | import { useSettingsStore } from './settings' 14 | import { itemRepo } from '@/repository' 15 | 16 | type SyncType = '' | 'sync2local' 17 | 18 | export const useAppStore = defineStore('app', () => { 19 | const { 20 | saved_item_ids, unread_item_ids, read, unread, save, unsave, refresh, clearFailFeedIds 21 | } = useBaseStore() 22 | const { clear } = usePlayListStore() 23 | const { refresh: refreshFeed } = useFeedsStore() 24 | const { subscriptions } = storeToRefs(useFeedsStore()) 25 | const settingsStore = useSettingsStore() 26 | const { refreshItems, pageRoute } = useItemsStore() 27 | const loading: Ref = ref(false) 28 | const lastRefeshTime = ref(0); 29 | const authInfo: Ref = ref(JSON.parse(localStorage.getItem('auth') || '{"username":"guest"}')) 30 | const nav: Reactive = reactive({ title: 'loading' }) 31 | const readerMode = ref(false) 32 | 33 | const item7DayIds: Ref = ref([]) 34 | 35 | const item7DayTime = ref(0) 36 | 37 | async function sync(type: SyncType = '') { 38 | async function pullData2Local() { 39 | return await pulllocal() 40 | } 41 | const item7DayStart = new Date() 42 | item7DayStart.setDate(item7DayStart.getDate() - 1) 43 | item7DayTime.value = Math.round(item7DayStart.getTime() / 1000) 44 | lastRefeshTime.value = Math.round(new Date().getTime() / 1000) 45 | const tmpPullDataFail = settingsStore.general.pullDataFail 46 | settingsStore.general.pullDataFail = true 47 | settingsStore.saveToLocalStorage() 48 | loading.value = true 49 | if (type == '') { 50 | await refresh(async () => { 51 | await pullData2Local() 52 | await refreshFeed() 53 | setRanks(await getRanks()) 54 | await refreshItems() 55 | }, async () => { 56 | if (tmpPullDataFail) { 57 | await pullData2Local() 58 | } 59 | setRanks(await getRanks()) 60 | await refreshItems() 61 | }) 62 | } else if (type = 'sync2local') { 63 | await pullData2Local() 64 | await refreshFeed() 65 | setRanks(await getRanks()) 66 | } 67 | item7DayIds.value = await fetch7dayItemId(item7DayStart.getTime()) 68 | settingsStore.general.pullDataFail = false 69 | settingsStore.saveToLocalStorage() 70 | loading.value = false 71 | setTimeout(() => initNav(pageRoute), 1000) 72 | lastRefeshTime.value = Math.round(new Date().getTime() / 1000) 73 | info('sync end') 74 | 75 | } 76 | 77 | 78 | 79 | async function reloadBuild() { 80 | await clearIndexedDB() 81 | saved_item_ids.clear() 82 | unread_item_ids.clear() 83 | clearFailFeedIds() 84 | localStorage.removeItem('app-settings') 85 | localStorage.removeItem('readfeeds') 86 | clear() 87 | setTimeout(async () => { 88 | authInfo.value = JSON.parse(localStorage.getItem('auth') || '{"username":"guest"}') 89 | await refreshFeed() 90 | await sync() 91 | }, 1000); 92 | 93 | } 94 | 95 | const savedQty = computed(() => saved_item_ids.size) 96 | const unReadQty = computed(() => unread_item_ids.size) 97 | const item7DayUnReadQty = computed(() => item7DayIds.value.filter(id => unread_item_ids.has(id)).length) 98 | watch(unReadQty, () => { 99 | setTitle(unReadQty.value) 100 | }) 101 | onMounted(async () => { 102 | await sync() 103 | setTitle(unReadQty.value) 104 | setTimeout(() => { 105 | watchAll([pageRoute, subscriptions, savedQty], () => initNav(pageRoute)) 106 | }, 1000); 107 | // 都是为了更新nav 108 | }) 109 | 110 | 111 | function initNav(v: PageRoute) { 112 | nav.isFailure = false 113 | switch (v.type) { 114 | case LsItemType.ALL: 115 | nav.title = '全部文章' 116 | nav.qty = unReadQty.value 117 | return 118 | case LsItemType.SAVED: 119 | nav.title = '稍后阅读' 120 | nav.qty = savedQty.value 121 | return 122 | case LsItemType.GROUP: 123 | const ga = subscriptions?.value?.filter(g => g.id == v.id) 124 | if (ga?.length) { 125 | nav.title = ga[0].title 126 | nav.qty = ga[0].unreadQty 127 | } 128 | return 129 | case LsItemType.ITEMS: 130 | if (v.meta) { 131 | nav.title = v.meta.title 132 | nav.qty = v.meta.qty 133 | } 134 | return 135 | case LsItemType.RECOMMEND: 136 | nav.title = '今天' 137 | nav.qty = item7DayUnReadQty.value 138 | return 139 | case LsItemType.FEED: 140 | let fs = subscriptions?.value?.flatMap(g => g.feeds).filter(f => f.id == v.id) 141 | if (fs?.length) { 142 | nav.title = fs[0].title 143 | nav.qty = fs[0].unreadQty 144 | nav.isFailure = fs[0].isFailure 145 | nav.url = fs[0].url 146 | } 147 | return 148 | } 149 | } 150 | 151 | async function fetch7dayItemId(start: number): Promise { 152 | 153 | return await itemRepo.getIdsInTimeRange(start / 1000, new Date().getTime() / 1000) 154 | } 155 | 156 | 157 | return { reloadBuild, sync, loading, read, unread, save, unsave, savedQty, unReadQty, item7DayUnReadQty, authInfo, lastRefeshTime, item7DayTime, nav, readerMode } 158 | }) 159 | 160 | 161 | 162 | export { useFeedsStore, useItemsStore, usePlayListStore, useSettingsStore, useBaseStore }; 163 | 164 | type wathRef = Ref | Reactive 165 | 166 | function watchAll(wathers: wathRef[], call: () => void) { 167 | wathers.forEach(w => { 168 | // deep 169 | watch(w, call, { deep: true }) 170 | }) 171 | } -------------------------------------------------------------------------------- /src/store/items.ts: -------------------------------------------------------------------------------- 1 | // stores/counter.js 2 | import { 3 | defineStore, 4 | } from 'pinia' 5 | import { 6 | ref, 7 | computed, 8 | Ref, 9 | reactive, 10 | Reactive, 11 | } from 'vue' 12 | import { 13 | listItem, 14 | syncFeedItem, 15 | } from '@/service' 16 | 17 | import { 18 | useBaseStore 19 | } from './base' 20 | import { FeedItem, LsItemType } from '@/service/types' 21 | import { PageRoute, PageRouteMeta } from './types' 22 | 23 | export const useItemsStore = defineStore('items', () => { 24 | const { 25 | saved_item_ids, 26 | unread_item_ids 27 | } = useBaseStore() 28 | const data: Ref = ref([]) 29 | 30 | const isLast: Ref = ref(false) 31 | // 用于导航栏变动 32 | const pageRoute: Reactive = reactive({ type: LsItemType.ALL, id: 0 }) 33 | 34 | let cacheLoadParams: any = {} 35 | 36 | const items: Ref = computed(() => data.value ? data.value?.map(item => { 37 | item.isSaved = saved_item_ids.has(item.id) 38 | item.isRead = !unread_item_ids.has(item.id) 39 | return item 40 | }) : []) 41 | 42 | async function loadData(id: any, type: LsItemType, page: number = 0, onlyUnread: boolean = false, meta?: PageRouteMeta) { 43 | cacheLoadParams = { id, type, page, onlyUnread } 44 | id = type == LsItemType.SAVED ? saved_item_ids : id 45 | id = type == LsItemType.ALL ? null : id 46 | pageRoute.id = id 47 | pageRoute.type = type 48 | pageRoute.meta = meta 49 | const r = await listItem(id, type, page, onlyUnread, unread_item_ids) 50 | if (page == 0) { 51 | data.value = [] 52 | } 53 | r?.data.forEach((item) => data.value?.push(item)) 54 | isLast.value = r?.isLast 55 | } 56 | 57 | async function refreshItems() { 58 | if (Object.keys(cacheLoadParams).length) { 59 | await loadData(cacheLoadParams.id, cacheLoadParams.type, cacheLoadParams.page, cacheLoadParams.onlyUnread) 60 | } 61 | } 62 | 63 | async function pullFeedItems(feedId: number) { 64 | await syncFeedItem(feedId) 65 | } 66 | 67 | return { 68 | items, 69 | isLast, 70 | loadData, 71 | refreshItems, 72 | pullFeedItems, 73 | pageRoute 74 | } 75 | }) -------------------------------------------------------------------------------- /src/store/playlist.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref, onBeforeMount, watch } from 'vue' 3 | 4 | interface Play { 5 | currentPlaying: Audio | null, 6 | list: Audio[] 7 | } 8 | 9 | export interface Audio { 10 | id: number, 11 | url: string | undefined | null, 12 | thumbil: string, 13 | title: string, 14 | subtitle: string 15 | feedId?: number, 16 | currentTime: number, 17 | } 18 | 19 | export const usePlayListStore = defineStore('playlist', () => { 20 | 21 | const currentPlaying = ref