liangyue commited on
Commit
23cb88b
1 Parent(s): 29163f9

Upload 72 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. apps/server/.env.local.example +28 -0
  2. apps/server/.eslintrc.js +25 -0
  3. apps/server/.gitignore +6 -0
  4. apps/server/.prettierrc.json +5 -0
  5. apps/server/README.md +73 -0
  6. apps/server/docker-bootstrap.sh +8 -0
  7. apps/server/nest-cli.json +8 -0
  8. apps/server/package.json +93 -0
  9. apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql +33 -0
  10. apps/server/prisma-sqlite/migrations/migration_lock.toml +3 -0
  11. apps/server/prisma-sqlite/schema.prisma +56 -0
  12. apps/server/prisma/migrations/20240227153512_init/migration.sql +39 -0
  13. apps/server/prisma/migrations/migration_lock.toml +3 -0
  14. apps/server/prisma/schema.prisma +56 -0
  15. apps/server/src/app.controller.spec.ts +22 -0
  16. apps/server/src/app.controller.ts +31 -0
  17. apps/server/src/app.module.ts +39 -0
  18. apps/server/src/app.service.ts +19 -0
  19. apps/server/src/configuration.ts +35 -0
  20. apps/server/src/constants.ts +14 -0
  21. apps/server/src/feeds/feeds.controller.spec.ts +18 -0
  22. apps/server/src/feeds/feeds.controller.ts +64 -0
  23. apps/server/src/feeds/feeds.module.ts +12 -0
  24. apps/server/src/feeds/feeds.service.spec.ts +18 -0
  25. apps/server/src/feeds/feeds.service.ts +277 -0
  26. apps/server/src/main.ts +49 -0
  27. apps/server/src/prisma/prisma.module.ts +8 -0
  28. apps/server/src/prisma/prisma.service.ts +9 -0
  29. apps/server/src/trpc/trpc.module.ts +12 -0
  30. apps/server/src/trpc/trpc.router.ts +421 -0
  31. apps/server/src/trpc/trpc.service.ts +232 -0
  32. apps/server/test/app.e2e-spec.ts +24 -0
  33. apps/server/test/jest-e2e.json +9 -0
  34. apps/server/tsconfig.build.json +4 -0
  35. apps/server/tsconfig.json +13 -0
  36. apps/web/.env.local.example +2 -0
  37. apps/web/.eslintrc.cjs +19 -0
  38. apps/web/.gitignore +24 -0
  39. apps/web/README.md +30 -0
  40. apps/web/index.html +17 -0
  41. apps/web/package.json +43 -0
  42. apps/web/postcss.config.js +6 -0
  43. apps/web/src/App.tsx +28 -0
  44. apps/web/src/components/GitHubIcon.tsx +26 -0
  45. apps/web/src/components/Nav.tsx +112 -0
  46. apps/web/src/components/PlusIcon.tsx +30 -0
  47. apps/web/src/components/StatusDropdown.tsx +46 -0
  48. apps/web/src/components/ThemeSwitcher.tsx +75 -0
  49. apps/web/src/constants.ts +5 -0
  50. apps/web/src/index.css +3 -0
apps/server/.env.local.example ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ HOST=0.0.0.0
2
+ PORT=4000
3
+
4
+ # Prisma
5
+ # https://www.prisma.io/docs/reference/database-reference/connection-urls#env
6
+ DATABASE_URL="mysql://root:123456@127.0.0.1:3306/wewe-rss"
7
+
8
+ # 使用Sqlite
9
+ # DATABASE_URL="file:../data/wewe-rss.db"
10
+ # DATABASE_TYPE="sqlite"
11
+
12
+ # 访问授权码
13
+ AUTH_CODE=123567
14
+
15
+ # 每分钟最大请求次数
16
+ MAX_REQUEST_PER_MINUTE=60
17
+
18
+ # 自动提取全文内容
19
+ FEED_MODE="fulltext"
20
+
21
+ # nginx 转发后的服务端地址
22
+ SERVER_ORIGIN_URL=http://localhost:4000
23
+
24
+ # 定时更新订阅源Cron表达式
25
+ CRON_EXPRESSION="35 5,17 * * *"
26
+
27
+ # 读书转发服务,不需要修改
28
+ PLATFORM_URL="https://weread.111965.xyz"
apps/server/.eslintrc.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ parser: '@typescript-eslint/parser',
3
+ parserOptions: {
4
+ project: 'tsconfig.json',
5
+ tsconfigRootDir: __dirname,
6
+ sourceType: 'module',
7
+ },
8
+ plugins: ['@typescript-eslint/eslint-plugin'],
9
+ extends: [
10
+ 'plugin:@typescript-eslint/recommended',
11
+ 'plugin:prettier/recommended',
12
+ ],
13
+ root: true,
14
+ env: {
15
+ node: true,
16
+ jest: true,
17
+ },
18
+ ignorePatterns: ['.eslintrc.js'],
19
+ rules: {
20
+ '@typescript-eslint/interface-name-prefix': 'off',
21
+ '@typescript-eslint/explicit-function-return-type': 'off',
22
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
23
+ '@typescript-eslint/no-explicit-any': 'off',
24
+ },
25
+ };
apps/server/.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules
2
+ # Keep environment variables out of version control
3
+ .env
4
+
5
+ client
6
+ data
apps/server/.prettierrc.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "tabWidth": 2,
3
+ "singleQuote": true,
4
+ "trailingComma": "all"
5
+ }
apps/server/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
3
+ </p>
4
+
5
+ [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6
+ [circleci-url]: https://circleci.com/gh/nestjs/nest
7
+
8
+ <p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
11
+ <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
12
+ <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
13
+ <a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
14
+ <a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
15
+ <a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
16
+ <a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
17
+ <a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
18
+ <a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
19
+ <a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
20
+ <a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
21
+ </p>
22
+ <!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
23
+ [![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
24
+
25
+ ## Description
26
+
27
+ [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ $ pnpm install
33
+ ```
34
+
35
+ ## Running the app
36
+
37
+ ```bash
38
+ # development
39
+ $ pnpm run start
40
+
41
+ # watch mode
42
+ $ pnpm run start:dev
43
+
44
+ # production mode
45
+ $ pnpm run start:prod
46
+ ```
47
+
48
+ ## Test
49
+
50
+ ```bash
51
+ # unit tests
52
+ $ pnpm run test
53
+
54
+ # e2e tests
55
+ $ pnpm run test:e2e
56
+
57
+ # test coverage
58
+ $ pnpm run test:cov
59
+ ```
60
+
61
+ ## Support
62
+
63
+ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
64
+
65
+ ## Stay in touch
66
+
67
+ - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
68
+ - Website - [https://nestjs.com](https://nestjs.com/)
69
+ - Twitter - [@nestframework](https://twitter.com/nestframework)
70
+
71
+ ## License
72
+
73
+ Nest is [MIT licensed](LICENSE).
apps/server/docker-bootstrap.sh ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+
2
+ #!/bin/sh
3
+ # ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses
4
+ # Need to explicit pass DATABASE_URL here, otherwise migration doesn't work
5
+ # Run migrations
6
+ DATABASE_URL=${DATABASE_URL} npx prisma migrate deploy
7
+ # start app
8
+ DATABASE_URL=${DATABASE_URL} node dist/main
apps/server/nest-cli.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json.schemastore.org/nest-cli",
3
+ "collection": "@nestjs/schematics",
4
+ "sourceRoot": "src",
5
+ "compilerOptions": {
6
+ "deleteOutDir": true
7
+ }
8
+ }
apps/server/package.json ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "server",
3
+ "version": "1.7.1",
4
+ "description": "",
5
+ "author": "",
6
+ "private": true,
7
+ "license": "UNLICENSED",
8
+ "scripts": {
9
+ "build": "nest build",
10
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11
+ "start": "nest start",
12
+ "dev": "nest start --watch",
13
+ "start:debug": "nest start --debug --watch",
14
+ "start:prod": "node dist/main",
15
+ "start:migrate:prod": "prisma migrate deploy && npm run start:prod",
16
+ "postinstall": "npx prisma generate",
17
+ "migrate": "pnpm prisma migrate dev",
18
+ "studio": "pnpm prisma studio",
19
+ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
20
+ "test": "jest",
21
+ "test:watch": "jest --watch",
22
+ "test:cov": "jest --coverage",
23
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
24
+ "test:e2e": "jest --config ./test/jest-e2e.json"
25
+ },
26
+ "dependencies": {
27
+ "@cjs-exporter/p-map": "^5.5.0",
28
+ "@nestjs/common": "^10.3.3",
29
+ "@nestjs/config": "^3.2.0",
30
+ "@nestjs/core": "^10.3.3",
31
+ "@nestjs/platform-express": "^10.3.3",
32
+ "@nestjs/schedule": "^4.0.1",
33
+ "@nestjs/throttler": "^5.1.2",
34
+ "@prisma/client": "5.10.1",
35
+ "@trpc/server": "^10.45.1",
36
+ "axios": "^1.6.7",
37
+ "cheerio": "1.0.0-rc.12",
38
+ "class-transformer": "^0.5.1",
39
+ "class-validator": "^0.14.1",
40
+ "dayjs": "^1.11.10",
41
+ "express": "^4.18.2",
42
+ "feed": "^4.2.2",
43
+ "got": "11.8.6",
44
+ "hbs": "^4.2.0",
45
+ "html-minifier": "^4.0.0",
46
+ "node-cache": "^5.1.2",
47
+ "prisma": "^5.10.2",
48
+ "reflect-metadata": "^0.2.1",
49
+ "rxjs": "^7.8.1",
50
+ "zod": "^3.22.4"
51
+ },
52
+ "devDependencies": {
53
+ "@nestjs/cli": "^10.3.2",
54
+ "@nestjs/schematics": "^10.1.1",
55
+ "@nestjs/testing": "^10.3.3",
56
+ "@types/express": "^4.17.21",
57
+ "@types/html-minifier": "^4.0.5",
58
+ "@types/jest": "^29.5.12",
59
+ "@types/node": "^20.11.19",
60
+ "@types/supertest": "^6.0.2",
61
+ "@typescript-eslint/eslint-plugin": "^7.0.2",
62
+ "@typescript-eslint/parser": "^7.0.2",
63
+ "eslint": "^8.56.0",
64
+ "eslint-config-prettier": "^9.1.0",
65
+ "eslint-plugin-prettier": "^5.1.3",
66
+ "jest": "^29.7.0",
67
+ "prettier": "^3.2.5",
68
+ "source-map-support": "^0.5.21",
69
+ "supertest": "^6.3.4",
70
+ "ts-jest": "^29.1.2",
71
+ "ts-loader": "^9.5.1",
72
+ "ts-node": "^10.9.2",
73
+ "tsconfig-paths": "^4.2.0",
74
+ "typescript": "^5.3.3"
75
+ },
76
+ "jest": {
77
+ "moduleFileExtensions": [
78
+ "js",
79
+ "json",
80
+ "ts"
81
+ ],
82
+ "rootDir": "src",
83
+ "testRegex": ".*\\.spec\\.ts$",
84
+ "transform": {
85
+ "^.+\\.(t|j)s$": "ts-jest"
86
+ },
87
+ "collectCoverageFrom": [
88
+ "**/*.(t|j)s"
89
+ ],
90
+ "coverageDirectory": "../coverage",
91
+ "testEnvironment": "node"
92
+ }
93
+ }
apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- CreateTable
2
+ CREATE TABLE "accounts" (
3
+ "id" TEXT NOT NULL PRIMARY KEY,
4
+ "token" TEXT NOT NULL,
5
+ "name" TEXT NOT NULL,
6
+ "status" INTEGER NOT NULL DEFAULT 1,
7
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
9
+ );
10
+
11
+ -- CreateTable
12
+ CREATE TABLE "feeds" (
13
+ "id" TEXT NOT NULL PRIMARY KEY,
14
+ "mp_name" TEXT NOT NULL,
15
+ "mp_cover" TEXT NOT NULL,
16
+ "mp_intro" TEXT NOT NULL,
17
+ "status" INTEGER NOT NULL DEFAULT 1,
18
+ "sync_time" INTEGER NOT NULL DEFAULT 0,
19
+ "update_time" INTEGER NOT NULL,
20
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
21
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
22
+ );
23
+
24
+ -- CreateTable
25
+ CREATE TABLE "articles" (
26
+ "id" TEXT NOT NULL PRIMARY KEY,
27
+ "mp_id" TEXT NOT NULL,
28
+ "title" TEXT NOT NULL,
29
+ "pic_url" TEXT NOT NULL,
30
+ "publish_time" INTEGER NOT NULL,
31
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
32
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
33
+ );
apps/server/prisma-sqlite/migrations/migration_lock.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (i.e. Git)
3
+ provider = "sqlite"
apps/server/prisma-sqlite/schema.prisma ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ datasource db {
2
+ provider = "sqlite"
3
+ url = env("DATABASE_URL")
4
+ }
5
+
6
+ generator client {
7
+ provider = "prisma-client-js"
8
+ binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
9
+ }
10
+
11
+ // 读书账号
12
+ model Account {
13
+ id String @id
14
+ token String @map("token")
15
+ name String @map("name")
16
+ // 状态 0:失效 1:启用 2:禁用
17
+ status Int @default(1) @map("status")
18
+ createdAt DateTime @default(now()) @map("created_at")
19
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
20
+
21
+ @@map("accounts")
22
+ }
23
+
24
+ // 订阅源
25
+ model Feed {
26
+ id String @id
27
+ mpName String @map("mp_name")
28
+ mpCover String @map("mp_cover")
29
+ mpIntro String @map("mp_intro")
30
+ // 状态 0:失效 1:启用 2:禁用
31
+ status Int @default(1) @map("status")
32
+
33
+ // article最后同步时间
34
+ syncTime Int @default(0) @map("sync_time")
35
+
36
+ // 信息更新时间
37
+ updateTime Int @map("update_time")
38
+
39
+ createdAt DateTime @default(now()) @map("created_at")
40
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
41
+
42
+ @@map("feeds")
43
+ }
44
+
45
+ model Article {
46
+ id String @id
47
+ mpId String @map("mp_id")
48
+ title String @map("title")
49
+ picUrl String @map("pic_url")
50
+ publishTime Int @map("publish_time")
51
+
52
+ createdAt DateTime @default(now()) @map("created_at")
53
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
54
+
55
+ @@map("articles")
56
+ }
apps/server/prisma/migrations/20240227153512_init/migration.sql ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- CreateTable
2
+ CREATE TABLE `accounts` (
3
+ `id` VARCHAR(255) NOT NULL,
4
+ `token` VARCHAR(2048) NOT NULL,
5
+ `name` VARCHAR(1024) NOT NULL,
6
+ `status` INTEGER NOT NULL DEFAULT 1,
7
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
8
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
9
+
10
+ PRIMARY KEY (`id`)
11
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
12
+
13
+ -- CreateTable
14
+ CREATE TABLE `feeds` (
15
+ `id` VARCHAR(255) NOT NULL,
16
+ `mp_name` VARCHAR(512) NOT NULL,
17
+ `mp_cover` VARCHAR(1024) NOT NULL,
18
+ `mp_intro` TEXT NOT NULL,
19
+ `status` INTEGER NOT NULL DEFAULT 1,
20
+ `sync_time` INTEGER NOT NULL DEFAULT 0,
21
+ `update_time` INTEGER NOT NULL,
22
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
23
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
24
+
25
+ PRIMARY KEY (`id`)
26
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
27
+
28
+ -- CreateTable
29
+ CREATE TABLE `articles` (
30
+ `id` VARCHAR(255) NOT NULL,
31
+ `mp_id` VARCHAR(255) NOT NULL,
32
+ `title` VARCHAR(255) NOT NULL,
33
+ `pic_url` VARCHAR(255) NOT NULL,
34
+ `publish_time` INTEGER NOT NULL,
35
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
36
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
37
+
38
+ PRIMARY KEY (`id`)
39
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
apps/server/prisma/migrations/migration_lock.toml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Please do not edit this file manually
2
+ # It should be added in your version-control system (i.e. Git)
3
+ provider = "mysql"
apps/server/prisma/schema.prisma ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ datasource db {
2
+ provider = "mysql"
3
+ url = env("DATABASE_URL")
4
+ }
5
+
6
+ generator client {
7
+ provider = "prisma-client-js"
8
+ binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
9
+ }
10
+
11
+ // 读书账号
12
+ model Account {
13
+ id String @id @db.VarChar(255)
14
+ token String @map("token") @db.VarChar(2048)
15
+ name String @map("name") @db.VarChar(1024)
16
+ // 状态 0:失效 1:启用 2:禁用
17
+ status Int @default(1) @map("status") @db.Int()
18
+ createdAt DateTime @default(now()) @map("created_at")
19
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
20
+
21
+ @@map("accounts")
22
+ }
23
+
24
+ // 订阅源
25
+ model Feed {
26
+ id String @id @db.VarChar(255)
27
+ mpName String @map("mp_name") @db.VarChar(512)
28
+ mpCover String @map("mp_cover") @db.VarChar(1024)
29
+ mpIntro String @map("mp_intro") @db.Text()
30
+ // 状态 0:失效 1:启用 2:禁用
31
+ status Int @default(1) @map("status") @db.Int()
32
+
33
+ // article最后同步时间
34
+ syncTime Int @default(0) @map("sync_time")
35
+
36
+ // 信息更新时间
37
+ updateTime Int @map("update_time")
38
+
39
+ createdAt DateTime @default(now()) @map("created_at")
40
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
41
+
42
+ @@map("feeds")
43
+ }
44
+
45
+ model Article {
46
+ id String @id @db.VarChar(255)
47
+ mpId String @map("mp_id") @db.VarChar(255)
48
+ title String @map("title") @db.VarChar(255)
49
+ picUrl String @map("pic_url") @db.VarChar(255)
50
+ publishTime Int @map("publish_time")
51
+
52
+ createdAt DateTime @default(now()) @map("created_at")
53
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
54
+
55
+ @@map("articles")
56
+ }
apps/server/src/app.controller.spec.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { AppController } from './app.controller';
3
+ import { AppService } from './app.service';
4
+
5
+ describe('AppController', () => {
6
+ let appController: AppController;
7
+
8
+ beforeEach(async () => {
9
+ const app: TestingModule = await Test.createTestingModule({
10
+ controllers: [AppController],
11
+ providers: [AppService],
12
+ }).compile();
13
+
14
+ appController = app.get<AppController>(AppController);
15
+ });
16
+
17
+ describe('root', () => {
18
+ it('should return "Hello World!"', () => {
19
+ expect(appController.getHello()).toBe('Hello World!');
20
+ });
21
+ });
22
+ });
apps/server/src/app.controller.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Controller, Get, Redirect, Render } from '@nestjs/common';
2
+ import { AppService } from './app.service';
3
+
4
+ @Controller()
5
+ export class AppController {
6
+ constructor(private readonly appService: AppService) {}
7
+
8
+ @Get()
9
+ getHello(): string {
10
+ return this.appService.getHello();
11
+ }
12
+
13
+ @Get('/robots.txt')
14
+ forRobot(): string {
15
+ return 'User-agent: *\nDisallow: /';
16
+ }
17
+
18
+ @Get('favicon.ico')
19
+ @Redirect('https://r2-assets.111965.xyz/wewe-rss.png', 302)
20
+ getFavicon() {}
21
+
22
+ @Get('/dash*')
23
+ @Render('index.hbs')
24
+ dashRender() {
25
+ const { originUrl: weweRssServerOriginUrl } =
26
+ this.appService.getFeedConfig();
27
+ return {
28
+ weweRssServerOriginUrl,
29
+ };
30
+ }
31
+ }
apps/server/src/app.module.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { AppController } from './app.controller';
3
+ import { AppService } from './app.service';
4
+ import { TrpcModule } from '@server/trpc/trpc.module';
5
+ import { ConfigModule, ConfigService } from '@nestjs/config';
6
+ import configuration, { ConfigurationType } from './configuration';
7
+ import { ThrottlerModule } from '@nestjs/throttler';
8
+ import { ScheduleModule } from '@nestjs/schedule';
9
+ import { FeedsModule } from './feeds/feeds.module';
10
+
11
+ @Module({
12
+ imports: [
13
+ TrpcModule,
14
+ FeedsModule,
15
+ ScheduleModule.forRoot(),
16
+ ConfigModule.forRoot({
17
+ isGlobal: true,
18
+ envFilePath: ['.env.local', '.env'],
19
+ load: [configuration],
20
+ }),
21
+ ThrottlerModule.forRootAsync({
22
+ imports: [ConfigModule],
23
+ inject: [ConfigService],
24
+ useFactory(config: ConfigService) {
25
+ const throttler =
26
+ config.get<ConfigurationType['throttler']>('throttler');
27
+ return [
28
+ {
29
+ ttl: 60,
30
+ limit: throttler?.maxRequestPerMinute || 60,
31
+ },
32
+ ];
33
+ },
34
+ }),
35
+ ],
36
+ controllers: [AppController],
37
+ providers: [AppService],
38
+ })
39
+ export class AppModule {}
apps/server/src/app.service.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { ConfigurationType } from './configuration';
4
+
5
+ @Injectable()
6
+ export class AppService {
7
+ constructor(private readonly configService: ConfigService) {}
8
+ getHello(): string {
9
+ return `
10
+ <div style="display:flex;justify-content: center;height: 100%;align-items: center;font-size: 30px;">
11
+ <div>>> <a href="/dash">WeWe RSS</a> <<</div>
12
+ </div>
13
+ `;
14
+ }
15
+
16
+ getFeedConfig() {
17
+ return this.configService.get<ConfigurationType['feed']>('feed')!;
18
+ }
19
+ }
apps/server/src/configuration.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const configuration = () => {
2
+ const isProd = process.env.NODE_ENV === 'production';
3
+ const port = process.env.PORT || 4000;
4
+ const host = process.env.HOST || '0.0.0.0';
5
+
6
+ const maxRequestPerMinute = parseInt(
7
+ `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`,
8
+ );
9
+
10
+ const authCode = process.env.AUTH_CODE;
11
+ const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz';
12
+ const originUrl = process.env.SERVER_ORIGIN_URL || '';
13
+
14
+ const feedMode = process.env.FEED_MODE as 'fulltext' | '';
15
+
16
+ const databaseType = process.env.DATABASE_TYPE || 'mysql';
17
+
18
+ return {
19
+ server: { isProd, port, host },
20
+ throttler: { maxRequestPerMinute },
21
+ auth: { code: authCode },
22
+ platform: { url: platformUrl },
23
+ feed: {
24
+ originUrl,
25
+ mode: feedMode,
26
+ },
27
+ database: {
28
+ type: databaseType,
29
+ },
30
+ };
31
+ };
32
+
33
+ export default configuration;
34
+
35
+ export type ConfigurationType = ReturnType<typeof configuration>;
apps/server/src/constants.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const statusMap = {
2
+ // 0:失效 1:启用 2:禁用
3
+ INVALID: 0,
4
+ ENABLE: 1,
5
+ DISABLE: 2,
6
+ };
7
+
8
+ export const feedTypes = ['rss', 'atom', 'json'] as const;
9
+
10
+ export const feedMimeTypeMap = {
11
+ rss: 'application/rss+xml; charset=utf-8',
12
+ atom: 'application/atom+xml; charset=utf-8',
13
+ json: 'application/feed+json; charset=utf-8',
14
+ } as const;
apps/server/src/feeds/feeds.controller.spec.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { FeedsController } from './feeds.controller';
3
+
4
+ describe('FeedsController', () => {
5
+ let controller: FeedsController;
6
+
7
+ beforeEach(async () => {
8
+ const module: TestingModule = await Test.createTestingModule({
9
+ controllers: [FeedsController],
10
+ }).compile();
11
+
12
+ controller = module.get<FeedsController>(FeedsController);
13
+ });
14
+
15
+ it('should be defined', () => {
16
+ expect(controller).toBeDefined();
17
+ });
18
+ });
apps/server/src/feeds/feeds.controller.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Controller,
3
+ DefaultValuePipe,
4
+ Get,
5
+ Logger,
6
+ Param,
7
+ ParseIntPipe,
8
+ Query,
9
+ Request,
10
+ Response,
11
+ } from '@nestjs/common';
12
+ import { FeedsService } from './feeds.service';
13
+ import { Response as Res, Request as Req } from 'express';
14
+
15
+ @Controller('feeds')
16
+ export class FeedsController {
17
+ private readonly logger = new Logger(this.constructor.name);
18
+
19
+ constructor(private readonly feedsService: FeedsService) {}
20
+
21
+ @Get('/')
22
+ async getFeedList() {
23
+ return this.feedsService.getFeedList();
24
+ }
25
+
26
+ @Get('/all.(json|rss|atom)')
27
+ async getFeeds(
28
+ @Request() req: Req,
29
+ @Response() res: Res,
30
+ @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30,
31
+ @Query('mode') mode: string,
32
+ ) {
33
+ const path = req.path;
34
+ const type = path.split('.').pop() || '';
35
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
36
+ type,
37
+ limit,
38
+ mode,
39
+ });
40
+
41
+ res.setHeader('Content-Type', mimeType);
42
+ res.send(content);
43
+ }
44
+
45
+ @Get('/:feed')
46
+ async getFeed(
47
+ @Response() res: Res,
48
+ @Param('feed') feed: string,
49
+ @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,
50
+ @Query('mode') mode: string,
51
+ ) {
52
+ const [id, type] = feed.split('.');
53
+ this.logger.log('getFeed: ', id);
54
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
55
+ id,
56
+ type,
57
+ limit,
58
+ mode,
59
+ });
60
+
61
+ res.setHeader('Content-Type', mimeType);
62
+ res.send(content);
63
+ }
64
+ }
apps/server/src/feeds/feeds.module.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { FeedsController } from './feeds.controller';
3
+ import { FeedsService } from './feeds.service';
4
+ import { PrismaModule } from '@server/prisma/prisma.module';
5
+ import { TrpcModule } from '@server/trpc/trpc.module';
6
+
7
+ @Module({
8
+ imports: [PrismaModule, TrpcModule],
9
+ controllers: [FeedsController],
10
+ providers: [FeedsService],
11
+ })
12
+ export class FeedsModule {}
apps/server/src/feeds/feeds.service.spec.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { FeedsService } from './feeds.service';
3
+
4
+ describe('FeedsService', () => {
5
+ let service: FeedsService;
6
+
7
+ beforeEach(async () => {
8
+ const module: TestingModule = await Test.createTestingModule({
9
+ providers: [FeedsService],
10
+ }).compile();
11
+
12
+ service = module.get<FeedsService>(FeedsService);
13
+ });
14
+
15
+ it('should be defined', () => {
16
+ expect(service).toBeDefined();
17
+ });
18
+ });
apps/server/src/feeds/feeds.service.ts ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
2
+ import { PrismaService } from '@server/prisma/prisma.service';
3
+ import { Cron } from '@nestjs/schedule';
4
+ import { TrpcService } from '@server/trpc/trpc.service';
5
+ import { feedMimeTypeMap, feedTypes } from '@server/constants';
6
+ import { ConfigService } from '@nestjs/config';
7
+ import { Article, Feed as FeedInfo } from '@prisma/client';
8
+ import { ConfigurationType } from '@server/configuration';
9
+ import { Feed } from 'feed';
10
+ import got, { Got } from 'got';
11
+ import { load } from 'cheerio';
12
+ import { minify } from 'html-minifier';
13
+ import NodeCache from 'node-cache';
14
+ import pMap from '@cjs-exporter/p-map';
15
+
16
+ console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);
17
+
18
+ const mpCache = new NodeCache({
19
+ maxKeys: 1000,
20
+ });
21
+
22
+ @Injectable()
23
+ export class FeedsService {
24
+ private readonly logger = new Logger(this.constructor.name);
25
+
26
+ private request: Got;
27
+ constructor(
28
+ private readonly prismaService: PrismaService,
29
+ private readonly trpcService: TrpcService,
30
+ private readonly configService: ConfigService,
31
+ ) {
32
+ this.request = got.extend({
33
+ retry: {
34
+ limit: 3,
35
+ methods: ['GET'],
36
+ },
37
+ timeout: 8 * 1e3,
38
+ headers: {
39
+ accept:
40
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
41
+ 'accept-encoding': 'gzip, deflate, br',
42
+ 'accept-language': 'en-US,en;q=0.9',
43
+ 'cache-control': 'max-age=0',
44
+ 'sec-ch-ua':
45
+ '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
46
+ 'sec-ch-ua-mobile': '?0',
47
+ 'sec-ch-ua-platform': '"macOS"',
48
+ 'sec-fetch-dest': 'document',
49
+ 'sec-fetch-mode': 'navigate',
50
+ 'sec-fetch-site': 'none',
51
+ 'sec-fetch-user': '?1',
52
+ 'upgrade-insecure-requests': '1',
53
+ 'user-agent':
54
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
55
+ },
56
+ hooks: {
57
+ beforeRetry: [
58
+ async (options, error, retryCount) => {
59
+ this.logger.warn(`retrying ${options.url}...`);
60
+ return new Promise((resolve) =>
61
+ setTimeout(resolve, 2e3 * (retryCount || 1)),
62
+ );
63
+ },
64
+ ],
65
+ },
66
+ });
67
+ }
68
+
69
+ @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {
70
+ name: 'updateFeeds',
71
+ timeZone: 'Asia/Shanghai',
72
+ })
73
+ async handleUpdateFeedsCron() {
74
+ this.logger.debug('Called handleUpdateFeedsCron');
75
+
76
+ const feeds = await this.prismaService.feed.findMany({
77
+ where: { status: 1 },
78
+ });
79
+ this.logger.debug('feeds length:' + feeds.length);
80
+
81
+ for (const feed of feeds) {
82
+ this.logger.debug('feed', feed.id);
83
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);
84
+ // wait 30s for next feed
85
+ await new Promise((resolve) => setTimeout(resolve, 90 * 1e3));
86
+ }
87
+ }
88
+
89
+ async cleanHtml(source: string) {
90
+ const $ = load(source, { decodeEntities: false });
91
+
92
+ const dirtyHtml = $.html($('.rich_media_content'));
93
+
94
+ const html = dirtyHtml
95
+ .replace(/data-src=/g, 'src=')
96
+ .replace(/visibility: hidden;/g, '');
97
+
98
+ const content =
99
+ '<style> .rich_media_content {overflow: hidden;color: #222;font-size: 17px;word-wrap: break-word;-webkit-hyphens: auto;-ms-hyphens: auto;hyphens: auto;text-align: justify;position: relative;z-index: 0;}.rich_media_content {font-size: 18px;}</style>' +
100
+ html;
101
+
102
+ const result = minify(content, {
103
+ removeAttributeQuotes: true,
104
+ collapseWhitespace: true,
105
+ });
106
+
107
+ return result;
108
+ }
109
+
110
+ async getHtmlByUrl(url: string) {
111
+ const html = await this.request(url, { responseType: 'text' }).text();
112
+ const result = await this.cleanHtml(html);
113
+
114
+ return result;
115
+ }
116
+
117
+ async tryGetContent(id: string) {
118
+ let content = mpCache.get(id) as string;
119
+ if (content) {
120
+ return content;
121
+ }
122
+ const url = `https://mp.weixin.qq.com/s/${id}`;
123
+ content = await this.getHtmlByUrl(url).catch((e) => {
124
+ this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);
125
+
126
+ return '获取全文失败,请重试~';
127
+ });
128
+ mpCache.set(id, content);
129
+ return content;
130
+ }
131
+
132
+ async renderFeed({
133
+ type,
134
+ feedInfo,
135
+ articles,
136
+ mode,
137
+ }: {
138
+ type: string;
139
+ feedInfo: FeedInfo;
140
+ articles: Article[];
141
+ mode?: string;
142
+ }) {
143
+ const { originUrl, mode: globalMode } =
144
+ this.configService.get<ConfigurationType['feed']>('feed')!;
145
+
146
+ const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;
147
+
148
+ const feed = new Feed({
149
+ title: feedInfo.mpName,
150
+ description: feedInfo.mpIntro,
151
+ id: link,
152
+ link: link,
153
+ language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
154
+ image: feedInfo.mpCover,
155
+ favicon: feedInfo.mpCover,
156
+ copyright: '',
157
+ updated: new Date(feedInfo.updateTime * 1e3),
158
+ generator: 'WeWe-RSS',
159
+ });
160
+
161
+ feed.addExtension({
162
+ name: 'generator',
163
+ objects: `WeWe-RSS`,
164
+ });
165
+
166
+ /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
167
+ const enableFullText =
168
+ typeof mode === 'string'
169
+ ? mode === 'fulltext'
170
+ : globalMode === 'fulltext';
171
+
172
+ const mapper = async (item) => {
173
+ const { title, id, publishTime, picUrl } = item;
174
+ const link = `https://mp.weixin.qq.com/s/${id}`;
175
+
176
+ const published = new Date(publishTime * 1e3);
177
+
178
+ let description = '';
179
+ if (enableFullText) {
180
+ description = await this.tryGetContent(id);
181
+ }
182
+
183
+ feed.addItem({
184
+ id,
185
+ title,
186
+ link: link,
187
+ guid: link,
188
+ description,
189
+ date: published,
190
+ image: picUrl,
191
+ });
192
+ };
193
+
194
+ await pMap(articles, mapper, { concurrency: 2, stopOnError: false });
195
+
196
+ return feed;
197
+ }
198
+
199
+ async handleGenerateFeed({
200
+ id,
201
+ type,
202
+ limit,
203
+ mode,
204
+ }: {
205
+ id?: string;
206
+ type: string;
207
+ limit: number;
208
+ mode?: string;
209
+ }) {
210
+ if (!feedTypes.includes(type as any)) {
211
+ type = 'atom';
212
+ }
213
+
214
+ let articles: Article[];
215
+ let feedInfo: FeedInfo;
216
+ if (id) {
217
+ feedInfo = (await this.prismaService.feed.findFirst({
218
+ where: { id },
219
+ }))!;
220
+
221
+ if (!feedInfo) {
222
+ throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
223
+ }
224
+
225
+ articles = await this.prismaService.article.findMany({
226
+ where: { mpId: id },
227
+ orderBy: { publishTime: 'desc' },
228
+ take: limit,
229
+ });
230
+ } else {
231
+ articles = await this.prismaService.article.findMany({
232
+ orderBy: { publishTime: 'desc' },
233
+ take: limit,
234
+ });
235
+
236
+ feedInfo = {
237
+ id: 'all',
238
+ mpName: 'WeWe-RSS All',
239
+ mpIntro: 'WeWe-RSS 全部文章',
240
+ mpCover: 'https://r2-assets.111965.xyz/wewe-rss.png',
241
+ status: 1,
242
+ syncTime: 0,
243
+ updateTime: Math.floor(Date.now() / 1e3),
244
+ createdAt: new Date(),
245
+ updatedAt: new Date(),
246
+ };
247
+ }
248
+
249
+ this.logger.log('handleGenerateFeed articles: ' + articles.length);
250
+ const feed = await this.renderFeed({ feedInfo, articles, type, mode });
251
+
252
+ switch (type) {
253
+ case 'rss':
254
+ return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
255
+ case 'json':
256
+ return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
257
+ case 'atom':
258
+ default:
259
+ return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
260
+ }
261
+ }
262
+
263
+ async getFeedList() {
264
+ const data = await this.prismaService.feed.findMany();
265
+
266
+ return data.map((item) => {
267
+ return {
268
+ id: item.id,
269
+ name: item.mpName,
270
+ intro: item.mpIntro,
271
+ cover: item.mpCover,
272
+ syncTime: item.syncTime,
273
+ updateTime: item.updateTime,
274
+ };
275
+ });
276
+ }
277
+ }
apps/server/src/main.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NestFactory } from '@nestjs/core';
2
+ import { AppModule } from './app.module';
3
+ import { TrpcRouter } from '@server/trpc/trpc.router';
4
+ import { ConfigService } from '@nestjs/config';
5
+ import { json, urlencoded } from 'express';
6
+ import { NestExpressApplication } from '@nestjs/platform-express';
7
+ import { ConfigurationType } from './configuration';
8
+ import { join, resolve } from 'path';
9
+ import { readFileSync } from 'fs';
10
+
11
+ const packageJson = JSON.parse(
12
+ readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'),
13
+ );
14
+
15
+ const appVersion = packageJson.version;
16
+ console.log('appVersion: v' + appVersion);
17
+
18
+ async function bootstrap() {
19
+ const app = await NestFactory.create<NestExpressApplication>(AppModule);
20
+ const configService = app.get(ConfigService);
21
+
22
+ const { host, isProd, port } =
23
+ configService.get<ConfigurationType['server']>('server')!;
24
+
25
+ app.use(json({ limit: '10mb' }));
26
+ app.use(urlencoded({ extended: true, limit: '10mb' }));
27
+
28
+ app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), {
29
+ prefix: '/dash/assets/',
30
+ });
31
+ app.setBaseViewsDir(join(__dirname, '..', 'client'));
32
+ app.setViewEngine('hbs');
33
+
34
+ if (isProd) {
35
+ app.enable('trust proxy');
36
+ }
37
+
38
+ app.enableCors({
39
+ exposedHeaders: ['authorization'],
40
+ });
41
+
42
+ const trpc = app.get(TrpcRouter);
43
+ trpc.applyMiddleware(app);
44
+
45
+ await app.listen(port, host);
46
+
47
+ console.log(`Server is running at http://${host}:${port}`);
48
+ }
49
+ bootstrap();
apps/server/src/prisma/prisma.module.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { PrismaService } from './prisma.service';
3
+
4
+ @Module({
5
+ providers: [PrismaService],
6
+ exports: [PrismaService],
7
+ })
8
+ export class PrismaModule {}
apps/server/src/prisma/prisma.service.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, OnModuleInit } from '@nestjs/common';
2
+ import { PrismaClient } from '@prisma/client';
3
+
4
+ @Injectable()
5
+ export class PrismaService extends PrismaClient implements OnModuleInit {
6
+ async onModuleInit() {
7
+ await this.$connect();
8
+ }
9
+ }
apps/server/src/trpc/trpc.module.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { TrpcService } from '@server/trpc/trpc.service';
3
+ import { TrpcRouter } from '@server/trpc/trpc.router';
4
+ import { PrismaModule } from '@server/prisma/prisma.module';
5
+
6
+ @Module({
7
+ imports: [PrismaModule],
8
+ controllers: [],
9
+ providers: [TrpcService, TrpcRouter],
10
+ exports: [TrpcService, TrpcRouter],
11
+ })
12
+ export class TrpcModule {}
apps/server/src/trpc/trpc.router.ts ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { INestApplication, Injectable, Logger } from '@nestjs/common';
2
+ import { z } from 'zod';
3
+ import { TrpcService } from '@server/trpc/trpc.service';
4
+ import * as trpcExpress from '@trpc/server/adapters/express';
5
+ import { TRPCError } from '@trpc/server';
6
+ import { PrismaService } from '@server/prisma/prisma.service';
7
+ import { statusMap } from '@server/constants';
8
+ import { ConfigService } from '@nestjs/config';
9
+ import { ConfigurationType } from '@server/configuration';
10
+
11
+ @Injectable()
12
+ export class TrpcRouter {
13
+ constructor(
14
+ private readonly trpcService: TrpcService,
15
+ private readonly prismaService: PrismaService,
16
+ private readonly configService: ConfigService,
17
+ ) {}
18
+
19
+ private readonly logger = new Logger(this.constructor.name);
20
+
21
+ accountRouter = this.trpcService.router({
22
+ list: this.trpcService.protectedProcedure
23
+ .input(
24
+ z.object({
25
+ limit: z.number().min(1).max(100).nullish(),
26
+ cursor: z.string().nullish(),
27
+ }),
28
+ )
29
+ .query(async ({ input }) => {
30
+ const limit = input.limit ?? 50;
31
+ const { cursor } = input;
32
+
33
+ const items = await this.prismaService.account.findMany({
34
+ take: limit + 1,
35
+ where: {},
36
+ select: {
37
+ id: true,
38
+ name: true,
39
+ status: true,
40
+ createdAt: true,
41
+ updatedAt: true,
42
+ token: false,
43
+ },
44
+ cursor: cursor
45
+ ? {
46
+ id: cursor,
47
+ }
48
+ : undefined,
49
+ orderBy: {
50
+ createdAt: 'asc',
51
+ },
52
+ });
53
+ let nextCursor: typeof cursor | undefined = undefined;
54
+ if (items.length > limit) {
55
+ // Remove the last item and use it as next cursor
56
+
57
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
58
+ const nextItem = items.pop()!;
59
+ nextCursor = nextItem.id;
60
+ }
61
+
62
+ const disabledAccounts = this.trpcService.getBlockedAccountIds();
63
+ return {
64
+ blocks: disabledAccounts,
65
+ items,
66
+ nextCursor,
67
+ };
68
+ }),
69
+ byId: this.trpcService.protectedProcedure
70
+ .input(z.string())
71
+ .query(async ({ input: id }) => {
72
+ const account = await this.prismaService.account.findUnique({
73
+ where: { id },
74
+ });
75
+ if (!account) {
76
+ throw new TRPCError({
77
+ code: 'BAD_REQUEST',
78
+ message: `No account with id '${id}'`,
79
+ });
80
+ }
81
+ return account;
82
+ }),
83
+ add: this.trpcService.protectedProcedure
84
+ .input(
85
+ z.object({
86
+ id: z.string().min(1).max(32),
87
+ token: z.string().min(1),
88
+ name: z.string().min(1),
89
+ status: z.number().default(statusMap.ENABLE),
90
+ }),
91
+ )
92
+ .mutation(async ({ input }) => {
93
+ const { id, ...data } = input;
94
+ const account = await this.prismaService.account.upsert({
95
+ where: {
96
+ id,
97
+ },
98
+ update: data,
99
+ create: input,
100
+ });
101
+
102
+ return account;
103
+ }),
104
+ edit: this.trpcService.protectedProcedure
105
+ .input(
106
+ z.object({
107
+ id: z.string(),
108
+ data: z.object({
109
+ token: z.string().min(1).optional(),
110
+ name: z.string().min(1).optional(),
111
+ status: z.number().optional(),
112
+ }),
113
+ }),
114
+ )
115
+ .mutation(async ({ input }) => {
116
+ const { id, data } = input;
117
+ const account = await this.prismaService.account.update({
118
+ where: { id },
119
+ data,
120
+ });
121
+ return account;
122
+ }),
123
+ delete: this.trpcService.protectedProcedure
124
+ .input(z.string())
125
+ .mutation(async ({ input: id }) => {
126
+ await this.prismaService.account.delete({ where: { id } });
127
+ return id;
128
+ }),
129
+ });
130
+
131
+ feedRouter = this.trpcService.router({
132
+ list: this.trpcService.protectedProcedure
133
+ .input(
134
+ z.object({
135
+ limit: z.number().min(1).max(100).nullish(),
136
+ cursor: z.string().nullish(),
137
+ }),
138
+ )
139
+ .query(async ({ input }) => {
140
+ const limit = input.limit ?? 50;
141
+ const { cursor } = input;
142
+
143
+ const items = await this.prismaService.feed.findMany({
144
+ take: limit + 1,
145
+ where: {},
146
+ cursor: cursor
147
+ ? {
148
+ id: cursor,
149
+ }
150
+ : undefined,
151
+ orderBy: {
152
+ createdAt: 'asc',
153
+ },
154
+ });
155
+ let nextCursor: typeof cursor | undefined = undefined;
156
+ if (items.length > limit) {
157
+ // Remove the last item and use it as next cursor
158
+
159
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
160
+ const nextItem = items.pop()!;
161
+ nextCursor = nextItem.id;
162
+ }
163
+
164
+ return {
165
+ items: items,
166
+ nextCursor,
167
+ };
168
+ }),
169
+ byId: this.trpcService.protectedProcedure
170
+ .input(z.string())
171
+ .query(async ({ input: id }) => {
172
+ const feed = await this.prismaService.feed.findUnique({
173
+ where: { id },
174
+ });
175
+ if (!feed) {
176
+ throw new TRPCError({
177
+ code: 'BAD_REQUEST',
178
+ message: `No feed with id '${id}'`,
179
+ });
180
+ }
181
+ return feed;
182
+ }),
183
+ add: this.trpcService.protectedProcedure
184
+ .input(
185
+ z.object({
186
+ id: z.string(),
187
+ mpName: z.string(),
188
+ mpCover: z.string(),
189
+ mpIntro: z.string(),
190
+ syncTime: z
191
+ .number()
192
+ .optional()
193
+ .default(Math.floor(Date.now() / 1e3)),
194
+ updateTime: z.number(),
195
+ status: z.number().default(statusMap.ENABLE),
196
+ }),
197
+ )
198
+ .mutation(async ({ input }) => {
199
+ const { id, ...data } = input;
200
+ const feed = await this.prismaService.feed.upsert({
201
+ where: {
202
+ id,
203
+ },
204
+ update: data,
205
+ create: input,
206
+ });
207
+
208
+ return feed;
209
+ }),
210
+ edit: this.trpcService.protectedProcedure
211
+ .input(
212
+ z.object({
213
+ id: z.string(),
214
+ data: z.object({
215
+ mpName: z.string().optional(),
216
+ mpCover: z.string().optional(),
217
+ mpIntro: z.string().optional(),
218
+ syncTime: z.number().optional(),
219
+ updateTime: z.number().optional(),
220
+ status: z.number().optional(),
221
+ }),
222
+ }),
223
+ )
224
+ .mutation(async ({ input }) => {
225
+ const { id, data } = input;
226
+ const feed = await this.prismaService.feed.update({
227
+ where: { id },
228
+ data,
229
+ });
230
+ return feed;
231
+ }),
232
+ delete: this.trpcService.protectedProcedure
233
+ .input(z.string())
234
+ .mutation(async ({ input: id }) => {
235
+ await this.prismaService.feed.delete({ where: { id } });
236
+ return id;
237
+ }),
238
+
239
+ refreshArticles: this.trpcService.protectedProcedure
240
+ .input(z.string())
241
+ .mutation(async ({ input: mpId }) => {
242
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId);
243
+ }),
244
+ });
245
+
246
+ articleRouter = this.trpcService.router({
247
+ list: this.trpcService.protectedProcedure
248
+ .input(
249
+ z.object({
250
+ limit: z.number().min(1).max(100).nullish(),
251
+ cursor: z.string().nullish(),
252
+ mpId: z.string().nullish(),
253
+ }),
254
+ )
255
+ .query(async ({ input }) => {
256
+ const limit = input.limit ?? 50;
257
+ const { cursor, mpId } = input;
258
+
259
+ const items = await this.prismaService.article.findMany({
260
+ orderBy: [
261
+ {
262
+ publishTime: 'desc',
263
+ },
264
+ ],
265
+ take: limit + 1,
266
+ where: mpId ? { mpId } : undefined,
267
+ cursor: cursor
268
+ ? {
269
+ id: cursor,
270
+ }
271
+ : undefined,
272
+ });
273
+ let nextCursor: typeof cursor | undefined = undefined;
274
+ if (items.length > limit) {
275
+ // Remove the last item and use it as next cursor
276
+
277
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
278
+ const nextItem = items.pop()!;
279
+ nextCursor = nextItem.id;
280
+ }
281
+
282
+ return {
283
+ items,
284
+ nextCursor,
285
+ };
286
+ }),
287
+ byId: this.trpcService.protectedProcedure
288
+ .input(z.string())
289
+ .query(async ({ input: id }) => {
290
+ const article = await this.prismaService.article.findUnique({
291
+ where: { id },
292
+ });
293
+ if (!article) {
294
+ throw new TRPCError({
295
+ code: 'BAD_REQUEST',
296
+ message: `No article with id '${id}'`,
297
+ });
298
+ }
299
+ return article;
300
+ }),
301
+
302
+ add: this.trpcService.protectedProcedure
303
+ .input(
304
+ z.object({
305
+ id: z.string(),
306
+ mpId: z.string(),
307
+ title: z.string(),
308
+ picUrl: z.string().optional().default(''),
309
+ publishTime: z.number(),
310
+ }),
311
+ )
312
+ .mutation(async ({ input }) => {
313
+ const { id, ...data } = input;
314
+ const article = await this.prismaService.article.upsert({
315
+ where: {
316
+ id,
317
+ },
318
+ update: data,
319
+ create: input,
320
+ });
321
+
322
+ return article;
323
+ }),
324
+ delete: this.trpcService.protectedProcedure
325
+ .input(z.string())
326
+ .mutation(async ({ input: id }) => {
327
+ await this.prismaService.article.delete({ where: { id } });
328
+ return id;
329
+ }),
330
+ });
331
+
332
+ platformRouter = this.trpcService.router({
333
+ getMpArticles: this.trpcService.protectedProcedure
334
+ .input(
335
+ z.object({
336
+ mpId: z.string(),
337
+ }),
338
+ )
339
+ .mutation(async ({ input: { mpId } }) => {
340
+ try {
341
+ const results = await this.trpcService.getMpArticles(mpId);
342
+ return results;
343
+ } catch (err: any) {
344
+ this.logger.log('getMpArticles err: ', err);
345
+ throw new TRPCError({
346
+ code: 'INTERNAL_SERVER_ERROR',
347
+ message: err.response?.data?.message || err.message,
348
+ cause: err.stack,
349
+ });
350
+ }
351
+ }),
352
+ getMpInfo: this.trpcService.protectedProcedure
353
+ .input(
354
+ z.object({
355
+ wxsLink: z
356
+ .string()
357
+ .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),
358
+ }),
359
+ )
360
+ .mutation(async ({ input: { wxsLink: url } }) => {
361
+ try {
362
+ const results = await this.trpcService.getMpInfo(url);
363
+ return results;
364
+ } catch (err: any) {
365
+ this.logger.log('getMpInfo err: ', err);
366
+ throw new TRPCError({
367
+ code: 'INTERNAL_SERVER_ERROR',
368
+ message: err.response?.data?.message || err.message,
369
+ cause: err.stack,
370
+ });
371
+ }
372
+ }),
373
+
374
+ createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {
375
+ return this.trpcService.createLoginUrl();
376
+ }),
377
+ getLoginResult: this.trpcService.protectedProcedure
378
+ .input(
379
+ z.object({
380
+ id: z.string(),
381
+ }),
382
+ )
383
+ .query(async ({ input }) => {
384
+ return this.trpcService.getLoginResult(input.id);
385
+ }),
386
+ });
387
+
388
+ appRouter = this.trpcService.router({
389
+ feed: this.feedRouter,
390
+ account: this.accountRouter,
391
+ article: this.articleRouter,
392
+ platform: this.platformRouter,
393
+ });
394
+
395
+ async applyMiddleware(app: INestApplication) {
396
+ app.use(
397
+ `/trpc`,
398
+ trpcExpress.createExpressMiddleware({
399
+ router: this.appRouter,
400
+ createContext: ({ req }) => {
401
+ const authCode =
402
+ this.configService.get<ConfigurationType['auth']>('auth')!.code;
403
+
404
+ if (req.headers.authorization !== authCode) {
405
+ return {
406
+ errorMsg: 'authCode不正确!',
407
+ };
408
+ }
409
+ return {
410
+ errorMsg: null,
411
+ };
412
+ },
413
+ middleware: (req, res, next) => {
414
+ next();
415
+ },
416
+ }),
417
+ );
418
+ }
419
+ }
420
+
421
+ export type AppRouter = TrpcRouter[`appRouter`];
apps/server/src/trpc/trpc.service.ts ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { ConfigurationType } from '@server/configuration';
4
+ import { statusMap } from '@server/constants';
5
+ import { PrismaService } from '@server/prisma/prisma.service';
6
+ import { TRPCError, initTRPC } from '@trpc/server';
7
+ import Axios, { AxiosInstance } from 'axios';
8
+ import dayjs from 'dayjs';
9
+ import timezone from 'dayjs/plugin/timezone';
10
+ import utc from 'dayjs/plugin/utc';
11
+
12
+ dayjs.extend(utc);
13
+ dayjs.extend(timezone);
14
+
15
+ /**
16
+ * 读书账号每日小黑屋
17
+ */
18
+ const blockedAccountsMap = new Map<string, string[]>();
19
+
20
+ @Injectable()
21
+ export class TrpcService {
22
+ trpc = initTRPC.create();
23
+ publicProcedure = this.trpc.procedure;
24
+ protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => {
25
+ const errorMsg = (ctx as any).errorMsg;
26
+ if (errorMsg) {
27
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg });
28
+ }
29
+ return next({ ctx });
30
+ });
31
+ router = this.trpc.router;
32
+ mergeRouters = this.trpc.mergeRouters;
33
+ request: AxiosInstance;
34
+
35
+ private readonly logger = new Logger(this.constructor.name);
36
+
37
+ constructor(
38
+ private readonly prismaService: PrismaService,
39
+ private readonly configService: ConfigService,
40
+ ) {
41
+ const { url } =
42
+ this.configService.get<ConfigurationType['platform']>('platform')!;
43
+ this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });
44
+
45
+ this.request.interceptors.response.use(
46
+ (response) => {
47
+ return response;
48
+ },
49
+ async (error) => {
50
+ this.logger.log('error: ', error);
51
+ const errMsg = error.response?.data?.message || '';
52
+
53
+ const id = (error.config.headers as any).xid;
54
+ if (errMsg.includes('WeReadError401')) {
55
+ // 账号失效
56
+ await this.prismaService.account.update({
57
+ where: { id },
58
+ data: { status: statusMap.INVALID },
59
+ });
60
+ this.logger.error(`账号(${id})登录失效,已禁用`);
61
+ } else {
62
+ if (errMsg.includes('WeReadError400')) {
63
+ // TODO 处理请求参数出错,可能是账号被限制导致的
64
+ this.logger.error(
65
+ `账号(${id})处理请求参数出错,可能是账号被限制导致的,打入小黑屋`,
66
+ );
67
+ this.logger.error('WeReadError400: ', errMsg);
68
+ } else if (errMsg.includes('WeReadError429')) {
69
+ //TODO 处理请求频繁
70
+ this.logger.error(`账号(${id})请求频繁,打入小黑屋`);
71
+ }
72
+
73
+ const today = this.getTodayDate();
74
+
75
+ const blockedAccounts = blockedAccountsMap.get(today);
76
+
77
+ if (Array.isArray(blockedAccounts)) {
78
+ blockedAccounts.push(id);
79
+ blockedAccountsMap.set(today, blockedAccounts);
80
+ } else {
81
+ blockedAccountsMap.set(today, [id]);
82
+ }
83
+ }
84
+
85
+ return Promise.reject(error);
86
+ },
87
+ );
88
+ }
89
+
90
+ private getTodayDate() {
91
+ return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');
92
+ }
93
+
94
+ getBlockedAccountIds() {
95
+ const today = this.getTodayDate();
96
+ const disabledAccounts = blockedAccountsMap.get(today) || [];
97
+ this.logger.debug('disabledAccounts: ', disabledAccounts);
98
+ return disabledAccounts.filter(Boolean);
99
+ }
100
+
101
+ private async getAvailableAccount() {
102
+ const disabledAccounts = this.getBlockedAccountIds();
103
+ const account = await this.prismaService.account.findFirst({
104
+ where: {
105
+ status: statusMap.ENABLE,
106
+ NOT: {
107
+ id: { in: disabledAccounts },
108
+ },
109
+ },
110
+ });
111
+
112
+ if (!account) {
113
+ throw new Error('暂无可用读书账号!');
114
+ }
115
+
116
+ return account;
117
+ }
118
+
119
+ async getMpArticles(mpId: string) {
120
+ const account = await this.getAvailableAccount();
121
+
122
+ return this.request
123
+ .get<
124
+ {
125
+ id: string;
126
+ title: string;
127
+ picUrl: string;
128
+ publishTime: number;
129
+ }[]
130
+ >(`/api/platform/mps/${mpId}/articles`, {
131
+ headers: {
132
+ xid: account.id,
133
+ Authorization: `Bearer ${account.token}`,
134
+ },
135
+ })
136
+ .then((res) => res.data)
137
+ .then((res) => {
138
+ this.logger.log(`getMpArticles(${mpId}): ${res.length} articles`);
139
+ return res;
140
+ });
141
+ }
142
+
143
+ async refreshMpArticlesAndUpdateFeed(mpId: string) {
144
+ const articles = await this.getMpArticles(mpId);
145
+
146
+ if (articles.length > 0) {
147
+ let results;
148
+ const { type } =
149
+ this.configService.get<ConfigurationType['database']>('database')!;
150
+ if (type === 'sqlite') {
151
+ // sqlite3 不支持 createMany
152
+ const inserts = articles.map(({ id, picUrl, publishTime, title }) =>
153
+ this.prismaService.article.upsert({
154
+ create: { id, mpId, picUrl, publishTime, title },
155
+ update: {
156
+ publishTime,
157
+ title,
158
+ },
159
+ where: { id },
160
+ }),
161
+ );
162
+ results = await this.prismaService.$transaction(inserts);
163
+ } else {
164
+ results = await (this.prismaService.article as any).createMany({
165
+ data: articles.map(({ id, picUrl, publishTime, title }) => ({
166
+ id,
167
+ mpId,
168
+ picUrl,
169
+ publishTime,
170
+ title,
171
+ })),
172
+ skipDuplicates: true,
173
+ });
174
+ }
175
+
176
+ this.logger.debug('refreshMpArticlesAndUpdateFeed results: ', results);
177
+ }
178
+
179
+ await this.prismaService.feed.update({
180
+ where: { id: mpId },
181
+ data: {
182
+ syncTime: Math.floor(Date.now() / 1e3),
183
+ },
184
+ });
185
+ }
186
+
187
+ async getMpInfo(url: string) {
188
+ url = url.trim();
189
+ const account = await this.getAvailableAccount();
190
+
191
+ return this.request
192
+ .post<
193
+ {
194
+ id: string;
195
+ cover: string;
196
+ name: string;
197
+ intro: string;
198
+ updateTime: number;
199
+ }[]
200
+ >(
201
+ `/api/platform/wxs2mp`,
202
+ { url },
203
+ {
204
+ headers: {
205
+ xid: account.id,
206
+ Authorization: `Bearer ${account.token}`,
207
+ },
208
+ },
209
+ )
210
+ .then((res) => res.data);
211
+ }
212
+
213
+ async createLoginUrl() {
214
+ return this.request
215
+ .post<{
216
+ uuid: string;
217
+ scanUrl: string;
218
+ }>(`/api/login/platform`)
219
+ .then((res) => res.data);
220
+ }
221
+
222
+ async getLoginResult(id: string) {
223
+ return this.request
224
+ .get<{
225
+ message: 'waiting' | 'success';
226
+ vid?: number;
227
+ token?: string;
228
+ username?: string;
229
+ }>(`/api/login/platform/${id}`)
230
+ .then((res) => res.data);
231
+ }
232
+ }
apps/server/test/app.e2e-spec.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { INestApplication } from '@nestjs/common';
3
+ import * as request from 'supertest';
4
+ import { AppModule } from './../src/app.module';
5
+
6
+ describe('AppController (e2e)', () => {
7
+ let app: INestApplication;
8
+
9
+ beforeEach(async () => {
10
+ const moduleFixture: TestingModule = await Test.createTestingModule({
11
+ imports: [AppModule],
12
+ }).compile();
13
+
14
+ app = moduleFixture.createNestApplication();
15
+ await app.init();
16
+ });
17
+
18
+ it('/ (GET)', () => {
19
+ return request(app.getHttpServer())
20
+ .get('/')
21
+ .expect(200)
22
+ .expect('Hello World!');
23
+ });
24
+ });
apps/server/test/jest-e2e.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "moduleFileExtensions": ["js", "json", "ts"],
3
+ "rootDir": ".",
4
+ "testEnvironment": "node",
5
+ "testRegex": ".e2e-spec.ts$",
6
+ "transform": {
7
+ "^.+\\.(t|j)s$": "ts-jest"
8
+ }
9
+ }
apps/server/tsconfig.build.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4
+ }
apps/server/tsconfig.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "declaration": true,
6
+ "removeComments": true,
7
+ "allowSyntheticDefaultImports": true,
8
+ "target": "ES2021",
9
+ "sourceMap": true,
10
+ "outDir": "./dist",
11
+ "esModuleInterop":true
12
+ }
13
+ }
apps/web/.env.local.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # 同SERVER_ORIGIN_URL
2
+ VITE_SERVER_ORIGIN_URL=http://localhost:4000
apps/web/.eslintrc.cjs ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ root: true,
3
+ env: { browser: true, es2020: true },
4
+ extends: [
5
+ 'eslint:recommended',
6
+ 'plugin:@typescript-eslint/recommended',
7
+ 'plugin:react-hooks/recommended',
8
+ ],
9
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
10
+ parser: '@typescript-eslint/parser',
11
+ plugins: ['react-refresh'],
12
+ rules: {
13
+ 'react-refresh/only-export-components': [
14
+ 'warn',
15
+ { allowConstantExport: true },
16
+ ],
17
+ '@typescript-eslint/no-explicit-any': 'warn',
18
+ },
19
+ };
apps/web/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
apps/web/README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## Expanding the ESLint configuration
11
+
12
+ If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13
+
14
+ - Configure the top-level `parserOptions` property like this:
15
+
16
+ ```js
17
+ export default {
18
+ // other rules...
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ sourceType: 'module',
22
+ project: ['./tsconfig.json', './tsconfig.node.json'],
23
+ tsconfigRootDir: __dirname,
24
+ },
25
+ }
26
+ ```
27
+
28
+ - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
29
+ - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
30
+ - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
apps/web/index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="https://r2-assets.111965.xyz/wewe-rss.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>WeWe RSS</title>
8
+ <meta name="description" content="更好的公众号订阅方式" />
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script>
13
+ window.__WEWE_RSS_SERVER_ORIGIN_URL__ = '{{ weweRssServerOriginUrl }}';
14
+ </script>
15
+ <script type="module" src="/src/main.tsx"></script>
16
+ </body>
17
+ </html>
apps/web/package.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "web",
3
+ "private": true,
4
+ "version": "1.7.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@nextui-org/react": "^2.2.9",
14
+ "@tanstack/react-query": "^4.35.3",
15
+ "@trpc/client": "^10.45.1",
16
+ "@trpc/next": "^10.45.1",
17
+ "@trpc/react-query": "^10.45.1",
18
+ "autoprefixer": "^10.0.1",
19
+ "dayjs": "^1.11.10",
20
+ "framer-motion": "^11.0.5",
21
+ "next-themes": "^0.2.1",
22
+ "postcss": "^8",
23
+ "qrcode.react": "^3.1.0",
24
+ "react": "^18.2.0",
25
+ "react-dom": "^18.2.0",
26
+ "react-router-dom": "^6.22.2",
27
+ "sonner": "^1.4.0",
28
+ "tailwindcss": "^3.3.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^20.11.24",
32
+ "@types/react": "^18.2.56",
33
+ "@types/react-dom": "^18.2.19",
34
+ "@typescript-eslint/eslint-plugin": "^7.0.2",
35
+ "@typescript-eslint/parser": "^7.0.2",
36
+ "@vitejs/plugin-react": "^4.2.1",
37
+ "eslint": "^8.56.0",
38
+ "eslint-plugin-react-hooks": "^4.6.0",
39
+ "eslint-plugin-react-refresh": "^0.4.5",
40
+ "typescript": "^5.2.2",
41
+ "vite": "^5.1.4"
42
+ }
43
+ }
apps/web/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
apps/web/src/App.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BrowserRouter, Route, Routes } from 'react-router-dom';
2
+ import Feeds from './pages/feeds';
3
+ import Login from './pages/login';
4
+ import Accounts from './pages/accounts';
5
+ import { BaseLayout } from './layouts/base';
6
+ import { TrpcProvider } from './provider/trpc';
7
+ import ThemeProvider from './provider/theme';
8
+
9
+ function App() {
10
+ return (
11
+ <BrowserRouter basename="/dash">
12
+ <ThemeProvider>
13
+ <TrpcProvider>
14
+ <Routes>
15
+ <Route path="/" element={<BaseLayout />}>
16
+ <Route index element={<Feeds />} />
17
+ <Route path="/feeds/:id?" element={<Feeds />} />
18
+ <Route path="/accounts" element={<Accounts />} />
19
+ <Route path="/login" element={<Login />} />
20
+ </Route>
21
+ </Routes>
22
+ </TrpcProvider>
23
+ </ThemeProvider>
24
+ </BrowserRouter>
25
+ );
26
+ }
27
+
28
+ export default App;
apps/web/src/components/GitHubIcon.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconSvgProps } from '../types';
2
+
3
+ export const GitHubIcon = ({
4
+ size = 24,
5
+ width,
6
+ height,
7
+ ...props
8
+ }: IconSvgProps) => (
9
+ <svg
10
+ aria-hidden="true"
11
+ fill="none"
12
+ focusable="false"
13
+ height={size || height}
14
+ role="presentation"
15
+ viewBox="0 0 24 24"
16
+ width={size || width}
17
+ {...props}
18
+ >
19
+ <path
20
+ clipRule="evenodd"
21
+ d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
22
+ fill="currentColor"
23
+ fillRule="evenodd"
24
+ ></path>
25
+ </svg>
26
+ );
apps/web/src/components/Nav.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Badge,
3
+ Image,
4
+ Link,
5
+ Navbar,
6
+ NavbarBrand,
7
+ NavbarContent,
8
+ NavbarItem,
9
+ Tooltip,
10
+ } from '@nextui-org/react';
11
+ import { ThemeSwitcher } from './ThemeSwitcher';
12
+ import { GitHubIcon } from './GitHubIcon';
13
+ import { useLocation } from 'react-router-dom';
14
+ import { appVersion } from '@web/utils/env';
15
+ import { useEffect, useState } from 'react';
16
+
17
+ const navbarItemLink = [
18
+ {
19
+ href: '/feeds',
20
+ name: '公众号源',
21
+ },
22
+ {
23
+ href: '/accounts',
24
+ name: '账号管理',
25
+ },
26
+ // {
27
+ // href: '/settings',
28
+ // name: '设置',
29
+ // },
30
+ ];
31
+
32
+ const Nav = () => {
33
+ const { pathname } = useLocation();
34
+ const [releaseVersion, setReleaseVersion] = useState(appVersion);
35
+
36
+ useEffect(() => {
37
+ fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest')
38
+ .then((res) => res.json())
39
+ .then((data) => {
40
+ setReleaseVersion(data.name.replace('v', ''));
41
+ });
42
+ }, []);
43
+
44
+ const isFoundNewVersion = releaseVersion > appVersion;
45
+ console.log('isFoundNewVersion: ', isFoundNewVersion);
46
+
47
+ return (
48
+ <div>
49
+ <Navbar isBordered>
50
+ <Tooltip
51
+ content={
52
+ <div className="p-1">
53
+ {isFoundNewVersion && (
54
+ <Link
55
+ href={`https://github.com/cooderl/wewe-rss/releases/latest`}
56
+ target="_blank"
57
+ className="mb-1 block text-medium"
58
+ >
59
+ 发现新版本:v{releaseVersion}
60
+ </Link>
61
+ )}
62
+ 当前版本: v{appVersion}
63
+ </div>
64
+ }
65
+ placement="left"
66
+ >
67
+ <NavbarBrand className="cursor-default">
68
+ <Badge
69
+ content={isFoundNewVersion ? '' : null}
70
+ color="danger"
71
+ size="sm"
72
+ >
73
+ <Image
74
+ width={28}
75
+ alt="WeWe RSS"
76
+ className="mr-2"
77
+ src="https://r2-assets.111965.xyz/wewe-rss.png"
78
+ ></Image>
79
+ </Badge>
80
+ <p className="font-bold text-inherit">WeWe RSS</p>
81
+ </NavbarBrand>
82
+ </Tooltip>
83
+ <NavbarContent className="hidden sm:flex gap-4" justify="center">
84
+ {navbarItemLink.map((item) => {
85
+ return (
86
+ <NavbarItem
87
+ isActive={pathname.startsWith(item.href)}
88
+ key={item.href}
89
+ >
90
+ <Link color="foreground" href={item.href}>
91
+ {item.name}
92
+ </Link>
93
+ </NavbarItem>
94
+ );
95
+ })}
96
+ </NavbarContent>
97
+ <NavbarContent justify="end">
98
+ <ThemeSwitcher></ThemeSwitcher>
99
+ <Link
100
+ href="https://github.com/cooderl/wewe-rss"
101
+ target="_blank"
102
+ color="foreground"
103
+ >
104
+ <GitHubIcon />
105
+ </Link>
106
+ </NavbarContent>
107
+ </Navbar>
108
+ </div>
109
+ );
110
+ };
111
+
112
+ export default Nav;
apps/web/src/components/PlusIcon.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconSvgProps } from '../types';
2
+
3
+ export const PlusIcon = ({
4
+ size = 24,
5
+ width,
6
+ height,
7
+ ...props
8
+ }: IconSvgProps) => (
9
+ <svg
10
+ aria-hidden="true"
11
+ fill="none"
12
+ focusable="false"
13
+ height={size || height}
14
+ role="presentation"
15
+ viewBox="0 0 24 24"
16
+ width={size || width}
17
+ {...props}
18
+ >
19
+ <g
20
+ fill="none"
21
+ stroke="currentColor"
22
+ strokeLinecap="round"
23
+ strokeLinejoin="round"
24
+ strokeWidth={1.5}
25
+ >
26
+ <path d="M6 12h12" />
27
+ <path d="M12 18V6" />
28
+ </g>
29
+ </svg>
30
+ );
apps/web/src/components/StatusDropdown.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import {
3
+ Dropdown,
4
+ DropdownTrigger,
5
+ DropdownMenu,
6
+ DropdownItem,
7
+ Button,
8
+ } from '@nextui-org/react';
9
+ import { statusMap } from '@web/constants';
10
+
11
+ export function StatusDropdown({
12
+ value = 1,
13
+ onChange,
14
+ }: {
15
+ value: number;
16
+ onChange: (value: number) => void;
17
+ }) {
18
+ return (
19
+ <Dropdown>
20
+ <DropdownTrigger>
21
+ <Button size="sm" variant="bordered" className="capitalize">
22
+ {statusMap[value].label}
23
+ </Button>
24
+ </DropdownTrigger>
25
+ <DropdownMenu
26
+ disabledKeys={['0']}
27
+ aria-label="状态设置"
28
+ variant="flat"
29
+ disallowEmptySelection
30
+ selectionMode="single"
31
+ selectedKeys={[`${value}`]}
32
+ onSelectionChange={(keys) => {
33
+ onChange(+Array.from(keys)[0]);
34
+ }}
35
+ >
36
+ {Object.entries(statusMap).map(([key, value]) => {
37
+ return (
38
+ <DropdownItem color={value.color} key={`${key}`} value={`${key}`}>
39
+ {value.label}
40
+ </DropdownItem>
41
+ );
42
+ })}
43
+ </DropdownMenu>
44
+ </Dropdown>
45
+ );
46
+ }
apps/web/src/components/ThemeSwitcher.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { VisuallyHidden, useSwitch } from '@nextui-org/react';
4
+ import { useTheme } from 'next-themes';
5
+
6
+ export const MoonIcon = (props) => (
7
+ <svg
8
+ aria-hidden="true"
9
+ focusable="false"
10
+ height="1em"
11
+ role="presentation"
12
+ viewBox="0 0 24 24"
13
+ width="1em"
14
+ {...props}
15
+ >
16
+ <path
17
+ d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
18
+ fill="currentColor"
19
+ />
20
+ </svg>
21
+ );
22
+
23
+ export const SunIcon = (props) => (
24
+ <svg
25
+ aria-hidden="true"
26
+ focusable="false"
27
+ height="1em"
28
+ role="presentation"
29
+ viewBox="0 0 24 24"
30
+ width="1em"
31
+ {...props}
32
+ >
33
+ <g fill="currentColor">
34
+ <path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
35
+ <path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
36
+ </g>
37
+ </svg>
38
+ );
39
+
40
+ export function ThemeSwitcher(props) {
41
+ const { setTheme, theme } = useTheme();
42
+ const {
43
+ Component,
44
+ slots,
45
+ isSelected,
46
+ getBaseProps,
47
+ getInputProps,
48
+ getWrapperProps,
49
+ } = useSwitch({
50
+ onClick: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
51
+ isSelected: theme === 'dark',
52
+ });
53
+
54
+ return (
55
+ <div className="flex flex-col gap-2">
56
+ <Component {...getBaseProps()}>
57
+ <VisuallyHidden>
58
+ <input {...getInputProps()} />
59
+ </VisuallyHidden>
60
+ <div
61
+ {...getWrapperProps()}
62
+ className={slots.wrapper({
63
+ class: [
64
+ 'w-8 h-8',
65
+ 'flex items-center justify-center',
66
+ 'rounded-lg bg-default-100 hover:bg-default-200',
67
+ ],
68
+ })}
69
+ >
70
+ {isSelected ? <SunIcon /> : <MoonIcon />}
71
+ </div>
72
+ </Component>
73
+ </div>
74
+ );
75
+ }
apps/web/src/constants.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export const statusMap = {
2
+ 0: { label: '失效', color: 'danger' },
3
+ 1: { label: '启用', color: 'success' },
4
+ 2: { label: '禁用', color: 'warning' },
5
+ } as const;
apps/web/src/index.css ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;